feat(chat): persistance bornee du brouillon custom chat par projet/layout (#182)

This commit is contained in:
2026-08-11 16:24:20 +02:00
parent 25ad44a3d9
commit 94b4ce7e5f
23 changed files with 1552 additions and 70 deletions

View File

@ -11,7 +11,7 @@ import {
PluginMenuRegistry,
PluginRuntimeRegistry,
} from "@/plugins/runtime";
import type { Gateways } from "@/ports";
import type { ChatDraftScope, Gateways } from "@/ports";
import { CustomAgentChatView } from "./CustomAgentChatView";
const profile: AgentProfile = {
@ -1718,6 +1718,317 @@ describe("CustomAgentChatView", () => {
);
});
it("restores and saves drafts scoped by project, layout, and node", async () => {
const drafts = new Map<string, string>([
["project-1::layout-a::node-1", "restored draft"],
]);
const key = (scope: ChatDraftScope) =>
`${scope.projectId}::${scope.layoutId}::${scope.nodeId}`;
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async () => {}),
readChatDraft: vi.fn(async (scope: ChatDraftScope) => ({
text: drafts.get(key(scope)) ?? null,
updatedAtMs: null,
})),
saveChatDraft: vi.fn(async (scope: ChatDraftScope, text: string) => {
if (text) drafts.set(key(scope), text);
else drafts.delete(key(scope));
}),
clearChatDraft: vi.fn(async (scope: ChatDraftScope) => {
drafts.delete(key(scope));
}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const baseProps = {
agentId: "agent-1",
agentName: "Worker",
profile,
cwd: "/repo",
nodeId: "node-1",
sessionId: "chat-session-1",
conversationId: "conversation-1",
onSessionId: vi.fn(),
onConversationId: vi.fn(),
};
const { rerender } = render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
{...baseProps}
projectId="project-1"
layoutId="layout-a"
/>
</DIProvider>,
);
const composer = await screen.findByDisplayValue("restored draft");
fireEvent.change(composer, { target: { value: "edited draft" } });
await waitFor(() =>
expect(agent.saveChatDraft).toHaveBeenCalledWith(
{ projectId: "project-1", layoutId: "layout-a", nodeId: "node-1" },
"edited draft",
),
);
rerender(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
{...baseProps}
projectId="project-1"
layoutId="layout-b"
/>
</DIProvider>,
);
await waitFor(() =>
expect((screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement).value).toBe(""),
);
rerender(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
{...baseProps}
projectId="project-1"
layoutId="layout-a"
/>
</DIProvider>,
);
await screen.findByDisplayValue("edited draft");
rerender(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
{...baseProps}
projectId="project-2"
layoutId="layout-a"
/>
</DIProvider>,
);
await waitFor(() =>
expect((screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement).value).toBe(""),
);
});
it("does not persist attachments with the restored draft", async () => {
const drafts = new Map<string, string>();
const scope = { projectId: "project-1", layoutId: "layout-a", nodeId: "node-1" };
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async () => {}),
readChatDraft: vi.fn(async () => ({
text: drafts.get("draft") ?? null,
updatedAtMs: null,
})),
saveChatDraft: vi.fn(async (_scope: ChatDraftScope, text: string) => {
drafts.set("draft", text);
}),
clearChatDraft: vi.fn(async () => {
drafts.delete("draft");
}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const renderView = () =>
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId={scope.projectId}
layoutId={scope.layoutId}
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId={scope.nodeId}
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
const first = renderView();
await waitFor(() =>
expect(agent.readChatDraft).toHaveBeenCalledWith(scope),
);
const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
fireEvent.change(composer, { target: { value: "draft with file" } });
const file = new File(["ignored"], "not-persisted.png", { type: "image/png" });
Object.defineProperty(file, "arrayBuffer", {
value: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer),
});
fireEvent.paste(composer, {
clipboardData: {
items: [
{
kind: "file",
type: "image/png",
getAsFile: () => file,
},
],
getData: () => "",
},
});
await screen.findByText("Fichier joint: not-persisted.png");
await waitFor(() =>
expect(agent.saveChatDraft).toHaveBeenCalledWith(scope, "draft with file"),
);
first.unmount();
renderView();
await screen.findByDisplayValue("draft with file");
expect(screen.queryByText("Fichier joint: not-persisted.png")).toBeNull();
});
it("clears the persisted draft when sending the composer", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async () => {}),
readChatDraft: vi.fn(async () => ({ text: "to send", updatedAtMs: null })),
saveChatDraft: vi.fn(async () => {}),
clearChatDraft: vi.fn(async () => {}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const scope = { projectId: "project-1", layoutId: "layout-a", nodeId: "node-1" };
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId={scope.projectId}
layoutId={scope.layoutId}
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId={scope.nodeId}
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
await screen.findByDisplayValue("to send");
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1));
expect(agent.clearChatDraft).toHaveBeenCalledWith(scope);
expect((screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement).value).toBe("");
});
it("clears the persisted draft when a slash command is consumed", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async () => {}),
executeSlashCommand: vi.fn(async () => ({
command: {
name: "/help",
shortDescription: "Afficher les commandes disponibles",
requiresConfirmation: false,
availability: { status: "available" as const },
source: "native" as const,
native: "help" as const,
},
effect: {
kind: "help" as const,
commands: [],
},
})),
readChatDraft: vi.fn(async () => ({ text: "/help", updatedAtMs: null })),
saveChatDraft: vi.fn(async () => {}),
clearChatDraft: vi.fn(async () => {}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const scope = { projectId: "project-1", layoutId: "layout-a", nodeId: "node-1" };
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId={scope.projectId}
layoutId={scope.layoutId}
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId={scope.nodeId}
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
await screen.findByDisplayValue("/help");
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() =>
expect(agent.executeSlashCommand).toHaveBeenCalledWith("/help", {
sessionId: "chat-session-1",
}),
);
expect(agent.clearChatDraft).toHaveBeenCalledWith(scope);
expect((screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement).value).toBe("");
});
it("keeps the chat shell bounded with a scrollable message area and fixed composer", async () => {
const agent = {
launchAgentChat: vi.fn(() => new Promise<never>(() => {})),

View File

@ -28,7 +28,7 @@ import type {
import { useGateways } from "@/app/di";
import { usePluginRuntime } from "@/features/plugins/PluginRuntimeProvider";
import { Button, Spinner, cn } from "@/shared";
import type { ChatAttachmentInput } from "@/ports";
import type { ChatAttachmentInput, ChatDraftScope } from "@/ports";
export interface CustomAgentChatViewProps {
projectId: string;
@ -36,6 +36,7 @@ export interface CustomAgentChatViewProps {
agentName: string;
profile: AgentProfile;
cwd: string;
layoutId?: string;
nodeId: string;
sessionId: string | null;
conversationId: string | null;
@ -464,6 +465,7 @@ export function CustomAgentChatView({
agentName,
profile,
cwd,
layoutId,
nodeId,
sessionId,
conversationId,
@ -490,6 +492,12 @@ export function CustomAgentChatView({
const sessionRef = useRef<string | null>(sessionId);
sessionRef.current = currentSession;
const slashQuerySeqRef = useRef(0);
const draftSaveSeqRef = useRef(0);
const draftHydratedRef = useRef(false);
const draftEditedBeforeHydrationRef = useRef(false);
const lastPersistedDraftRef = useRef("");
const draftRef = useRef("");
draftRef.current = draft;
const mountedRef = useRef(false);
const openOrAttachCountRef = useRef(0);
const selfEmittedSessionIdRef = useRef<string | null | undefined>(undefined);
@ -507,6 +515,30 @@ export function CustomAgentChatView({
agent.closeAgentChat,
);
const slashPrefix = slashCommandPrefix(draft);
const draftScope = useMemo<ChatDraftScope | null>(
() => (layoutId ? { projectId, layoutId, nodeId } : null),
[layoutId, nodeId, projectId],
);
const clearPersistedDraft = useCallback(async () => {
if (!draftScope) return;
draftSaveSeqRef.current += 1;
lastPersistedDraftRef.current = "";
try {
if (agent.clearChatDraft) {
await agent.clearChatDraft(draftScope);
} else if (agent.saveChatDraft) {
await agent.saveChatDraft(draftScope, "");
}
} catch (e) {
console.warn("[chat-draft] clear failed", instrumentationError(e));
}
}, [agent, draftScope]);
const editDraft = useCallback((text: string) => {
if (!draftHydratedRef.current) draftEditedBeforeHydrationRef.current = true;
setDraft(text);
}, []);
useEffect(() => {
mountedRef.current = true;
@ -520,6 +552,79 @@ export function CustomAgentChatView({
if (chunk.kind === "final" || chunk.kind === "error") setBusy(false);
}, []);
useEffect(() => {
draftSaveSeqRef.current += 1;
draftHydratedRef.current = false;
draftEditedBeforeHydrationRef.current = false;
lastPersistedDraftRef.current = "";
setDraft("");
if (!draftScope || !agent.readChatDraft) {
draftHydratedRef.current = true;
return;
}
let cancelled = false;
agent
.readChatDraft(draftScope)
.then((snapshot) => {
if (cancelled || !mountedRef.current) return;
const restored = snapshot.text ?? "";
lastPersistedDraftRef.current = restored;
if (!draftEditedBeforeHydrationRef.current) {
setDraft(restored);
}
})
.catch((e) => {
if (!cancelled) {
console.warn("[chat-draft] read failed", instrumentationError(e));
}
})
.finally(() => {
if (!cancelled) {
draftHydratedRef.current = true;
if (
draftEditedBeforeHydrationRef.current &&
agent.saveChatDraft &&
draftRef.current !== lastPersistedDraftRef.current
) {
const text = draftRef.current;
void agent
.saveChatDraft(draftScope, text)
.then(() => {
lastPersistedDraftRef.current = text;
})
.catch((e) => {
console.warn("[chat-draft] save failed", instrumentationError(e));
});
}
}
});
return () => {
cancelled = true;
};
}, [agent, draftScope]);
useEffect(() => {
if (!draftScope || !agent.saveChatDraft || !draftHydratedRef.current) return;
if (draft === lastPersistedDraftRef.current) return;
const seq = ++draftSaveSeqRef.current;
const timeout = window.setTimeout(() => {
if (draftSaveSeqRef.current !== seq) return;
agent
.saveChatDraft!(draftScope, draft)
.then(() => {
if (draftSaveSeqRef.current === seq) {
lastPersistedDraftRef.current = draft;
}
})
.catch((e) => {
console.warn("[chat-draft] save failed", instrumentationError(e));
});
}, 250);
return () => window.clearTimeout(timeout);
}, [agent, draft, draftScope]);
useEffect(() => {
if (
!supported ||
@ -554,12 +659,12 @@ export function CustomAgentChatView({
const selectSlashCommand = useCallback((command: SlashCommand) => {
if (!isSlashCommandAvailable(command)) return;
setDraft(`${command.name} `);
editDraft(`${command.name} `);
setSlashMenuOpen(false);
setSlashCommands([]);
setSlashActiveIndex(0);
requestAnimationFrame(() => composerRef.current?.focus());
}, []);
}, [editDraft]);
const publishSessionId = useCallback((nextSessionId: string | null) => {
selfEmittedSessionIdRef.current = nextSessionId;
@ -887,6 +992,7 @@ export function CustomAgentChatView({
return;
}
setDraft("");
void clearPersistedDraft();
setError(null);
try {
const sid =
@ -979,6 +1085,7 @@ export function CustomAgentChatView({
return unique.length > 0 ? [...prev, ...unique] : prev;
});
if (pastedText) {
if (!draftHydratedRef.current) draftEditedBeforeHydrationRef.current = true;
setDraft((prev) =>
prev.slice(0, selectionStart) +
pastedText +
@ -1004,6 +1111,7 @@ export function CustomAgentChatView({
const displayText = text || "Pièce jointe";
setDraft("");
setAttachments([]);
void clearPersistedDraft();
setBusy(true);
setError(null);
setTurns((prev) => [
@ -1305,7 +1413,7 @@ export function CustomAgentChatView({
value={draft}
disabled={!supported || opening || busy}
placeholder="Message à l'agent…"
onChange={(e) => setDraft(e.target.value)}
onChange={(e) => editDraft(e.target.value)}
onPaste={(e) => void pasteClipboardFiles(e)}
onKeyDown={(e) => {
if (slashMenuOpen && slashCommands.length > 0) {

View File

@ -128,6 +128,7 @@ export function LayoutGrid({
vm={vm}
parentSplit={null}
projectId={projectId}
layoutId={layoutId}
workState={work.state}
refreshWorkState={work.refresh}
refitSignal={refitEpoch}
@ -145,6 +146,7 @@ interface NodeViewProps {
/** The enclosing split + this node's index in it, for the merge action. */
parentSplit: { container: string; index: number; siblings: number } | null;
projectId: string;
layoutId?: string;
workState: ProjectWorkState | null;
refreshWorkState: () => Promise<void>;
refitSignal: number;
@ -158,6 +160,7 @@ function NodeView({
vm,
parentSplit,
projectId,
layoutId,
workState,
refreshWorkState,
refitSignal,
@ -191,6 +194,7 @@ function NodeView({
vm={vm}
parentSplit={parentSplit}
projectId={projectId}
layoutId={layoutId}
workState={workState}
refreshWorkState={refreshWorkState}
refitSignal={refitSignal}
@ -203,6 +207,7 @@ function NodeView({
cwd={cwd}
vm={vm}
projectId={projectId}
layoutId={layoutId}
workState={workState}
refreshWorkState={refreshWorkState}
refitSignal={refitSignal}
@ -217,6 +222,7 @@ function NodeView({
cwd={cwd}
vm={vm}
projectId={projectId}
layoutId={layoutId}
workState={workState}
refreshWorkState={refreshWorkState}
refitSignal={refitSignal}
@ -236,6 +242,7 @@ interface LeafViewProps {
vm: LayoutViewModel;
parentSplit: { container: string; index: number; siblings: number } | null;
projectId: string;
layoutId?: string;
workState: ProjectWorkState | null;
refreshWorkState: () => Promise<void>;
refitSignal: number;
@ -339,6 +346,7 @@ function LeafView({
vm,
parentSplit,
projectId,
layoutId,
workState,
refreshWorkState,
refitSignal,
@ -1247,6 +1255,7 @@ function LeafView({
agentName={customCliAgent.name}
profile={customCliProfile}
cwd={cwd}
layoutId={layoutId}
nodeId={id}
sessionId={session}
conversationId={conversationId}
@ -1573,6 +1582,7 @@ interface SplitViewProps {
cwd: string;
vm: LayoutViewModel;
projectId: string;
layoutId?: string;
workState: ProjectWorkState | null;
refreshWorkState: () => Promise<void>;
refitSignal: number;
@ -1585,6 +1595,7 @@ function SplitView({
cwd,
vm,
projectId,
layoutId,
workState,
refreshWorkState,
refitSignal,
@ -1631,6 +1642,7 @@ function SplitView({
cwd={cwd}
vm={vm}
projectId={projectId}
layoutId={layoutId}
workState={workState}
refreshWorkState={refreshWorkState}
refitSignal={refitSignal}
@ -1727,6 +1739,7 @@ interface GridViewProps {
cwd: string;
vm: LayoutViewModel;
projectId: string;
layoutId?: string;
workState: ProjectWorkState | null;
refreshWorkState: () => Promise<void>;
refitSignal: number;
@ -1739,6 +1752,7 @@ function GridView({
cwd,
vm,
projectId,
layoutId,
workState,
refreshWorkState,
refitSignal,
@ -1781,6 +1795,7 @@ function GridView({
vm={vm}
parentSplit={null}
projectId={projectId}
layoutId={layoutId}
workState={workState}
refreshWorkState={refreshWorkState}
refitSignal={refitSignal}