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>
);
}