feat(chat): persistance bornee du brouillon custom chat par projet/layout (#182)
This commit is contained in:
@ -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>(() => {})),
|
||||
|
||||
Reference in New Issue
Block a user