feat(chat): persistance bornee du brouillon custom chat par projet/layout (#182)
This commit is contained in:
@ -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",
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>(() => {})),
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -166,6 +166,17 @@ export interface SendAgentChatOptions {
|
||||
attachments?: ChatAttachmentInput[];
|
||||
}
|
||||
|
||||
export interface ChatDraftScope {
|
||||
projectId: string;
|
||||
layoutId: string;
|
||||
nodeId: string;
|
||||
}
|
||||
|
||||
export interface ChatDraftSnapshot {
|
||||
text: string | null;
|
||||
updatedAtMs?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort enriched details about a CLI conversation (T7), used to enrich the
|
||||
* resume popup. Both fields are optional: a missing inspector or a missing
|
||||
@ -317,6 +328,12 @@ export interface AgentGateway {
|
||||
name: string,
|
||||
options?: { sessionId?: string | null; arguments?: unknown[] },
|
||||
): Promise<ExecuteSlashCommandResult>;
|
||||
/** Reads a persisted custom chat draft scoped by project/layout/node. */
|
||||
readChatDraft?(scope: ChatDraftScope): Promise<ChatDraftSnapshot>;
|
||||
/** Saves a custom chat draft. Empty text is accepted but callers usually clear explicitly. */
|
||||
saveChatDraft?(scope: ChatDraftScope, text: string): Promise<void>;
|
||||
/** Clears a persisted custom chat draft. */
|
||||
clearChatDraft?(scope: ChatDraftScope): Promise<void>;
|
||||
/** Interrupts only the current turn of a live structured session. */
|
||||
cancelAgentChat?(sessionId: string): Promise<void>;
|
||||
/** Shuts a live structured session down. */
|
||||
|
||||
Reference in New Issue
Block a user