diff --git a/frontend/src/features/agents/ResumeProjectPanel.tsx b/frontend/src/features/agents/ResumeProjectPanel.tsx index 6195e004..51059e7b 100644 --- a/frontend/src/features/agents/ResumeProjectPanel.tsx +++ b/frontend/src/features/agents/ResumeProjectPanel.tsx @@ -10,8 +10,7 @@ * `launch_agent` flow through {@link useResumeProject}; the panel itself is pure * presentation (it only consumes the view-model, never a gateway directly). * - * Status / labels mirror {@link ResumeConversationPopup}: the "en cours" / "clôt" - * status is derived **only** from `wasRunning`; an agent whose profile cannot + * Status / labels derive **only** from `wasRunning`; an agent whose profile cannot * resume a conversation (`resumeSupported === false`) shows "relance à neuf". * * Empty pending set ⇒ the component renders nothing (no overlay). diff --git a/frontend/src/features/layout/LayoutGrid.chat.test.tsx b/frontend/src/features/layout/LayoutGrid.chat.test.tsx index ffb5fe0b..4612a8ad 100644 --- a/frontend/src/features/layout/LayoutGrid.chat.test.tsx +++ b/frontend/src/features/layout/LayoutGrid.chat.test.tsx @@ -173,7 +173,7 @@ describe("LayoutGrid custom agent CLI (#147)", () => { }); fireEvent.click(screen.getByText("Envoyer")); - await waitFor(() => expect(screen.getByText("Task Complete")).toBeTruthy()); + await waitFor(() => expect(screen.getByText("Terminé")).toBeTruthy()); expect(screen.getAllByText(/Agent: reçu/).length).toBeGreaterThan(0); expect(screen.getAllByText(/hello there/).length).toBeGreaterThan(0); expect(screen.getAllByText(/mock-attachment.txt/).length).toBeGreaterThan(0); diff --git a/frontend/src/features/layout/LayoutGrid.refitEpoch.test.tsx b/frontend/src/features/layout/LayoutGrid.refitEpoch.test.tsx index 12820f40..955bf61d 100644 --- a/frontend/src/features/layout/LayoutGrid.refitEpoch.test.tsx +++ b/frontend/src/features/layout/LayoutGrid.refitEpoch.test.tsx @@ -24,7 +24,6 @@ vi.mock("@/features/terminals", () => ({ /> ); }, - ResumeConversationPopup: () => null, useWritePortal: () => ({ portal: { onHumanData: () => {}, diff --git a/frontend/src/features/layout/LayoutGrid.tsx b/frontend/src/features/layout/LayoutGrid.tsx index 60a2348a..31871b16 100644 --- a/frontend/src/features/layout/LayoutGrid.tsx +++ b/frontend/src/features/layout/LayoutGrid.tsx @@ -22,16 +22,11 @@ import type { Agent, AgentProfile } from "@/domain"; import type { LayoutNode } from "@/domain"; import type { ProjectWorkState } from "@/domain"; import type { - ConversationDetails, LiveAgent, OpenTerminalOptions, TerminalHandle, } from "@/ports"; -import { - ResumeConversationPopup, - TerminalView, - useWritePortal, -} from "@/features/terminals"; +import { TerminalView, useWritePortal } from "@/features/terminals"; import { useGateways } from "@/app/di"; import { useProjectWorkState } from "@/features/workstate/useProjectWorkState"; import { @@ -192,7 +187,6 @@ function NodeView({ session={node.node.session ?? null} agent={node.node.agent ?? null} conversationId={node.node.conversationId ?? null} - agentWasRunning={node.node.agentWasRunning ?? false} cwd={cwd} vm={vm} parentSplit={parentSplit} @@ -238,7 +232,6 @@ interface LeafViewProps { session: string | null; agent: string | null; conversationId: string | null; - agentWasRunning: boolean; cwd: string; vm: LayoutViewModel; parentSplit: { container: string; index: number; siblings: number } | null; @@ -248,19 +241,6 @@ interface LeafViewProps { refitSignal: number; } -/** - * A launch deferred by the resume popup (T7): TerminalView asked the opener to - * launch (fresh open or reattach-failed fallback) for a cell that carries a - * `conversationId`. We hold the open request here until the user picks - * Reprendre / Nouvelle conversation, then resolve the promise with the handle. - */ -interface PendingResume { - opts: OpenTerminalOptions; - onData: (bytes: Uint8Array) => void; - resolve: (handle: TerminalHandle) => void; - reject: (e: unknown) => void; -} - /** * A transient in-cell notice. `goToNodeId`, when present, identifies the layout * leaf that already hosts the conflicting live agent; the notice then renders an @@ -355,7 +335,6 @@ function LeafView({ session, agent, conversationId, - agentWasRunning, cwd, vm, parentSplit, @@ -812,15 +791,6 @@ function LeafView({ // we never silently relaunch a clone (product rule "1 agent = 1 employé"). const [busyNotice, setBusyNotice] = useState(null); - // ── Resume popup state (T7) ─────────────────────────────────────────────── - // When an agent cell carries a persisted conversation id and its PTY session - // is dead, the opener is about to relaunch in Resume mode. We intercept that - // launch with a popup so the user can choose Resume vs a fresh conversation. - // The reattach path (live PTY) never goes through this opener, so the popup is - // naturally skipped there. - const [pendingResume, setPendingResume] = useState(null); - const [resumeDetails, setResumeDetails] = useState(null); - // ── Live re-attach on background wake (inter-agent delegation) ───────────── // Bumped to re-key the TerminalView and force a re-attach to the cell's // pinned agent when that agent becomes live in the *background* AFTER this @@ -836,8 +806,6 @@ function LeafView({ const [attachGen, setAttachGen] = useState(0); useEffect(() => { if (!agentId || !agentGateway?.attachLiveAgent) return; - // Mid resume decision: let the popup own the (re)launch — don't race it. - if (pendingResume) return; // Only act when the pinned agent is live in ANOTHER (non-visible) cell and // this cell is not already bound to that session. `backgroundLive` is the // same guard `doLaunch` and the dropdown use; crucially it ignores a session @@ -913,52 +881,9 @@ function LeafView({ const terminalOpener = agentGateway && agentId ? (opts: OpenTerminalOptions, onData: (bytes: Uint8Array) => void): Promise => { - // No persisted conversation ⇒ fresh cell: open straight away (the launch - // may assign a new id). No popup on this path. - if (!conversationId) { - return doLaunch(opts, onData, undefined); - } - // If the cell was closed/remounted while the agent was running, resume - // silently. Layout/project changes must not interrupt an ongoing turn - // with a modal choice; the persisted conversation id is the continuity - // signal and `agentWasRunning` tells us this was not an intentional stop. - if (agentWasRunning) { - return doLaunch(opts, onData, conversationId); - } - // Resume case: defer the launch behind the popup. Fetch the best-effort - // enriched details (last topic + tokens) to enrich it; failure or empty - // ⇒ degraded mode (status only). Inspection never blocks the resume. - Promise.resolve( - agentGateway.inspectConversation?.(projectId, agentId, conversationId), - ) - .then((details) => setResumeDetails(details ?? {})) - .catch(() => setResumeDetails({})); - return new Promise((resolve, reject) => { - setPendingResume({ opts, onData, resolve, reject }); - }); + return doLaunch(opts, onData, conversationId ?? undefined); } : undefined; - - /** "Reprendre" → launch resuming the existing conversation id. */ - const onResume = () => { - const p = pendingResume; - if (!p) return; - setPendingResume(null); - setResumeDetails(null); - doLaunch(p.opts, p.onData, conversationId ?? undefined).then(p.resolve, p.reject); - }; - - /** "Nouvelle conversation" → clear the id first (Assign), then launch fresh. */ - const onNewConversation = () => { - const p = pendingResume; - if (!p) return; - setPendingResume(null); - setResumeDetails(null); - // Clear the persisted conversation id BEFORE launching so the backend treats - // it as a fresh cell and assigns a new conversation. - void vm.setCellConversation(id, null); - doLaunch(p.opts, p.onData, undefined).then(p.resolve, p.reject); - }; // Agent cells re-attach through the agent gateway; plain cells fall back to // the terminal gateway's reattach (handled by TerminalView's default). const reattachOpener = agentGateway && agentId @@ -1639,14 +1564,6 @@ function LeafView({ )} - {pendingResume && ( - - )} ); } diff --git a/frontend/src/features/layout/resumeConversationPopup.test.tsx b/frontend/src/features/layout/resumeConversationPopup.test.tsx deleted file mode 100644 index 5359b829..00000000 --- a/frontend/src/features/layout/resumeConversationPopup.test.tsx +++ /dev/null @@ -1,352 +0,0 @@ -/** - * T7 — the resume-conversation popup and its wiring in the layout grid. - * - * Two layers: - * 1. The presentational {@link ResumeConversationPopup} in isolation: status - * derived from `agentWasRunning` (never the inspector); enriched mode (topic - * + tokens) vs degraded mode (status only); the two action callbacks. - * 2. The grid wiring (`LayoutGrid` → `LeafView`): an agent cell that carries a - * persisted `conversationId` shows the popup **before** the Resume launch; - * "Reprendre" launches with the id; "Nouvelle conversation" clears the id - * first then launches without it. The reattach path (live PTY) never shows - * the popup. - * - * Because xterm bails under jsdom, `TerminalView`'s opener may not run in this - * environment — so the grid-level assertions render the popup by driving the - * opener directly is not possible. Instead we drive the popup through the - * component-level tests (deterministic) and assert the *opener wiring* by - * calling the exported component with stub gateways where the opener is invoked - * manually. To stay robust we focus the integration assertions on the popup - * presence + the two action effects, guarded on the opener having run. - */ -import { useEffect } from "react"; -import { describe, it, expect, vi } from "vitest"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; - -import type { ConversationDetails } from "@/ports"; -import { ResumeConversationPopup } from "@/features/terminals"; - -// --------------------------------------------------------------------------- -// 1. Presentational popup in isolation -// --------------------------------------------------------------------------- - -describe("ResumeConversationPopup — presentation", () => { - it("derives status 'en cours' from agentWasRunning=true (not the inspector)", () => { - render( - {}} - onNewConversation={() => {}} - />, - ); - expect(screen.getByTestId("resume-status").textContent).toBe("en cours"); - }); - - it("derives status 'clot' from agentWasRunning=false", () => { - render( - {}} - onNewConversation={() => {}} - />, - ); - expect(screen.getByTestId("resume-status").textContent).toBe("clot"); - }); - - it("enriched mode shows last topic + token count when present", () => { - render( - {}} - onNewConversation={() => {}} - />, - ); - expect(screen.getByTestId("resume-last-topic").textContent).toContain( - "refactor parser", - ); - expect(screen.getByTestId("resume-token-count").textContent).toContain("4242"); - }); - - it("degraded mode (empty details) shows status only, no topic/token lines", () => { - render( - {}} - onNewConversation={() => {}} - />, - ); - expect(screen.getByTestId("resume-status").textContent).toBe("clot"); - expect(screen.queryByTestId("resume-last-topic")).toBeNull(); - expect(screen.queryByTestId("resume-token-count")).toBeNull(); - }); - - it("details=null (loading) shows status only", () => { - render( - {}} - onNewConversation={() => {}} - />, - ); - expect(screen.getByTestId("resume-status").textContent).toBe("en cours"); - expect(screen.queryByTestId("resume-last-topic")).toBeNull(); - }); - - it("token count of 0 still renders (uses !== undefined, not truthiness)", () => { - render( - {}} - onNewConversation={() => {}} - />, - ); - expect(screen.getByTestId("resume-token-count").textContent).toContain("0"); - }); - - it("fires onResume / onNewConversation on the matching buttons", () => { - const onResume = vi.fn(); - const onNewConversation = vi.fn(); - render( - , - ); - fireEvent.click(screen.getByText(/Reprendre la dernière conversation/)); - expect(onResume).toHaveBeenCalledTimes(1); - fireEvent.click(screen.getByText(/Nouvelle conversation/)); - expect(onNewConversation).toHaveBeenCalledTimes(1); - }); -}); - -// --------------------------------------------------------------------------- -// 2. Grid wiring -// --------------------------------------------------------------------------- - -import { render as renderGridDom } from "@testing-library/react"; -import type { Gateways, OpenTerminalOptions, TerminalHandle } from "@/ports"; -import { MockLayoutGateway, MockAgentGateway, MockTerminalGateway } from "@/adapters/mock"; -import { DIProvider } from "@/app/di"; -import { leaves } from "./layout"; - -// xterm bails under jsdom, so the real `TerminalView` never runs its opener -// (and thus never reaches our deferral/popup wiring). Replace it with a stub -// that immediately invokes the `open` opener — exactly what the real view does -// once the PTY mount succeeds — so the deferral → popup → action → launch path -// is deterministically exercised. `ResumeConversationPopup` is NOT mocked (we -// assert its rendered output). Importing `LayoutGrid` after the mock is set up. -vi.mock("@/features/terminals", async () => { - const actual = await vi.importActual( - "@/features/terminals", - ); - return { - ...actual, - TerminalView: ({ - open, - }: { - open?: ( - o: OpenTerminalOptions, - onData: (b: Uint8Array) => void, - ) => Promise; - }) => { - // Mirror the real view: call the opener once on mount (fresh-open path). - // In an effect (not during render) so the opener's setState is safe. - useEffect(() => { - if (open) void open({ cwd: "/x", rows: 24, cols: 80 }, () => {}); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - return
; - }, - }; -}); - -// eslint-disable-next-line import/first -import { LayoutGrid } from "./LayoutGrid"; - -function makeHandle(sessionId: string): TerminalHandle { - return { - sessionId, - write: vi.fn().mockResolvedValue(undefined), - resize: vi.fn().mockResolvedValue(undefined), - detach: vi.fn(), - close: vi.fn().mockResolvedValue(undefined), - }; -} - -function renderGrid(layout: MockLayoutGateway, agent: unknown, projectId = "p1") { - const gateways = { - layout, - terminal: new MockTerminalGateway(), - agent, - } as unknown as Gateways; - return renderGridDom( - - - , - ); -} - -/** Seeds an agent on the root leaf carrying a persisted conversation id but no - * live session — the Resume case that must trigger the popup. */ -async function seedResumeLeaf(layout: MockLayoutGateway, agentId: string) { - const tree = await layout.loadLayout("p1"); - const leafId = leaves(tree)[0].id; - await layout.mutateLayout("p1", { type: "setCellAgent", target: leafId, agent: agentId }); - await layout.mutateLayout("p1", { - type: "setCellConversation", - target: leafId, - conversationId: "prior-conv", - }); - return leafId; -} - -describe("LayoutGrid — resume popup wiring (T7)", () => { - it("shows the popup for a resume cell and 'Reprendre' launches with the conversation id", async () => { - const layout = new MockLayoutGateway(); - const agent = new MockAgentGateway(); - const a = await agent.createAgent("p1", { name: "MyAgent", profileId: "p" }); - await seedResumeLeaf(layout, a.id); - - const launchAgent = vi.fn( - async ( - _p: string, - _ag: string, - _opts: OpenTerminalOptions, - _onData: (b: Uint8Array) => void, - ) => makeHandle("sess-resume"), - ); - const inspectConversation = vi.fn( - async (): Promise => ({ lastTopic: "topic-x", tokenCount: 7 }), - ); - const stubAgent = { - listAgents: () => Promise.resolve([a]), - launchAgent, - reattach: vi.fn(), - inspectConversation, - }; - - renderGrid(layout, stubAgent); - - // The popup appears (opener deferred behind it) and the launch is gated. - await waitFor(() => - expect(screen.getByTestId("resume-conversation-popup")).toBeTruthy(), - ); - expect(launchAgent).not.toHaveBeenCalled(); - expect(inspectConversation).toHaveBeenCalledWith("p1", a.id, "prior-conv"); - - fireEvent.click(screen.getByText(/Reprendre la dernière conversation/)); - - await waitFor(() => expect(launchAgent).toHaveBeenCalledTimes(1)); - const opts = launchAgent.mock.calls[0][2] as OpenTerminalOptions; - expect(opts.conversationId).toBe("prior-conv"); - // The id stays on the leaf (resume keeps it). - const fresh = await layout.loadLayout("p1"); - expect(leaves(fresh)[0].conversationId).toBe("prior-conv"); - }); - - it("'Nouvelle conversation' clears the id first, then launches without it", async () => { - const layout = new MockLayoutGateway(); - const agent = new MockAgentGateway(); - const a = await agent.createAgent("p1", { name: "MyAgent", profileId: "p" }); - await seedResumeLeaf(layout, a.id); - - const launchAgent = vi.fn( - async ( - _p: string, - _ag: string, - _opts: OpenTerminalOptions, - _onData: (b: Uint8Array) => void, - ) => makeHandle("sess-new"), - ); - const stubAgent = { - listAgents: () => Promise.resolve([a]), - launchAgent, - reattach: vi.fn(), - inspectConversation: vi.fn(async (): Promise => ({})), - }; - - renderGrid(layout, stubAgent); - - await waitFor(() => - expect(screen.getByTestId("resume-conversation-popup")).toBeTruthy(), - ); - expect(launchAgent).not.toHaveBeenCalled(); - - fireEvent.click(screen.getByText(/Nouvelle conversation/)); - - await waitFor(() => expect(launchAgent).toHaveBeenCalledTimes(1)); - const opts = launchAgent.mock.calls[0][2] as OpenTerminalOptions; - expect(opts.conversationId).toBeUndefined(); - // The id was cleared on the leaf before launching (Assign on next launch). - const fresh = await layout.loadLayout("p1"); - expect(leaves(fresh)[0].conversationId).toBeUndefined(); - }); - - it("a fresh cell (no conversation id) does NOT show the popup", async () => { - const layout = new MockLayoutGateway(); - const agent = new MockAgentGateway(); - const a = await agent.createAgent("p1", { name: "MyAgent", profileId: "p" }); - const tree = await layout.loadLayout("p1"); - const leafId = leaves(tree)[0].id; - await layout.mutateLayout("p1", { type: "setCellAgent", target: leafId, agent: a.id }); - - const inspectConversation = vi.fn(); - const stubAgent = { - listAgents: () => Promise.resolve([a]), - launchAgent: vi.fn(async () => makeHandle("sess-fresh")), - reattach: vi.fn(), - inspectConversation, - }; - - renderGrid(layout, stubAgent); - - await waitFor(() => expect(screen.getByRole("combobox")).toBeTruthy()); - // No popup, and inspection is never triggered for a fresh cell. - expect(screen.queryByTestId("resume-conversation-popup")).toBeNull(); - expect(inspectConversation).not.toHaveBeenCalled(); - }); - - it("does not show the popup for a remounted cell whose agent was still running (#158)", async () => { - const layout = new MockLayoutGateway(); - const agent = new MockAgentGateway(); - const a = await agent.createAgent("p1", { name: "MyAgent", profileId: "p" }); - const leafId = await seedResumeLeaf(layout, a.id); - await layout.mutateLayout("p1", { - type: "setAgentRunning", - target: leafId, - running: true, - }); - - const launchAgent = vi.fn( - async ( - _p: string, - _ag: string, - _opts: OpenTerminalOptions, - _onData: (b: Uint8Array) => void, - ) => makeHandle("sess-running-resume"), - ); - const inspectConversation = vi.fn(); - const stubAgent = { - listAgents: () => Promise.resolve([a]), - launchAgent, - reattach: vi.fn(), - inspectConversation, - }; - - renderGrid(layout, stubAgent); - - await waitFor(() => expect(launchAgent).toHaveBeenCalledTimes(1)); - expect(screen.queryByTestId("resume-conversation-popup")).toBeNull(); - expect(inspectConversation).not.toHaveBeenCalled(); - const opts = launchAgent.mock.calls[0][2] as OpenTerminalOptions; - expect(opts.conversationId).toBe("prior-conv"); - }); -}); diff --git a/frontend/src/features/terminals/ResumeConversationPopup.tsx b/frontend/src/features/terminals/ResumeConversationPopup.tsx deleted file mode 100644 index 2007ca19..00000000 --- a/frontend/src/features/terminals/ResumeConversationPopup.tsx +++ /dev/null @@ -1,120 +0,0 @@ -/** - * Resume-conversation popup (T7). Shown for an agent cell whose PTY session is - * dead but which carries a persisted `conversationId`, **before** the automatic - * Resume launch fires. It lets the user choose between resuming the previous CLI - * conversation or starting a fresh one. - * - * Pure presentation: it receives the status + best-effort details + the two - * action callbacks. It never calls a gateway / `invoke()` itself — the parent - * (`LayoutGrid`'s `LeafView`) wires it to the {@link AgentGateway} ports. The - * status ("en cours" / "clot") is derived **only** from `agentWasRunning` (the - * universal close-time flag), never from the inspector — a missing inspector - * just hides the enriched topic/token lines (degraded mode). - */ - -import type { ConversationDetails } from "@/ports"; - -interface ResumeConversationPopupProps { - /** - * Whether the agent process was running when the cell was last closed - * (`agentWasRunning`). `true` ⇒ "en cours"; `false`/absent ⇒ "clot". This is - * the universal status; it is NOT derived from the inspector. - */ - agentWasRunning: boolean; - /** - * Best-effort enriched details (last topic + token indicator). `null` while - * loading; an empty object once loaded with no details available (degraded - * mode: only the status + the resume affordance are shown). - */ - details: ConversationDetails | null; - /** Resume the previous conversation (keeps the conversation id ⇒ Resume). */ - onResume: () => void; - /** Start a fresh conversation (clears the conversation id ⇒ Assign). */ - onNewConversation: () => void; -} - -export function ResumeConversationPopup({ - agentWasRunning, - details, - onResume, - onNewConversation, -}: ResumeConversationPopupProps) { - const statusLabel = agentWasRunning ? "en cours" : "clot"; - - return ( -
-
-

- Reprise de conversation -

- -

- Statut :{" "} - {statusLabel} -

- - {details?.lastTopic && ( -

- Dernier sujet : {details.lastTopic} -

- )} - - {details?.tokenCount !== undefined && ( -

- Tokens : {details.tokenCount} -

- )} - -
- - -
-
-
- ); -} diff --git a/frontend/src/features/terminals/index.ts b/frontend/src/features/terminals/index.ts index 1dba9384..49273468 100644 --- a/frontend/src/features/terminals/index.ts +++ b/frontend/src/features/terminals/index.ts @@ -2,6 +2,5 @@ export { TerminalView } from "./TerminalView"; export type { TerminalInputApi } from "./TerminalView"; -export { ResumeConversationPopup } from "./ResumeConversationPopup"; export { useWritePortal } from "./useWritePortal"; export type { UseWritePortalResult } from "./useWritePortal";