fix(frontend): persistance immédiate de l'historique des prompts à chaque envoi — #323 (QA verte)
Symptôme: l'historique du composer CLI custom (flèche haut) n'était écrit dans localStorage qu'via un effet sur l'état, sans garantie au moment de l'envoi — une fermeture brutale/crash perdait la dernière entrée. - writeComposerHistory retourne désormais le succès de l'écriture; à chaque envoi, l'entrée est ajoutée et écrite AVANT l'invocation de l'agent (source de vérité par ref + état, réinit de l'index de navigation et du brouillon d'historique) - l'effet d'écriture global est supprimé: l'historique n'est plus réécrit en boucle, seulement aux envois (et lectures au changement de scope projet/layout/node) - si l'écriture échoue, l'envoi part quand même et un avertissement discret « Historique des prompts non sauvegardé. » (role=status) s'affiche au-dessus du composer - tests: persistance vérifiée au moment de l'appel sendAgentChat, envoi conservé + avertissement affiché quand localStorage échoue QA verte sur fix/batch-322-323-session-restore-prompt-history. Aucun push.
This commit is contained in:
@ -864,6 +864,66 @@ describe("CustomAgentChatView", () => {
|
|||||||
await waitFor(() => expect(composer.value).toBe("remember me"));
|
await waitFor(() => expect(composer.value).toBe("remember me"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("persists an accepted text prompt before invoking the agent", async () => {
|
||||||
|
const historyKey = "idea.custom-chat-history.project-1.layout-1.node-1.agent-1";
|
||||||
|
let historyAtSend: string[] = [];
|
||||||
|
const agent = {
|
||||||
|
launchAgentChat: vi.fn(),
|
||||||
|
reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [] })),
|
||||||
|
sendAgentChat: vi.fn(
|
||||||
|
async (_sessionId: string, _prompt: string, onChunk: (chunk: ReplyChunk) => void) => {
|
||||||
|
historyAtSend = JSON.parse(window.localStorage.getItem(historyKey) ?? "[]");
|
||||||
|
onChunk({ kind: "final", content: "done" });
|
||||||
|
},
|
||||||
|
),
|
||||||
|
cancelAgentChat: vi.fn(async () => {}),
|
||||||
|
closeAgentChat: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<DIProvider gateways={{ agent, system: { pickFile: vi.fn(async () => null) } } as unknown as Gateways}>
|
||||||
|
<CustomAgentChatView
|
||||||
|
projectId="project-1"
|
||||||
|
agentId="agent-1"
|
||||||
|
agentName="Worker"
|
||||||
|
profile={profile}
|
||||||
|
cwd="/repo"
|
||||||
|
layoutId="layout-1"
|
||||||
|
nodeId="node-1"
|
||||||
|
sessionId="chat-session-1"
|
||||||
|
conversationId="conversation-1"
|
||||||
|
onSessionId={vi.fn()}
|
||||||
|
onConversationId={vi.fn()}
|
||||||
|
/>
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const composer = await screen.findByLabelText(/message CLI custom/);
|
||||||
|
fireEvent.change(composer, { target: { value: "persist before send" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledOnce());
|
||||||
|
expect(historyAtSend).toEqual(["persist before send"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps sending when prompt-history persistence fails and reports it discreetly", async () => {
|
||||||
|
const setItem = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
||||||
|
throw new Error("storage unavailable");
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const agent = renderHistoryChat();
|
||||||
|
const composer = await screen.findByLabelText(/message CLI custom/);
|
||||||
|
fireEvent.change(composer, { target: { value: "still send" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledOnce());
|
||||||
|
expect(screen.getByRole("status").textContent).toContain(
|
||||||
|
"Historique des prompts non sauvegardé.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setItem.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("repaints attachment labels from user prompt scrollback", async () => {
|
it("repaints attachment labels from user prompt scrollback", async () => {
|
||||||
const agent = {
|
const agent = {
|
||||||
launchAgentChat: vi.fn(),
|
launchAgentChat: vi.fn(),
|
||||||
|
|||||||
@ -472,15 +472,16 @@ function readComposerHistory(storageKey: string): string[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeComposerHistory(storageKey: string, history: string[]): void {
|
function writeComposerHistory(storageKey: string, history: string[]): boolean {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return true;
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(
|
window.localStorage.setItem(
|
||||||
storageKey,
|
storageKey,
|
||||||
JSON.stringify(history.slice(-COMPOSER_HISTORY_LIMIT)),
|
JSON.stringify(history.slice(-COMPOSER_HISTORY_LIMIT)),
|
||||||
);
|
);
|
||||||
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
/* local history only */
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1151,6 +1152,8 @@ export function CustomAgentChatView({
|
|||||||
const [composerHistory, setComposerHistory] = useState<string[]>(() =>
|
const [composerHistory, setComposerHistory] = useState<string[]>(() =>
|
||||||
readComposerHistory(composerHistoryKey),
|
readComposerHistory(composerHistoryKey),
|
||||||
);
|
);
|
||||||
|
const composerHistoryRef = useRef(composerHistory);
|
||||||
|
const [composerHistoryWarning, setComposerHistoryWarning] = useState<string | null>(null);
|
||||||
const [composerHistoryIndex, setComposerHistoryIndex] =
|
const [composerHistoryIndex, setComposerHistoryIndex] =
|
||||||
useState<number | null>(null);
|
useState<number | null>(null);
|
||||||
const displayTurns = turns;
|
const displayTurns = turns;
|
||||||
@ -1304,15 +1307,13 @@ export function CustomAgentChatView({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const restored = readComposerHistory(composerHistoryKey);
|
const restored = readComposerHistory(composerHistoryKey);
|
||||||
|
composerHistoryRef.current = restored;
|
||||||
setComposerHistory(restored);
|
setComposerHistory(restored);
|
||||||
|
setComposerHistoryWarning(null);
|
||||||
setComposerHistoryIndex(null);
|
setComposerHistoryIndex(null);
|
||||||
draftBeforeHistoryNavigationRef.current = "";
|
draftBeforeHistoryNavigationRef.current = "";
|
||||||
}, [composerHistoryKey]);
|
}, [composerHistoryKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
writeComposerHistory(composerHistoryKey, composerHistory);
|
|
||||||
}, [composerHistory, composerHistoryKey]);
|
|
||||||
|
|
||||||
// Attachments can be large, so keep draft bytes only in this renderer's
|
// Attachments can be large, so keep draft bytes only in this renderer's
|
||||||
// bounded lifetime instead of serialising base64 into localStorage. The
|
// bounded lifetime instead of serialising base64 into localStorage. The
|
||||||
// project/layout/node scope mirrors the persisted text draft contract.
|
// project/layout/node scope mirrors the persisted text draft contract.
|
||||||
@ -2246,6 +2247,17 @@ export function CustomAgentChatView({
|
|||||||
async function send() {
|
async function send() {
|
||||||
const text = draft.trim();
|
const text = draft.trim();
|
||||||
if (!canSend || !agent.sendAgentChat) return;
|
if (!canSend || !agent.sendAgentChat) return;
|
||||||
|
if (text) {
|
||||||
|
const nextHistory = appendComposerHistoryEntry(composerHistoryRef.current, text);
|
||||||
|
composerHistoryRef.current = nextHistory;
|
||||||
|
const persisted = writeComposerHistory(composerHistoryKey, nextHistory);
|
||||||
|
setComposerHistory(nextHistory);
|
||||||
|
setComposerHistoryWarning(
|
||||||
|
persisted ? null : "Historique des prompts non sauvegardé.",
|
||||||
|
);
|
||||||
|
setComposerHistoryIndex(null);
|
||||||
|
draftBeforeHistoryNavigationRef.current = "";
|
||||||
|
}
|
||||||
const slashInvocation = slashCommandInvocation(text);
|
const slashInvocation = slashCommandInvocation(text);
|
||||||
if (slashInvocation) {
|
if (slashInvocation) {
|
||||||
await executeSlashCommand(slashInvocation);
|
await executeSlashCommand(slashInvocation);
|
||||||
@ -2263,11 +2275,6 @@ export function CustomAgentChatView({
|
|||||||
markProjectCliInteractionFinished(projectId, interactionId);
|
markProjectCliInteractionFinished(projectId, interactionId);
|
||||||
activeInteractionIdRef.current = null;
|
activeInteractionIdRef.current = null;
|
||||||
};
|
};
|
||||||
if (text) {
|
|
||||||
setComposerHistory((prev) => appendComposerHistoryEntry(prev, text));
|
|
||||||
setComposerHistoryIndex(null);
|
|
||||||
draftBeforeHistoryNavigationRef.current = "";
|
|
||||||
}
|
|
||||||
setDraft("");
|
setDraft("");
|
||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
imageTokenSeqRef.current = 0;
|
imageTokenSeqRef.current = 0;
|
||||||
@ -2705,6 +2712,11 @@ export function CustomAgentChatView({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{composerHistoryWarning && (
|
||||||
|
<p role="status" className="mb-1 text-[11px] text-warning">
|
||||||
|
{composerHistoryWarning}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="flex min-w-0 items-end gap-2">
|
<div className="flex min-w-0 items-end gap-2">
|
||||||
<div className="relative flex min-w-0 flex-1">
|
<div className="relative flex min-w-0 flex-1">
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
Reference in New Issue
Block a user