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

@ -253,6 +253,33 @@ describe("TauriAgentGateway invoke payloads", () => {
expect(out.effect.kind).toBe("cleanConversation");
});
it("wraps chat draft persistence commands with the project/layout/node scope", async () => {
const gateway = new TauriAgentGateway();
const scope = {
projectId: "proj-1",
layoutId: "layout-1",
nodeId: "node-1",
};
invoke.mockResolvedValueOnce({ text: "hello", updatedAtMs: 42 });
await expect(gateway.readChatDraft(scope)).resolves.toEqual({
text: "hello",
updatedAtMs: 42,
});
await gateway.saveChatDraft(scope, "edited");
await gateway.clearChatDraft(scope);
expect(invoke).toHaveBeenNthCalledWith(1, "read_chat_draft", {
request: scope,
});
expect(invoke).toHaveBeenNthCalledWith(2, "save_chat_draft", {
request: { ...scope, text: "edited" },
});
expect(invoke).toHaveBeenNthCalledWith(3, "clear_chat_draft", {
request: scope,
});
});
it("launchAgentChat returns a handle only when launch_agent confirms cellKind chat", async () => {
invoke.mockResolvedValueOnce({
sessionId: "chat-session-1",

View File

@ -28,6 +28,8 @@ import type {
import type {
AgentChatHandle,
AgentGateway,
ChatDraftScope,
ChatDraftSnapshot,
ConversationDetails,
CreateAgentInput,
LiveAgent,
@ -297,6 +299,24 @@ export class TauriAgentGateway implements AgentGateway {
});
}
readChatDraft(scope: ChatDraftScope): Promise<ChatDraftSnapshot> {
return invoke<ChatDraftSnapshot>("read_chat_draft", {
request: scope,
});
}
async saveChatDraft(scope: ChatDraftScope, text: string): Promise<void> {
await invoke("save_chat_draft", {
request: { ...scope, text },
});
}
async clearChatDraft(scope: ChatDraftScope): Promise<void> {
await invoke("clear_chat_draft", {
request: scope,
});
}
async closeAgentChat(sessionId: string): Promise<void> {
await invoke("close_agent_session", { sessionId });
}

View File

@ -41,6 +41,8 @@ import type {
} from "@/domain";
import type {
AgentGateway,
ChatDraftScope,
ChatDraftSnapshot,
ConversationDetails,
CreateAgentInput,
CreateTicketInput,
@ -255,6 +257,18 @@ export class HttpAgentGateway implements AgentGateway {
});
}
readChatDraft(scope: ChatDraftScope): Promise<ChatDraftSnapshot> {
return this.http.invoke<ChatDraftSnapshot>("read_chat_draft", { request: scope });
}
async saveChatDraft(scope: ChatDraftScope, text: string): Promise<void> {
await this.http.invoke("save_chat_draft", { request: { ...scope, text } });
}
async clearChatDraft(scope: ChatDraftScope): Promise<void> {
await this.http.invoke("clear_chat_draft", { request: scope });
}
async launchAgent(
projectId: string,
agentId: string,

View File

@ -103,6 +103,8 @@ import type {
import type {
AgentChatHandle,
AgentGateway,
ChatDraftScope,
ChatDraftSnapshot,
ConversationGateway,
ConversationPageRequest,
ConversationDetails,
@ -401,6 +403,8 @@ export class MockAgentGateway implements AgentGateway {
private liveKindByAgent = new Map<string, "pty" | "structured">();
/** Retained structured reply chunks per live chat session. */
private chatScrollback = new Map<string, ReplyChunk[]>();
/** Persisted custom chat drafts keyed by project/layout/node. */
private chatDrafts = new Map<string, ChatDraftSnapshot>();
private slashCommands: SlashCommand[] = [
{
name: "/help",
@ -437,6 +441,10 @@ export class MockAgentGateway implements AgentGateway {
return `${projectId}::${agentId}`;
}
private chatDraftKey(scope: ChatDraftScope): string {
return `${scope.projectId}::${scope.layoutId}::${scope.nodeId}`;
}
async listAgents(projectId: string): Promise<Agent[]> {
return structuredClone(this.getAgents(projectId));
}
@ -985,6 +993,28 @@ export class MockAgentGateway implements AgentGateway {
};
}
async readChatDraft(scope: ChatDraftScope): Promise<ChatDraftSnapshot> {
return structuredClone(
this.chatDrafts.get(this.chatDraftKey(scope)) ?? { text: null, updatedAtMs: null },
);
}
async saveChatDraft(scope: ChatDraftScope, text: string): Promise<void> {
const key = this.chatDraftKey(scope);
if (text.length === 0) {
this.chatDrafts.delete(key);
return;
}
this.chatDrafts.set(key, {
text,
updatedAtMs: Date.now(),
});
}
async clearChatDraft(scope: ChatDraftScope): Promise<void> {
this.chatDrafts.delete(this.chatDraftKey(scope));
}
async closeAgentChat(sessionId: string): Promise<void> {
this.chatScrollback.delete(sessionId);
for (const [agentId, liveSessionId] of this.liveSessionByAgent) {