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:
181
frontend/src/features/agents/useResumeProject.ts
Normal file
181
frontend/src/features/agents/useResumeProject.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user