diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 0276aec..10e9016 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -745,11 +745,18 @@ export class MockAgentGateway implements AgentGateway { }; throw err; } - // Singleton invariant: refuse a launch when the agent is already live in a - // *different* cell (mirrors the backend `AGENT_ALREADY_RUNNING`). The same - // node is allowed (idempotent re-launch of the very same cell). + // Singleton invariant for the human native TUI surface: refuse a launch when + // the agent already has a PTY in a different cell. Structured/headless + // sessions are separate surfaces and must not make the human TUI look + // "already active elsewhere" (#169/#170). const liveNode = this.liveByAgent.get(agentId); - if (liveNode !== undefined && options.nodeId && liveNode !== options.nodeId) { + const liveKind = this.liveKindByAgent.get(agentId) ?? "pty"; + if ( + liveNode !== undefined && + liveKind === "pty" && + options.nodeId && + liveNode !== options.nodeId + ) { const err: GatewayError = { code: "AGENT_ALREADY_RUNNING", message: `agent ${agentId} is already running in cell ${liveNode}`, @@ -814,7 +821,13 @@ export class MockAgentGateway implements AgentGateway { } as GatewayError; } const liveNode = this.liveByAgent.get(agentId); - if (liveNode !== undefined && options.nodeId && liveNode !== options.nodeId) { + const liveKind = this.liveKindByAgent.get(agentId) ?? "pty"; + if ( + liveNode !== undefined && + liveKind === "pty" && + options.nodeId && + liveNode !== options.nodeId + ) { throw { code: "AGENT_ALREADY_RUNNING", message: `agent ${agentId} is already running in cell ${liveNode}`, diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index 10f6d90..b458f96 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -111,8 +111,9 @@ describe("CustomAgentChatView", () => { expect.any(Function), ), ); + expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull(); - fireEvent.click(await screen.findByRole("button", { name: "Cancel" })); + fireEvent.click(await screen.findByRole("button", { name: "Stop" })); await waitFor(() => expect(agent.cancelAgentChat).toHaveBeenCalledWith("chat-session-1"), @@ -1080,7 +1081,7 @@ describe("CustomAgentChatView", () => { expect(screen.getByText("Fichier: paste-image.png")).toBeTruthy(); }); - it("accepts pasted images exposed only through clipboardData.files", async () => { + it("accepts pasted generic files exposed only through clipboardData.files", async () => { const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(async (sessionId: string) => ({ @@ -1121,7 +1122,7 @@ describe("CustomAgentChatView", () => { ), ); - const file = new File(["ignored"], "files-only.png", { type: "image/png" }); + const file = new File(["ignored"], "files-only.pdf", { type: "application/pdf" }); Object.defineProperty(file, "arrayBuffer", { value: vi.fn(async () => new Uint8Array([4, 5, 6]).buffer), }); @@ -1133,7 +1134,8 @@ describe("CustomAgentChatView", () => { }, }); - await screen.findByText("Fichier joint: files-only.png"); + await screen.findByText("Fichier joint: files-only.pdf"); + expect(screen.queryByTestId("attachment-preview-files-only.pdf")).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1)); @@ -1144,9 +1146,9 @@ describe("CustomAgentChatView", () => { { attachments: [ { - filename: "files-only.png", + filename: "files-only.pdf", contentBase64: "BAUG", - mime: "image/png", + mime: "application/pdf", sourceKind: "clipboard", }, ], @@ -1194,14 +1196,12 @@ describe("CustomAgentChatView", () => { }); const scroll = screen.getByTestId("custom-agent-chat-scroll"); const composer = screen.getByTestId("custom-agent-chat-composer"); - const cancel = await screen.findByRole("button", { name: "Cancel" }); expect(shell.className).toContain("h-full"); expect(shell.className).toContain("min-h-0"); expect(shell.className).toContain("overflow-hidden"); expect(toolbar.className).toContain("overflow-hidden"); - expect(cancel.className).toContain("shrink-0"); - expect(cancel.className).toContain("z-30"); + expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull(); expect(scroll.className).toContain("flex-1"); expect(scroll.className).toContain("basis-0"); expect(scroll.className).toContain("overflow-y-auto"); @@ -1520,7 +1520,7 @@ describe("CustomAgentChatView", () => { fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalled()); - fireEvent.click(await screen.findByRole("button", { name: "Cancel" })); + fireEvent.click(await screen.findByRole("button", { name: "Stop" })); expect(await screen.findByText("Session déjà fermée.")).toBeTruthy(); expect(onSessionId).toHaveBeenCalledWith(null); diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index d641d9d..e13ecc8 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -246,11 +246,13 @@ function bytesToBase64(bytes: Uint8Array): string { return btoa(binary); } -async function clipboardImageToAttachment(file: File): Promise { +async function clipboardFileToAttachment(file: File): Promise { const mime = file.type || "application/octet-stream"; const filename = file.name || - `clipboard-image-${Date.now()}.${fileExtension(mime)}`; + (mime.startsWith("image/") + ? `clipboard-image-${Date.now()}.${fileExtension(mime)}` + : `clipboard-file-${Date.now()}`); const contentBase64 = bytesToBase64(new Uint8Array(await file.arrayBuffer())); return { id: `clipboard-${Date.now()}-${Math.random().toString(36).slice(2)}`, @@ -267,7 +269,7 @@ async function clipboardImageToAttachment(file: File): Promise }; } -function clipboardImageFiles(event: ClipboardEvent): File[] { +function clipboardFiles(event: ClipboardEvent): File[] { const clipboardItems = event.clipboardData.items ? Array.from(event.clipboardData.items) : []; @@ -275,12 +277,11 @@ function clipboardImageFiles(event: ClipboardEvent): File[] ? Array.from(event.clipboardData.files) : []; const fromItems = clipboardItems - .filter((item) => item.kind === "file" && item.type.startsWith("image/")) + .filter((item) => item.kind === "file") .map((item) => item.getAsFile()) .filter((file): file is File => Boolean(file)); - const fromFiles = clipboardFiles.filter((file) => file.type.startsWith("image/")); const seen = new Set(); - return [...fromItems, ...fromFiles].filter((file) => { + return [...fromItems, ...clipboardFiles].filter((file) => { const key = `${file.name}\u0000${file.type}\u0000${file.size}\u0000${file.lastModified}`; if (seen.has(key)) return false; seen.add(key); @@ -849,10 +850,10 @@ export function CustomAgentChatView({ } } - async function pasteClipboardImages( + async function pasteClipboardFiles( event: ClipboardEvent, ) { - const files = clipboardImageFiles(event); + const files = clipboardFiles(event); if (files.length === 0) return; event.preventDefault(); @@ -861,7 +862,7 @@ export function CustomAgentChatView({ const selectionEnd = event.currentTarget.selectionEnd; try { const nextAttachments = await Promise.all( - files.map(clipboardImageToAttachment), + files.map(clipboardFileToAttachment), ); setAttachments((prev) => [...prev, ...nextAttachments]); if (pastedText) { @@ -976,18 +977,6 @@ export function CustomAgentChatView({ CLI custom · {profile.name} -
- {(opening || busy) && ( - - )} -
{!supported && ( @@ -1206,7 +1195,7 @@ export function CustomAgentChatView({ disabled={!supported || opening || busy} placeholder="Message à l'agent…" onChange={(e) => setDraft(e.target.value)} - onPaste={(e) => void pasteClipboardImages(e)} + onPaste={(e) => void pasteClipboardFiles(e)} onKeyDown={(e) => { if (slashMenuOpen && slashCommands.length > 0) { if (e.key === "ArrowDown") { @@ -1250,12 +1239,12 @@ export function CustomAgentChatView({ diff --git a/frontend/src/features/layout/LayoutGrid.chat.test.tsx b/frontend/src/features/layout/LayoutGrid.chat.test.tsx index c8bad81..ffb5fe0 100644 --- a/frontend/src/features/layout/LayoutGrid.chat.test.tsx +++ b/frontend/src/features/layout/LayoutGrid.chat.test.tsx @@ -179,6 +179,100 @@ describe("LayoutGrid custom agent CLI (#147)", () => { expect(screen.getAllByText(/mock-attachment.txt/).length).toBeGreaterThan(0); }); + it("opens the custom CLI even when the agent already has a structured background session (#169)", async () => { + const layout = new MockLayoutGateway(); + const agent = new MockAgentGateway(); + const profileGateway = new MockProfileGateway(); + const terminal = new MockTerminalGateway(); + const system = new MockSystemGateway(); + await profileGateway.configureProfiles([structuredProfile]); + const created = await agent.createAgent("p1", { + name: "Worker", + profileId: structuredProfile.id, + }); + const tree = await layout.loadLayout("p1"); + const leafId = leaves(tree)[0].id; + await layout.mutateLayout("p1", { + type: "setCellAgent", + target: leafId, + agent: created.id, + }); + window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom"); + await agent.launchAgentChat("p1", created.id, { + cwd: "/home/me/proj", + rows: 24, + cols: 80, + nodeId: "structured-background", + }); + const launchChat = vi.spyOn(agent, "launchAgentChat"); + + renderGrid({ + layout, + agent, + profile: profileGateway, + terminal, + system, + } as unknown as Gateways); + + await waitFor(() => + expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy(), + ); + await waitFor(() => expect(launchChat).toHaveBeenCalled()); + expect(launchChat.mock.calls.at(-1)?.[2]).toMatchObject({ nodeId: leafId }); + expect(screen.queryByText(/déjà actif dans une autre cellule/)).toBeNull(); + }); + + it("closes the structured chat session by id before switching back to native TUI (#170)", async () => { + const layout = new MockLayoutGateway(); + const agent = new MockAgentGateway(); + const profileGateway = new MockProfileGateway(); + const terminal = new MockTerminalGateway(); + const system = new MockSystemGateway(); + await profileGateway.configureProfiles([structuredProfile]); + const created = await agent.createAgent("p1", { + name: "Worker", + profileId: structuredProfile.id, + }); + const tree = await layout.loadLayout("p1"); + const leafId = leaves(tree)[0].id; + await layout.mutateLayout("p1", { + type: "setCellAgent", + target: leafId, + agent: created.id, + }); + window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom"); + const closeChat = vi.spyOn(agent, "closeAgentChat"); + const stopLive = vi.spyOn(agent, "stopLiveAgent"); + + renderGrid({ + layout, + agent, + profile: profileGateway, + terminal, + system, + } as unknown as Gateways); + + await waitFor(() => + expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy(), + ); + await waitFor(async () => { + const updated = await layout.loadLayout("p1"); + expect(leaves(updated)[0].session).toMatch(/^mock-agent-chat-/); + }); + + fireEvent.click(screen.getByText("TUI native")); + await waitFor(() => + expect(screen.getByRole("alertdialog", { name: "Confirmer le changement de CLI" })).toBeTruthy(), + ); + fireEvent.click(screen.getByText("Arrêter et relancer")); + + await waitFor(() => + expect(closeChat).toHaveBeenCalledWith(expect.stringMatching(/^mock-agent-chat-/)), + ); + expect(stopLive).not.toHaveBeenCalled(); + await waitFor(() => expect(screen.getByTestId("terminal-view")).toBeTruthy()); + }); + it("requires confirmation before switching a live native TUI session", async () => { renderGrid(await seeded(structuredProfile)); diff --git a/frontend/src/features/layout/LayoutGrid.tsx b/frontend/src/features/layout/LayoutGrid.tsx index 0c3bd30..ee5dd3a 100644 --- a/frontend/src/features/layout/LayoutGrid.tsx +++ b/frontend/src/features/layout/LayoutGrid.tsx @@ -712,16 +712,12 @@ function LeafView({ async function stopCurrentSessionForSwitch(): Promise { if (!session) return; - if (agentId && agentGateway?.stopLiveAgent) { + if (cellMode === "custom" && agentGateway?.closeAgentChat) { + await agentGateway.closeAgentChat(session); + } else if (agentId && agentGateway?.stopLiveAgent) { await agentGateway.stopLiveAgent(projectId, agentId).catch(async () => { - if (cellMode === "custom" && agentGateway.closeAgentChat) { - await agentGateway.closeAgentChat(session); - return; - } await terminal?.closeTerminal(session); }); - } else if (cellMode === "custom" && agentGateway?.closeAgentChat) { - await agentGateway.closeAgentChat(session); } else { await terminal?.closeTerminal(session); }