feat(backend,application,frontend,domain,web-server,app-tauri): bouton Redémarrer l'agent dans la barre supérieure des cellules — #317 (QA verte)

La cellule expose un bouton Redémarrer l'agent valable pour les trois
modes de présentation (headless, TUI native, CLI custom), sans jamais
prendre la présentation pour vérité runtime.

- Application: use case RestartCellAgentRuntime validant le binding
  exact cellule/runtime via les snapshots live (RestartRuntimeNotFound,
  ConflictAgentVisibleElsewhere, AgentNotBoundToCell) puis déléguant au
  scheduler self-restart existant (schedule_cell); CellAgentPresentation
  purement informationnelle.
- Backend/app-tauri/web-server: commande restart_cell_agent sur contrat
  DTO identique Tauri/HTTP, stop borné par timeout typé
  (RestartStopTimeout), relance préservant routage TUI natif, géométrie
  de cellule (node/rows/cols) et conversation; évenements domaine
  enrichis.
- Frontend: bouton dans la barre de cellule avec confirmation,
  overlay d'état Redémarrage…/échec (libellés par code d'erreur, Fermer/
  Réessayer), gardes d'identité de cellule et de restarts externes,
  gateway/mock/http adapters et ports alignés.
- Tests: dto_agents, workstate_actions, contrats web-server,
  LayoutGrid.cellControlsLayering, adapters agent/mock/http.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 15:00:43 +02:00
parent 46449bdf46
commit 7fb9d8bb86
26 changed files with 1404 additions and 58 deletions

View File

@ -398,6 +398,7 @@ describe("CustomAgentChatView", () => {
agentId: "agent-1",
restartId: "restart-2",
phase: "launching",
code: "LAUNCH_FAILED",
message: "runtime unavailable",
});
});

View File

@ -361,6 +361,7 @@ describe("#288 — native TUI restart overlay", () => {
agentId: "agent-T",
restartId: "restart-2",
phase: "launching",
code: "LAUNCH_FAILED",
message: "runtime unavailable",
});
});

View File

@ -194,6 +194,183 @@ function expectedOperation(action: CellAction, cellId: string) {
}
describe("LayoutGrid — ticket #48 cell control layering", () => {
it("shows restart only for an agent cell and disables its inputs while scheduled", async () => {
const setup = await makeSplitAgentGrid();
const restart = vi
.spyOn(setup.gateways.agent, "restartCellAgentRuntime")
.mockResolvedValue({
restartId: "restart-cell-1",
status: "scheduled",
agentId: setup.agentId,
nodeId: setup.bId,
kind: "pty",
presentation: "tui",
layoutId: null,
});
renderGrid(setup.gateways);
const button = await screen.findByRole("button", { name: "Redémarrer l’agent" });
expect(screen.getAllByRole("button", { name: "Redémarrer l’agent" })).toHaveLength(1);
fireEvent.click(button);
await waitFor(() =>
expect(restart).toHaveBeenCalledWith({
projectId: "p1",
nodeId: setup.bId,
agentId: setup.agentId,
presentation: "tui",
}),
);
expect(
(screen.getByRole("button", {
name: "Redémarrage de l’agent…",
}) as HTMLButtonElement).disabled,
).toBe(true);
expect(
(screen.getByLabelText(`agent selector ${setup.bId}`) as HTMLSelectElement)
.disabled,
).toBe(true);
expect(screen.getByText("Redémarrage de l’agent…")).toBeTruthy();
await flush();
act(() => {
setup.system.emit({
type: "agentRestartCompleted",
agentId: setup.agentId,
restartId: "restart-cell-1",
});
});
await waitFor(() =>
expect(
(screen.getByRole("button", {
name: "Redémarrer l’agent",
}) as HTMLButtonElement).disabled,
).toBe(false),
);
});
it("confirms a busy restart and forwards the custom CLI presentation", async () => {
const setup = await makeSplitAgentGrid();
window.localStorage.setItem(`idea.agent-cell-mode.p1.${setup.bId}`, "custom");
setup.workState._setProjectWorkState("p1", {
agents: [
{
agentId: setup.agentId,
name: "Worker",
profileId: "mock-claude",
busy: { state: "busy", sinceMs: 1 },
},
],
});
const restart = vi
.spyOn(setup.gateways.agent, "restartCellAgentRuntime")
.mockResolvedValue({
restartId: "restart-cell-2",
status: "scheduled",
agentId: setup.agentId,
nodeId: setup.bId,
kind: "structured",
presentation: "customCli",
layoutId: null,
});
renderGrid(setup.gateways);
fireEvent.click(await screen.findByRole("button", { name: "Redémarrer l’agent" }));
const dialog = await screen.findByRole("alertdialog", {
name: "Redémarrer l’agent ?",
});
expect(restart).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Interrompre et redémarrer" }));
await waitFor(() =>
expect(restart).toHaveBeenCalledWith({
projectId: "p1",
nodeId: setup.bId,
agentId: setup.agentId,
presentation: "customCli",
}),
);
expect(dialog.isConnected).toBe(false);
window.localStorage.removeItem(`idea.agent-cell-mode.p1.${setup.bId}`);
});
it("distinguishes an asynchronous relaunch failure and offers retry/close", async () => {
const setup = await makeSplitAgentGrid();
vi.spyOn(setup.gateways.agent, "restartCellAgentRuntime").mockResolvedValue({
restartId: "restart-cell-3",
status: "scheduled",
agentId: setup.agentId,
nodeId: setup.bId,
kind: "pty",
presentation: "tui",
layoutId: null,
});
renderGrid(setup.gateways);
fireEvent.click(await screen.findByRole("button", { name: "Redémarrer l’agent" }));
await flush();
act(() => {
setup.system.emit({
type: "agentRestartFailed",
agentId: setup.agentId,
restartId: "restart-cell-3",
phase: "launching",
code: "LAUNCH_FAILED",
message: "provider unavailable",
});
});
expect(await screen.findByText("Impossible de relancer l’agent.")).toBeTruthy();
expect(screen.getByText("provider unavailable")).toBeTruthy();
expect(screen.getByRole("button", { name: "Réessayer" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Fermer" }));
expect(screen.queryByTestId("cell-agent-restart-state")).toBeNull();
});
it("distinguishes an asynchronous stop failure", async () => {
const setup = await makeSplitAgentGrid();
vi.spyOn(setup.gateways.agent, "restartCellAgentRuntime").mockResolvedValue({
restartId: "restart-cell-stop",
status: "scheduled",
agentId: setup.agentId,
nodeId: setup.bId,
kind: "pty",
presentation: "tui",
layoutId: null,
});
renderGrid(setup.gateways);
fireEvent.click(await screen.findByRole("button", { name: "Redémarrer l’agent" }));
await flush();
act(() => {
setup.system.emit({
type: "agentRestartFailed",
agentId: setup.agentId,
restartId: "restart-cell-stop",
phase: "stopping",
code: "STOP_TIMEOUT",
message: "stop timed out",
});
});
expect(await screen.findByText("Impossible d’arrêter l’agent.")).toBeTruthy();
});
it("surfaces a synchronous typed binding error", async () => {
const setup = await makeSplitAgentGrid();
vi.spyOn(setup.gateways.agent, "restartCellAgentRuntime").mockRejectedValue({
code: "AGENT_NOT_BOUND_TO_CELL",
message: "stale cell binding",
});
renderGrid(setup.gateways);
fireEvent.click(await screen.findByRole("button", { name: "Redémarrer l’agent" }));
expect(
await screen.findByText("L’agent n’est plus associé à cette cellule."),
).toBeTruthy();
expect(screen.getByText("stale cell binding")).toBeTruthy();
});
it("keeps custom CLI mode controls compact inside the cell toolbar (#151)", async () => {
const setup = await makeSplitAgentGrid();

View File

@ -275,6 +275,13 @@ interface PendingModeSwitch {
target: AgentCellMode;
}
interface CellRestartState {
status: "running" | "failed";
restartId: string | null;
code?: string;
message?: string;
}
interface TrustedCustomCliBinding {
agent: Agent;
profile: AgentProfile;
@ -382,6 +389,91 @@ function StoppedAgentCellPanel({
);
}
function restartFailureLabel(code?: string): string {
switch (code) {
case "STOP_TIMEOUT":
case "STOP_FAILED":
return "Impossible d’arrêter l’agent.";
case "LAUNCH_FAILED":
case "RESUME_FAILED":
return "Impossible de relancer l’agent.";
case "RUNTIME_NOT_FOUND":
return "Runtime de l’agent introuvable.";
case "AGENT_NOT_BOUND_TO_CELL":
return "L’agent n’est plus associé à cette cellule.";
case "CONFLICT_AGENT_VISIBLE_ELSEWHERE":
return "Cet agent est visible dans une autre cellule.";
case "CONFLICT":
return "Un redémarrage est déjà en cours pour cet agent.";
default:
return "Impossible de redémarrer l’agent.";
}
}
function CellAgentRestartOverlay({
state,
onRetry,
onClose,
}: {
state: CellRestartState;
onRetry: () => void;
onClose: () => void;
}) {
const failed = state.status === "failed";
return (
<div
data-testid="cell-agent-restart-state"
role={failed ? "alert" : "status"}
aria-live="polite"
style={{
position: "absolute",
inset: 0,
zIndex: failed ? CELL_Z.controls + 1 : CELL_Z.veil,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(0, 0, 0, 0.58)",
color: "var(--color-content, #e0e0e0)",
padding: 12,
pointerEvents: failed ? "auto" : "none",
}}
>
<div
style={{
maxWidth: 420,
border: "1px solid var(--color-border, #3a3a3a)",
borderRadius: 6,
background: "var(--color-surface, #1e1e1e)",
padding: "8px 12px",
fontSize: 12,
}}
>
<strong>
{failed ? restartFailureLabel(state.code) : "Redémarrage de l’agent…"}
</strong>
{failed && state.message && (
<p style={{ margin: "6px 0 0", color: "var(--color-muted, #9aa7b4)" }}>
{state.message}
</p>
)}
{failed && (
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 8,
marginTop: 8,
}}
>
<button type="button" onClick={onClose}>Fermer</button>
<button type="button" onClick={onRetry}>Réessayer</button>
</div>
)}
</div>
</div>
);
}
/**
* Local z-index scale for a single cell (ticket #48). Intra-cell layering only —
* unrelated to the global {@link zIndex} scale used for app-level surfaces.
@ -769,6 +861,76 @@ function LeafView({
const busyByWorkState = agentWork?.busy.state === "busy" || Boolean(activeTicket);
const delegatedTicket = activeTicket?.source.kind === "agent" ? activeTicket : undefined;
const busyActive = announcementBusyActive || busyByWorkState;
const [cellRestart, setCellRestart] = useState<CellRestartState | null>(null);
const cellRestartRef = useRef<CellRestartState | null>(null);
const cellRestartRequestRef = useRef(0);
const cellRestartOwnedIdRef = useRef<string | null>(null);
const [restartConfirmationOpen, setRestartConfirmationOpen] = useState(false);
const cellRestartBusy = cellRestart?.status === "running";
const otherRestartActive = Boolean(
agentRestart && agentRestart.restartId !== cellRestartOwnedIdRef.current,
);
const cellIdentity = `${projectId}:${layoutId ?? ""}:${id}:${agentId ?? ""}`;
const cellIdentityRef = useRef(cellIdentity);
const previousCellIdentityRef = useRef(cellIdentity);
cellIdentityRef.current = cellIdentity;
const commitCellRestart = (next: CellRestartState | null) => {
cellRestartRef.current = next;
setCellRestart(next);
};
useEffect(() => {
if (previousCellIdentityRef.current === cellIdentity) return;
previousCellIdentityRef.current = cellIdentity;
cellRestartRequestRef.current += 1;
cellRestartOwnedIdRef.current = null;
commitCellRestart(null);
setRestartConfirmationOpen(false);
}, [cellIdentity]);
async function restartCellAgent(): Promise<void> {
if (!agentId || !agentGateway || cellRestartRef.current?.status === "running") return;
const requestIdentity = cellIdentity;
const sequence = ++cellRestartRequestRef.current;
cellRestartOwnedIdRef.current = null;
const running: CellRestartState = { status: "running", restartId: null };
commitCellRestart(running);
setRestartConfirmationOpen(false);
try {
const scheduled = await agentGateway.restartCellAgentRuntime({
projectId,
nodeId: id,
agentId,
presentation: cellMode === "custom" ? "customCli" : "tui",
...(layoutId ? { layoutId } : {}),
});
if (
cellRestartRequestRef.current === sequence &&
cellIdentityRef.current === requestIdentity
) {
cellRestartOwnedIdRef.current = scheduled.restartId;
commitCellRestart({ status: "running", restartId: scheduled.restartId });
}
} catch (error) {
if (
cellRestartRequestRef.current === sequence &&
cellIdentityRef.current === requestIdentity
) {
commitCellRestart({
status: "failed",
restartId: null,
code: gatewayErrorCode(error),
message: describeNotice(error),
});
}
}
}
function requestCellAgentRestart(): void {
if (busyActive) setRestartConfirmationOpen(true);
else void restartCellAgent();
}
const targetAnnouncementsFallback = delegatedTicket?.requesterLabel
? `Travaille sur une demande de ${delegatedTicket.requesterLabel}.`
: "Travaille sur une demande en cours.";
@ -1042,6 +1204,84 @@ function LeafView({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [liveAgents, agentId, session]);
useEffect(() => {
if (!system || !agentId) return;
let cancelled = false;
let unsubscribe: (() => void) | undefined;
void system
.onDomainEvent((event) => {
if (
event.type !== "agentRestartRequested" &&
event.type !== "agentRestartPhaseChanged" &&
event.type !== "agentRestartCompleted" &&
event.type !== "agentRestartFailed"
) return;
if (event.agentId !== agentId) return;
const current = cellRestartRef.current;
if (!current || current.status !== "running") return;
if (event.type === "agentRestartRequested") {
if (!current.restartId) {
cellRestartOwnedIdRef.current = event.restartId;
commitCellRestart({ ...current, restartId: event.restartId });
}
return;
}
if (
event.type !== "agentRestartCompleted" &&
event.type !== "agentRestartFailed"
) return;
if (current.restartId && current.restartId !== event.restartId) return;
if (event.type === "agentRestartFailed") {
cellRestartOwnedIdRef.current = event.restartId;
commitCellRestart({
status: "failed",
restartId: event.restartId,
code: event.code,
message: event.message,
});
return;
}
commitCellRestart(null);
void (async () => {
const live = await agentGateway?.listLiveAgents(projectId);
if (cancelled || !live) return;
setLiveAgents(live);
const restarted = live.find((entry) => entry.agentId === agentId);
if (
restarted?.kind === "pty" &&
restarted.sessionId &&
restarted.sessionId !== session
) {
await vm.attachLiveAgentToCell(
id,
agentId,
restarted.sessionId,
);
if (!cancelled) setAttachGen((generation) => generation + 1);
}
await refreshWorkState();
})().catch(() => {
// The restart itself completed; lifecycle refresh remains best-effort.
});
})
.then((un) => {
if (cancelled) un();
else unsubscribe = un;
})
.catch(() => {
// The synchronous command error remains visible if events are unavailable.
});
return () => {
cancelled = true;
unsubscribe?.();
};
// The subscription follows the exact visible cell identity. Runtime refresh
// functions are deliberately read from that render until the identity changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [system, agentGateway, projectId, layoutId, id, agentId]);
// ── Cell routing (Option 1 — Terminal + MCP) ──────────────────────────────
// Every agent cell renders the raw {@link TerminalView}: the human view is the
// native interactive PTY (live reasoning + Échap are native CLI behaviours,
@ -1143,6 +1383,7 @@ function LeafView({
<select
aria-label={`agent selector ${id}`}
value={agentId ?? ""}
disabled={cellRestartBusy}
onChange={(e) => {
const val = e.target.value;
if (val === "") {
@ -1230,6 +1471,7 @@ function LeafView({
>
<button
type="button"
disabled={cellRestartBusy}
aria-pressed={cellMode === "tui"}
title="TUI native"
onClick={() => requestMode("tui")}
@ -1257,6 +1499,7 @@ function LeafView({
</button>
<button
type="button"
disabled={cellRestartBusy}
aria-pressed={cellMode === "custom"}
title="CLI custom"
onClick={() => requestMode("custom")}
@ -1284,6 +1527,18 @@ function LeafView({
</div>
)}
{agentId && (
<button
type="button"
title={cellRestartBusy ? "Redémarrage de l’agent…" : "Redémarrer l’agent"}
aria-label={cellRestartBusy ? "Redémarrage de l’agent…" : "Redémarrer l’agent"}
disabled={cellRestartBusy}
onClick={requestCellAgentRestart}
>
↻
</button>
)}
<button
type="button"
title="Split into columns"
@ -1467,7 +1722,8 @@ function LeafView({
is being injected into the agent's PTY, a grey veil with a centred
message sits above the terminal. Only ever shown for an agent cell —
and never together with the F3 overlay (exactly one veil, F3 first). */}
{!agentRestart &&
{!cellRestart &&
!otherRestartActive &&
!modelServerOverlay &&
cellMode !== "custom" &&
shouldShowWritePortalVeil(agentId != null, Boolean(overlay), busyActive) && (
@ -1506,7 +1762,7 @@ function LeafView({
an availability veil showing its live réflexions. Native TUI cells
need the full-cell guard; custom CLI cells keep the canonical busy
badge and composer lock only, matching their normal chat turn flow. */}
{!agentRestart && !modelServerOverlay && cellMode !== "custom" && agentId != null && (
{!cellRestart && !otherRestartActive && !modelServerOverlay && cellMode !== "custom" && agentId != null && (
<TargetAnnouncementsOverlay
projectId={projectId}
agentId={agentId}
@ -1519,7 +1775,7 @@ function LeafView({
agent's bound local server is downloading its model / starting /
probing, the empty terminal is covered rather than left to time out.
Retracts at `ready`/`failed` (the predicate returns null then). */}
{!agentRestart && modelServerOverlay && (
{!cellRestart && !otherRestartActive && modelServerOverlay && (
<div
data-testid="model-server-overlay"
role="status"
@ -1636,9 +1892,19 @@ function LeafView({
</div>
</div>
)}
{cellMode !== "custom" && agentId != null && agentRestart && (
{!cellRestart &&
cellMode !== "custom" &&
agentId != null &&
otherRestartActive && (
<AgentRestartOverlay agentId={agentId} />
)}
{cellRestart && (
<CellAgentRestartOverlay
state={cellRestart}
onRetry={() => void restartCellAgent()}
onClose={() => commitCellRestart(null)}
/>
)}
</div>
{busyNotice && (
<div
@ -1769,6 +2035,58 @@ function LeafView({
</div>
</div>
)}
{restartConfirmationOpen && (
<div
role="alertdialog"
aria-modal="true"
aria-label="Redémarrer l’agent ?"
style={{
position: "absolute",
inset: 0,
zIndex: CELL_Z.controls + 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(0, 0, 0, 0.62)",
padding: 12,
}}
>
<div
style={{
width: 360,
maxWidth: "100%",
border: "1px solid var(--color-border, #3a3a3a)",
borderRadius: 6,
background: "var(--color-surface, #1e1e1e)",
color: "var(--color-content, #e0e0e0)",
padding: 12,
}}
>
<h2 style={{ margin: 0, fontSize: 14 }}>Redémarrer l’agent ?</h2>
<p style={{ margin: "8px 0 0", fontSize: 12 }}>
L’activité en cours sera interrompue avant la relance de l’agent.
</p>
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 8,
marginTop: 12,
}}
>
<button
type="button"
onClick={() => setRestartConfirmationOpen(false)}
>
Annuler
</button>
<button type="button" onClick={() => void restartCellAgent()}>
Interrompre et redémarrer
</button>
</div>
</div>
</div>
)}
</div>
);
}
@ -2022,6 +2340,13 @@ function describeNotice(e: unknown): string {
return String(e);
}
function gatewayErrorCode(e: unknown): string | undefined {
if (e && typeof e === "object" && "code" in e) {
return String((e as { code: unknown }).code);
}
return undefined;
}
/** True when `e` is the backend singleton-invariant refusal (`GatewayError`). */
function isAlreadyRunning(e: unknown): boolean {
return (