merge(ui): supprime le popup de reprise par cellule — #177 (QA verte)
This commit is contained in:
@ -10,8 +10,7 @@
|
|||||||
* `launch_agent` flow through {@link useResumeProject}; the panel itself is pure
|
* `launch_agent` flow through {@link useResumeProject}; the panel itself is pure
|
||||||
* presentation (it only consumes the view-model, never a gateway directly).
|
* presentation (it only consumes the view-model, never a gateway directly).
|
||||||
*
|
*
|
||||||
* Status / labels mirror {@link ResumeConversationPopup}: the "en cours" / "clôt"
|
* Status / labels derive **only** from `wasRunning`; an agent whose profile cannot
|
||||||
* status is derived **only** from `wasRunning`; an agent whose profile cannot
|
|
||||||
* resume a conversation (`resumeSupported === false`) shows "relance à neuf".
|
* resume a conversation (`resumeSupported === false`) shows "relance à neuf".
|
||||||
*
|
*
|
||||||
* Empty pending set ⇒ the component renders nothing (no overlay).
|
* Empty pending set ⇒ the component renders nothing (no overlay).
|
||||||
|
|||||||
@ -173,7 +173,7 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
|
|||||||
});
|
});
|
||||||
fireEvent.click(screen.getByText("Envoyer"));
|
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(/Agent: reçu/).length).toBeGreaterThan(0);
|
||||||
expect(screen.getAllByText(/hello there/).length).toBeGreaterThan(0);
|
expect(screen.getAllByText(/hello there/).length).toBeGreaterThan(0);
|
||||||
expect(screen.getAllByText(/mock-attachment.txt/).length).toBeGreaterThan(0);
|
expect(screen.getAllByText(/mock-attachment.txt/).length).toBeGreaterThan(0);
|
||||||
|
|||||||
@ -24,7 +24,6 @@ vi.mock("@/features/terminals", () => ({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
ResumeConversationPopup: () => null,
|
|
||||||
useWritePortal: () => ({
|
useWritePortal: () => ({
|
||||||
portal: {
|
portal: {
|
||||||
onHumanData: () => {},
|
onHumanData: () => {},
|
||||||
|
|||||||
@ -22,16 +22,11 @@ import type { Agent, AgentProfile } from "@/domain";
|
|||||||
import type { LayoutNode } from "@/domain";
|
import type { LayoutNode } from "@/domain";
|
||||||
import type { ProjectWorkState } from "@/domain";
|
import type { ProjectWorkState } from "@/domain";
|
||||||
import type {
|
import type {
|
||||||
ConversationDetails,
|
|
||||||
LiveAgent,
|
LiveAgent,
|
||||||
OpenTerminalOptions,
|
OpenTerminalOptions,
|
||||||
TerminalHandle,
|
TerminalHandle,
|
||||||
} from "@/ports";
|
} from "@/ports";
|
||||||
import {
|
import { TerminalView, useWritePortal } from "@/features/terminals";
|
||||||
ResumeConversationPopup,
|
|
||||||
TerminalView,
|
|
||||||
useWritePortal,
|
|
||||||
} from "@/features/terminals";
|
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
import { useProjectWorkState } from "@/features/workstate/useProjectWorkState";
|
import { useProjectWorkState } from "@/features/workstate/useProjectWorkState";
|
||||||
import {
|
import {
|
||||||
@ -192,7 +187,6 @@ function NodeView({
|
|||||||
session={node.node.session ?? null}
|
session={node.node.session ?? null}
|
||||||
agent={node.node.agent ?? null}
|
agent={node.node.agent ?? null}
|
||||||
conversationId={node.node.conversationId ?? null}
|
conversationId={node.node.conversationId ?? null}
|
||||||
agentWasRunning={node.node.agentWasRunning ?? false}
|
|
||||||
cwd={cwd}
|
cwd={cwd}
|
||||||
vm={vm}
|
vm={vm}
|
||||||
parentSplit={parentSplit}
|
parentSplit={parentSplit}
|
||||||
@ -238,7 +232,6 @@ interface LeafViewProps {
|
|||||||
session: string | null;
|
session: string | null;
|
||||||
agent: string | null;
|
agent: string | null;
|
||||||
conversationId: string | null;
|
conversationId: string | null;
|
||||||
agentWasRunning: boolean;
|
|
||||||
cwd: string;
|
cwd: string;
|
||||||
vm: LayoutViewModel;
|
vm: LayoutViewModel;
|
||||||
parentSplit: { container: string; index: number; siblings: number } | null;
|
parentSplit: { container: string; index: number; siblings: number } | null;
|
||||||
@ -248,19 +241,6 @@ interface LeafViewProps {
|
|||||||
refitSignal: number;
|
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
|
* A transient in-cell notice. `goToNodeId`, when present, identifies the layout
|
||||||
* leaf that already hosts the conflicting live agent; the notice then renders an
|
* leaf that already hosts the conflicting live agent; the notice then renders an
|
||||||
@ -355,7 +335,6 @@ function LeafView({
|
|||||||
session,
|
session,
|
||||||
agent,
|
agent,
|
||||||
conversationId,
|
conversationId,
|
||||||
agentWasRunning,
|
|
||||||
cwd,
|
cwd,
|
||||||
vm,
|
vm,
|
||||||
parentSplit,
|
parentSplit,
|
||||||
@ -812,15 +791,6 @@ function LeafView({
|
|||||||
// we never silently relaunch a clone (product rule "1 agent = 1 employé").
|
// we never silently relaunch a clone (product rule "1 agent = 1 employé").
|
||||||
const [busyNotice, setBusyNotice] = useState<CellNotice | null>(null);
|
const [busyNotice, setBusyNotice] = useState<CellNotice | null>(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<PendingResume | null>(null);
|
|
||||||
const [resumeDetails, setResumeDetails] = useState<ConversationDetails | null>(null);
|
|
||||||
|
|
||||||
// ── Live re-attach on background wake (inter-agent delegation) ─────────────
|
// ── Live re-attach on background wake (inter-agent delegation) ─────────────
|
||||||
// Bumped to re-key the TerminalView and force a re-attach to the cell's
|
// 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
|
// pinned agent when that agent becomes live in the *background* AFTER this
|
||||||
@ -836,8 +806,6 @@ function LeafView({
|
|||||||
const [attachGen, setAttachGen] = useState(0);
|
const [attachGen, setAttachGen] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!agentId || !agentGateway?.attachLiveAgent) return;
|
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
|
// 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
|
// this cell is not already bound to that session. `backgroundLive` is the
|
||||||
// same guard `doLaunch` and the dropdown use; crucially it ignores a session
|
// same guard `doLaunch` and the dropdown use; crucially it ignores a session
|
||||||
@ -913,52 +881,9 @@ function LeafView({
|
|||||||
|
|
||||||
const terminalOpener = agentGateway && agentId
|
const terminalOpener = agentGateway && agentId
|
||||||
? (opts: OpenTerminalOptions, onData: (bytes: Uint8Array) => void): Promise<TerminalHandle> => {
|
? (opts: OpenTerminalOptions, onData: (bytes: Uint8Array) => void): Promise<TerminalHandle> => {
|
||||||
// No persisted conversation ⇒ fresh cell: open straight away (the launch
|
return doLaunch(opts, onData, conversationId ?? undefined);
|
||||||
// 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<TerminalHandle>((resolve, reject) => {
|
|
||||||
setPendingResume({ opts, onData, resolve, reject });
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
: 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
|
// Agent cells re-attach through the agent gateway; plain cells fall back to
|
||||||
// the terminal gateway's reattach (handled by TerminalView's default).
|
// the terminal gateway's reattach (handled by TerminalView's default).
|
||||||
const reattachOpener = agentGateway && agentId
|
const reattachOpener = agentGateway && agentId
|
||||||
@ -1639,14 +1564,6 @@ function LeafView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{pendingResume && (
|
|
||||||
<ResumeConversationPopup
|
|
||||||
agentWasRunning={agentWasRunning}
|
|
||||||
details={resumeDetails}
|
|
||||||
onResume={onResume}
|
|
||||||
onNewConversation={onNewConversation}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(
|
|
||||||
<ResumeConversationPopup
|
|
||||||
agentWasRunning
|
|
||||||
details={{ lastTopic: "x", tokenCount: 5 }}
|
|
||||||
onResume={() => {}}
|
|
||||||
onNewConversation={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(screen.getByTestId("resume-status").textContent).toBe("en cours");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("derives status 'clot' from agentWasRunning=false", () => {
|
|
||||||
render(
|
|
||||||
<ResumeConversationPopup
|
|
||||||
agentWasRunning={false}
|
|
||||||
details={{}}
|
|
||||||
onResume={() => {}}
|
|
||||||
onNewConversation={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(screen.getByTestId("resume-status").textContent).toBe("clot");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("enriched mode shows last topic + token count when present", () => {
|
|
||||||
render(
|
|
||||||
<ResumeConversationPopup
|
|
||||||
agentWasRunning
|
|
||||||
details={{ lastTopic: "refactor parser", tokenCount: 4242 }}
|
|
||||||
onResume={() => {}}
|
|
||||||
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(
|
|
||||||
<ResumeConversationPopup
|
|
||||||
agentWasRunning={false}
|
|
||||||
details={{}}
|
|
||||||
onResume={() => {}}
|
|
||||||
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(
|
|
||||||
<ResumeConversationPopup
|
|
||||||
agentWasRunning
|
|
||||||
details={null}
|
|
||||||
onResume={() => {}}
|
|
||||||
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(
|
|
||||||
<ResumeConversationPopup
|
|
||||||
agentWasRunning
|
|
||||||
details={{ tokenCount: 0 }}
|
|
||||||
onResume={() => {}}
|
|
||||||
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(
|
|
||||||
<ResumeConversationPopup
|
|
||||||
agentWasRunning
|
|
||||||
details={{}}
|
|
||||||
onResume={onResume}
|
|
||||||
onNewConversation={onNewConversation}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
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<typeof import("@/features/terminals")>(
|
|
||||||
"@/features/terminals",
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
TerminalView: ({
|
|
||||||
open,
|
|
||||||
}: {
|
|
||||||
open?: (
|
|
||||||
o: OpenTerminalOptions,
|
|
||||||
onData: (b: Uint8Array) => void,
|
|
||||||
) => Promise<TerminalHandle>;
|
|
||||||
}) => {
|
|
||||||
// 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 <div data-testid="terminal-view" />;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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(
|
|
||||||
<DIProvider gateways={gateways}>
|
|
||||||
<LayoutGrid projectId={projectId} cwd="/home/me/proj" />
|
|
||||||
</DIProvider>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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<ConversationDetails> => ({ 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<ConversationDetails> => ({})),
|
|
||||||
};
|
|
||||||
|
|
||||||
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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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 (
|
|
||||||
<div
|
|
||||||
data-testid="resume-conversation-popup"
|
|
||||||
role="dialog"
|
|
||||||
aria-label="Reprise de conversation"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
inset: 0,
|
|
||||||
zIndex: 5,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
background: "rgba(0, 0, 0, 0.55)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
minWidth: 260,
|
|
||||||
maxWidth: 360,
|
|
||||||
padding: "16px 18px",
|
|
||||||
background: "var(--color-surface, #1e1e1e)",
|
|
||||||
color: "var(--color-content, #e0e0e0)",
|
|
||||||
border: "1px solid var(--color-border, #3a3a3a)",
|
|
||||||
borderRadius: 6,
|
|
||||||
boxShadow: "0 8px 24px rgba(0, 0, 0, 0.5)",
|
|
||||||
fontSize: 13,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p style={{ margin: "0 0 8px", fontWeight: 600 }}>
|
|
||||||
Reprise de conversation
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p style={{ margin: "0 0 6px" }}>
|
|
||||||
Statut :{" "}
|
|
||||||
<span data-testid="resume-status">{statusLabel}</span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{details?.lastTopic && (
|
|
||||||
<p style={{ margin: "0 0 6px" }} data-testid="resume-last-topic">
|
|
||||||
Dernier sujet : {details.lastTopic}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{details?.tokenCount !== undefined && (
|
|
||||||
<p style={{ margin: "0 0 10px" }} data-testid="resume-token-count">
|
|
||||||
Tokens : {details.tokenCount}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: 8, marginTop: 12 }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onResume}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
padding: "6px 10px",
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Reprendre la dernière conversation
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onNewConversation}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
padding: "6px 10px",
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Nouvelle conversation
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -2,6 +2,5 @@
|
|||||||
|
|
||||||
export { TerminalView } from "./TerminalView";
|
export { TerminalView } from "./TerminalView";
|
||||||
export type { TerminalInputApi } from "./TerminalView";
|
export type { TerminalInputApi } from "./TerminalView";
|
||||||
export { ResumeConversationPopup } from "./ResumeConversationPopup";
|
|
||||||
export { useWritePortal } from "./useWritePortal";
|
export { useWritePortal } from "./useWritePortal";
|
||||||
export type { UseWritePortalResult } from "./useWritePortal";
|
export type { UseWritePortalResult } from "./useWritePortal";
|
||||||
|
|||||||
Reference in New Issue
Block a user