feat(agent): reprise des sessions au redémarrage (B2) — commande + ResumeProjectPanel

- Tauri : commande list_resumable_agents (projectId) + ResumableAgentListDto
  { resumable } / ResumableAgentDto (camelCase, conversationId omis si None),
  câblage state.rs (réutilise stores + ProfileStore, aucun nouveau port).
- Front : gateway listResumableAgents, ResumeProjectPanel monté à l'ouverture
  de projet (opt-in, FR) : Reprendre (launch_agent nodeId+conversationId),
  Nouvelle conversation (setCellConversation(null) puis launch), Ignorer,
  Tout reprendre/ignorer. resumeSupported=false ⇒ « relance à neuf ».
- doc : §15.2 coquille agents→resumable (alignée use case/back/front).

Tests : app-tauri dto 9/9 ; vitest 324 (+10) ; workspace Rust 0 échec. 0 régression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 13:04:41 +02:00
parent b82e3e1a40
commit 7375f706da
15 changed files with 992 additions and 6 deletions

View File

@ -0,0 +1,154 @@
/**
* `ResumeProjectPanel` — the reopen resume panel (L15 / lot B2, ARCHITECTURE
* §15.2).
*
* Mounted **once when a project is (re)opened**, only when the resumable
* inventory is non-empty. It lists every agent cell that "was running" and/or
* carries a persisted conversation id, and lets the user decide, per agent:
* **Reprendre** / **Nouvelle conversation** / **Ignorer**, plus the global
* **Tout reprendre** / **Tout ignorer**. Each choice reuses the existing
* `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
* resume a conversation (`resumeSupported === false`) shows "relance à neuf".
*
* Empty pending set ⇒ the component renders nothing (no overlay).
*/
import { Button, cn } from "@/shared";
import { useResumeProject } from "./useResumeProject";
export interface ResumeProjectPanelProps {
/** Project being (re)opened. */
projectId: string;
/** Working directory the resumed agents launch in (the project root). */
cwd: string;
}
export function ResumeProjectPanel({ projectId, cwd }: ResumeProjectPanelProps) {
const vm = useResumeProject(projectId, cwd);
// No resumable agents ⇒ no panel at all.
if (vm.pending.length === 0) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-label="Reprise des agents"
data-testid="resume-project-panel"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 p-4"
>
<div className="flex max-h-[80vh] w-full max-w-lg flex-col rounded-lg border border-border bg-surface p-5 shadow-xl">
<h4 className="text-sm font-semibold text-content">
Reprendre les agents&nbsp;?
</h4>
<p className="mt-1 text-sm text-muted">
Ces agents tournaient à la dernière fermeture du projet. Choisissez
ceux à reprendre.
</p>
{vm.error && (
<p
role="alert"
className="mt-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
>
{vm.error}
</p>
)}
{/* ── Per-agent rows ── */}
<ul className="mt-4 flex flex-col gap-2 overflow-auto">
{vm.pending.map((a) => {
const statusLabel = a.wasRunning ? "en cours" : "clôt";
return (
<li
key={a.agentId}
data-testid={`resume-row-${a.agentId}`}
className="flex flex-col gap-2 rounded-md border border-border bg-raised p-3"
>
<div className="flex items-center justify-between gap-2">
<span className="min-w-0 truncate font-medium text-content">
{a.name}
</span>
<span
data-testid={`resume-status-${a.agentId}`}
className={cn(
"shrink-0 rounded px-1.5 py-0.5 text-xs",
a.wasRunning
? "bg-primary/15 text-primary"
: "bg-muted/15 text-muted",
)}
>
{statusLabel}
</span>
</div>
{!a.resumeSupported && (
<p
data-testid={`resume-fresh-note-${a.agentId}`}
className="text-xs text-muted"
>
Historique non disponible pour ce moteur relance à neuf.
</p>
)}
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="primary"
aria-label={`resume ${a.name}`}
disabled={vm.busy}
onClick={() => void vm.resume(a)}
>
{a.resumeSupported ? "Reprendre" : "Relancer à neuf"}
</Button>
<Button
size="sm"
aria-label={`new conversation ${a.name}`}
disabled={vm.busy}
onClick={() => void vm.newConversation(a)}
>
Nouvelle conversation
</Button>
<Button
size="sm"
variant="ghost"
aria-label={`ignore ${a.name}`}
disabled={vm.busy}
onClick={() => vm.ignore(a.agentId)}
>
Ignorer
</Button>
</div>
</li>
);
})}
</ul>
{/* ── Global actions ── */}
<div className="mt-4 flex items-center justify-end gap-2">
<Button
variant="ghost"
aria-label="ignore all agents"
disabled={vm.busy}
onClick={() => vm.ignoreAll()}
>
Tout ignorer
</Button>
<Button
variant="primary"
aria-label="resume all agents"
loading={vm.busy}
disabled={vm.busy}
onClick={() => void vm.resumeAll()}
>
Tout reprendre
</Button>
</div>
</div>
</div>
);
}

View File

@ -6,3 +6,7 @@ export { AgentsPanel } from "./AgentsPanel";
export type { AgentsPanelProps } from "./AgentsPanel";
export { useAgents } from "./useAgents";
export type { AgentsViewModel } from "./useAgents";
export { ResumeProjectPanel } from "./ResumeProjectPanel";
export type { ResumeProjectPanelProps } from "./ResumeProjectPanel";
export { useResumeProject } from "./useResumeProject";
export type { ResumeProjectViewModel } from "./useResumeProject";

View File

@ -0,0 +1,283 @@
/**
* B2 — reopen resume flow (ARCHITECTURE §15.2) wired to the stateful
* `MockAgentGateway` / `MockLayoutGateway` via the real `DIProvider`.
*
* Covers both layers visible from the frontend:
* - **Adapter** (`TauriAgentGateway.listResumableAgents`): invokes
* `list_resumable_agents` with a top-level `{ projectId }` payload (NOT
* `{ request: … }`) and unwraps `{ resumable }` → `ResumableAgent[]`.
* - **Panel** (`ResumeProjectPanel`): renders nothing on an empty inventory;
* lists the resumable agents (with their status) when non-empty.
* - **Actions** (`useResumeProject`): Reprendre (with / without resume support),
* Nouvelle conversation, Ignorer, Tout reprendre / Tout ignorer — each
* asserting the exact `launchAgent` / `setCellConversation` calls.
* - French labels on the main title + actions.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import {
MockAgentGateway,
MockLayoutGateway,
} from "@/adapters/mock";
import type { Gateways } from "@/ports";
import type { ResumableAgent } from "@/domain";
import { DIProvider } from "@/app/di";
import { ResumeProjectPanel } from "./ResumeProjectPanel";
// ---------------------------------------------------------------------------
// Adapter: TauriAgentGateway.listResumableAgents payload + unwrap
// ---------------------------------------------------------------------------
const invokeMock = vi.fn();
vi.mock("@tauri-apps/api/core", () => ({
invoke: (...args: unknown[]) => invokeMock(...args),
Channel: class {},
}));
describe("TauriAgentGateway.listResumableAgents (adapter)", () => {
beforeEach(() => invokeMock.mockReset());
it("invokes list_resumable_agents with top-level projectId and unwraps { resumable }", async () => {
const { TauriAgentGateway } = await import("@/adapters/agent");
const wire: ResumableAgent[] = [
{
agentId: "a1",
name: "Architect",
nodeId: "n1",
conversationId: "c1",
wasRunning: true,
resumeSupported: true,
},
];
invokeMock.mockResolvedValueOnce({ resumable: wire });
const gw = new TauriAgentGateway();
const out = await gw.listResumableAgents("proj-42");
// Command name + EXACT payload: top-level projectId, not wrapped in request.
expect(invokeMock).toHaveBeenCalledTimes(1);
expect(invokeMock).toHaveBeenCalledWith("list_resumable_agents", {
projectId: "proj-42",
});
const [, payload] = invokeMock.mock.calls[0];
expect(payload).not.toHaveProperty("request");
// Unwrapped to the bare array.
expect(out).toEqual(wire);
});
});
// ---------------------------------------------------------------------------
// Panel + actions: behind the real DIProvider with stateful mocks
// ---------------------------------------------------------------------------
const PROJECT_ID = "proj-resume-001";
const CWD = "/home/me/proj";
function entry(over: Partial<ResumableAgent> = {}): ResumableAgent {
return {
agentId: "agent-1",
name: "Architect",
nodeId: "node-1",
conversationId: "conv-1",
wasRunning: true,
resumeSupported: true,
...over,
};
}
function renderPanel(seed: ResumableAgent[]) {
const agent = new MockAgentGateway();
const layout = new MockLayoutGateway();
agent._setResumableAgents(PROJECT_ID, seed);
// Spy on the launch/mutate flow so we assert the exact calls without relying
// on the singleton/agent-existence checks of the stateful mock.
const launchSpy = vi
.spyOn(agent, "launchAgent")
.mockResolvedValue({} as never);
// Resolve the layout mutation: the seeded `nodeId`s are synthetic and don't
// exist in the mock's default tree, so the real `applyOperation` would throw.
const mutateSpy = vi
.spyOn(layout, "mutateLayout")
.mockResolvedValue({} as never);
const gateways = { agent, layout } as unknown as Gateways;
const utils = render(
<DIProvider gateways={gateways}>
<ResumeProjectPanel projectId={PROJECT_ID} cwd={CWD} />
</DIProvider>,
);
return { agent, layout, launchSpy, mutateSpy, ...utils };
}
describe("ResumeProjectPanel (panel mounting)", () => {
it("renders nothing when the resumable inventory is empty", async () => {
const { container } = renderPanel([]);
// No async inventory ⇒ stays null. Give the effect a tick to settle.
await waitFor(() => {
expect(screen.queryByTestId("resume-project-panel")).toBeNull();
});
expect(container.firstChild).toBeNull();
});
it("mounts the panel listing the resumable agents when non-empty", async () => {
renderPanel([
entry({ agentId: "a-run", name: "Architect", wasRunning: true }),
entry({
agentId: "a-closed",
name: "Tester",
nodeId: "node-2",
wasRunning: false,
}),
]);
await screen.findByTestId("resume-project-panel");
expect(screen.getByTestId("resume-row-a-run")).toBeTruthy();
expect(screen.getByTestId("resume-row-a-closed")).toBeTruthy();
// Status derived from wasRunning.
expect(screen.getByTestId("resume-status-a-run").textContent).toBe(
"en cours",
);
expect(screen.getByTestId("resume-status-a-closed").textContent).toBe(
"clôt",
);
});
it("uses French labels for the title and global actions", async () => {
renderPanel([entry()]);
await screen.findByTestId("resume-project-panel");
// Title carries a non-breaking space before "?" (`&nbsp;`).
expect(screen.getByText(/Reprendre les agents\s*\?/)).toBeTruthy();
expect(screen.getByText("Tout reprendre")).toBeTruthy();
expect(screen.getByText("Tout ignorer")).toBeTruthy();
expect(screen.getByText("Nouvelle conversation")).toBeTruthy();
});
});
describe("ResumeProjectPanel — per-agent actions", () => {
it("Reprendre (resumeSupported + conversationId) → launchAgent with nodeId + conversationId", async () => {
const { launchSpy } = renderPanel([
entry({
agentId: "a1",
nodeId: "node-9",
conversationId: "conv-9",
resumeSupported: true,
}),
]);
await screen.findByTestId("resume-project-panel");
fireEvent.click(screen.getByLabelText("resume Architect"));
await waitFor(() => expect(launchSpy).toHaveBeenCalledTimes(1));
const [projectId, agentId, options] = launchSpy.mock.calls[0];
expect(projectId).toBe(PROJECT_ID);
expect(agentId).toBe("a1");
expect(options.nodeId).toBe("node-9");
expect(options.conversationId).toBe("conv-9");
// The button label is "Reprendre" when resume is supported.
expect(screen.queryByText("Reprendre")).toBeNull(); // row drained after action
});
it("Reprendre (resumeSupported === false) → 'relance à neuf' label + launch WITHOUT conversationId", async () => {
const { launchSpy } = renderPanel([
entry({
agentId: "a2",
nodeId: "node-7",
conversationId: "conv-ignored",
resumeSupported: false,
name: "Legacy",
}),
]);
await screen.findByTestId("resume-project-panel");
// The "relance à neuf" wording is shown for an unsupported profile.
expect(screen.getByTestId("resume-fresh-note-a2").textContent).toContain(
"relance à neuf",
);
// And the primary button reads "Relancer à neuf" (FR), not "Reprendre".
const btn = screen.getByLabelText("resume Legacy");
expect(btn.textContent).toBe("Relancer à neuf");
fireEvent.click(btn);
await waitFor(() => expect(launchSpy).toHaveBeenCalledTimes(1));
const [, , options] = launchSpy.mock.calls[0];
expect(options.nodeId).toBe("node-7");
// Unsupported ⇒ NO conversation id passed (launches fresh).
expect(options.conversationId).toBeUndefined();
});
it("Nouvelle conversation → setCellConversation(nodeId, null) then launch without conversationId", async () => {
const { launchSpy, mutateSpy } = renderPanel([
entry({ agentId: "a3", nodeId: "node-3", conversationId: "conv-3" }),
]);
await screen.findByTestId("resume-project-panel");
fireEvent.click(screen.getByLabelText("new conversation Architect"));
await waitFor(() => expect(launchSpy).toHaveBeenCalledTimes(1));
// setCellConversation cleared BEFORE the launch.
expect(mutateSpy).toHaveBeenCalledWith(PROJECT_ID, {
type: "setCellConversation",
target: "node-3",
conversationId: null,
});
const mutateOrder = mutateSpy.mock.invocationCallOrder[0];
const launchOrder = launchSpy.mock.invocationCallOrder[0];
expect(mutateOrder).toBeLessThan(launchOrder);
// Launch carries no conversation id.
const [, , options] = launchSpy.mock.calls[0];
expect(options.conversationId).toBeUndefined();
});
it("Ignorer → drops the row locally, no launchAgent", async () => {
const { launchSpy } = renderPanel([
entry({ agentId: "a4", name: "Doomed" }),
]);
await screen.findByTestId("resume-project-panel");
fireEvent.click(screen.getByLabelText("ignore Doomed"));
await waitFor(() =>
expect(screen.queryByTestId("resume-row-a4")).toBeNull(),
);
expect(launchSpy).not.toHaveBeenCalled();
});
});
describe("ResumeProjectPanel — global actions", () => {
it("Tout reprendre → launches every pending agent", async () => {
const { launchSpy } = renderPanel([
entry({ agentId: "a1", nodeId: "n1" }),
entry({ agentId: "a2", nodeId: "n2", name: "Tester" }),
]);
await screen.findByTestId("resume-project-panel");
fireEvent.click(screen.getByLabelText("resume all agents"));
await waitFor(() => expect(launchSpy).toHaveBeenCalledTimes(2));
const launchedAgentIds = launchSpy.mock.calls.map((c) => c[1]).sort();
expect(launchedAgentIds).toEqual(["a1", "a2"]);
// Panel unmounts once the pending set drains.
await waitFor(() =>
expect(screen.queryByTestId("resume-project-panel")).toBeNull(),
);
});
it("Tout ignorer → no launch, panel unmounts", async () => {
const { launchSpy } = renderPanel([
entry({ agentId: "a1" }),
entry({ agentId: "a2", name: "Tester" }),
]);
await screen.findByTestId("resume-project-panel");
fireEvent.click(screen.getByLabelText("ignore all agents"));
await waitFor(() =>
expect(screen.queryByTestId("resume-project-panel")).toBeNull(),
);
expect(launchSpy).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,181 @@
/**
* `useResumeProject` — view-model for the reopen resume flow (L15 / lot B2,
* ARCHITECTURE §15.2).
*
* On project open, it pulls the read-only resumable inventory
* ({@link AgentGateway.listResumableAgents}) and drives the per-agent resume
* actions of the {@link ResumeProjectPanel}. The actual resume **reuses the
* existing `launch_agent` flow** — no new backend use case:
*
* - **Reprendre** → `launchAgent(nodeId, conversationId)` (resume the CLI
* conversation). For a profile without a usable session strategy
* (`resumeSupported === false`) the entry simply carries no `conversationId`,
* so the launch starts fresh ("relance à neuf").
* - **Nouvelle conversation** → `setCellConversation(nodeId, null)` first (so the
* backend treats the cell as fresh and assigns a new id), then
* `launchAgent(nodeId)` without a conversation id.
* - **Ignorer** → drops the entry locally (no launch).
*
* The launch happens at the project level (no mounted xterm view yet): `onData`
* is a no-op sink — the hosting cell reattaches to the spawned live session when
* it mounts. It consumes the `agent` + `layout` gateways exclusively; never
* `invoke()` (ARCHITECTURE §1.3), so it stays testable with mocks.
*/
import { useCallback, useEffect, useState } from "react";
import type { GatewayError, ResumableAgent } from "@/domain";
import { useGateways } from "@/app/di";
/** Default PTY geometry used for an open-time (headless) resume launch. */
const DEFAULT_ROWS = 24;
const DEFAULT_COLS = 80;
/** What the {@link ResumeProjectPanel} needs from this hook. */
export interface ResumeProjectViewModel {
/** The agents still pending a decision (drains as the user acts). */
pending: ResumableAgent[];
/** Last error message, or `null`. */
error: string | null;
/** Whether a resume launch is in flight. */
busy: boolean;
/** Resumes one agent (keeps its conversation id ⇒ Resume / fresh if unsupported). */
resume: (agent: ResumableAgent) => Promise<void>;
/** Starts a fresh conversation for one agent (clears the id, then launches). */
newConversation: (agent: ResumableAgent) => Promise<void>;
/** Dismisses one agent without launching it. */
ignore: (agentId: string) => void;
/** Resumes every pending agent. */
resumeAll: () => Promise<void>;
/** Dismisses every pending agent without launching. */
ignoreAll: () => void;
}
function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as GatewayError).message);
}
return String(e);
}
/** A no-op output sink: the hosting cell reattaches to the live session later. */
const NO_DATA = (_bytes: Uint8Array): void => {};
export function useResumeProject(
projectId: string,
cwd: string,
): ResumeProjectViewModel {
const { agent, layout } = useGateways();
const [pending, setPending] = useState<ResumableAgent[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
// Pull the inventory once per (project) open. A failure degrades to an empty
// list (no panel), never surfaces — the per-cell fallback still works.
useEffect(() => {
if (!agent) return;
let cancelled = false;
agent
.listResumableAgents(projectId)
.then((list) => {
if (!cancelled) setPending(list);
})
.catch(() => {
if (!cancelled) setPending([]);
});
return () => {
cancelled = true;
};
}, [agent, projectId]);
/** Removes one agent from the pending set (after a decision). */
const drop = useCallback((agentId: string) => {
setPending((prev) => prev.filter((a) => a.agentId !== agentId));
}, []);
/** Launches one agent, resuming `conversationId` when provided. */
const launch = useCallback(
async (a: ResumableAgent, conversationId: string | undefined) => {
if (!agent) return;
await agent.launchAgent(
projectId,
a.agentId,
{
cwd,
rows: DEFAULT_ROWS,
cols: DEFAULT_COLS,
conversationId,
nodeId: a.nodeId,
},
NO_DATA,
);
},
[agent, projectId, cwd],
);
const resume = useCallback(
async (a: ResumableAgent) => {
setBusy(true);
setError(null);
try {
// A profile without a usable session strategy carries no conversation id
// ⇒ launches fresh ("relance à neuf"); otherwise resume the id.
const convId = a.resumeSupported ? a.conversationId : undefined;
await launch(a, convId ?? undefined);
drop(a.agentId);
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
},
[launch, drop],
);
const newConversation = useCallback(
async (a: ResumableAgent) => {
setBusy(true);
setError(null);
try {
// Clear the persisted id BEFORE launching so the backend assigns a fresh
// conversation (mirrors LayoutGrid's `onNewConversation`).
await layout.mutateLayout(projectId, {
type: "setCellConversation",
target: a.nodeId,
conversationId: null,
});
await launch(a, undefined);
drop(a.agentId);
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
},
[layout, projectId, launch, drop],
);
const ignore = useCallback((agentId: string) => drop(agentId), [drop]);
const resumeAll = useCallback(async () => {
// Snapshot the current pending set; `resume` drains it entry by entry.
const snapshot = pending;
for (const a of snapshot) {
// eslint-disable-next-line no-await-in-loop
await resume(a);
}
}, [pending, resume]);
const ignoreAll = useCallback(() => setPending([]), []);
return {
pending,
error,
busy,
resume,
newConversation,
ignore,
resumeAll,
ignoreAll,
};
}

View File

@ -32,7 +32,7 @@ import { useState } from "react";
import type { LayoutInfo } from "@/domain";
import { LayoutGrid, LayoutTabs } from "@/features/layout";
import { AgentsPanel } from "@/features/agents";
import { AgentsPanel, ResumeProjectPanel } from "@/features/agents";
import { TemplatesPanel } from "@/features/templates";
import { SkillsPanel } from "@/features/skills";
import { MemoryPanel } from "@/features/memory";
@ -339,6 +339,17 @@ export function ProjectsView() {
)}
</main>
</div>
{/* ── Reopen resume panel (§15.2) ──
Mounted per active project (keyed by id) so it re-pulls the resumable
inventory on every open/switch; it renders nothing when empty. */}
{active && (
<ResumeProjectPanel
key={`resume-${active.id}`}
projectId={active.id}
cwd={active.root}
/>
)}
</div>
);
}