fix(layout): ré-attacher le flux d'une cellule au réveil arrière-plan d'un agent

Une cellule ne ré-attachait pas le flux de sortie de son agent lorsque
celui-ci était réveillé en arrière-plan par une délégation : l'utilisateur
devait basculer la vue Plain↔agent pour voir l'agent travailler.

Ajout d'un effect de ré-attache déclenché sur le réveil arrière-plan + bump
de la key pour forcer le remontage de la vue. Test de régression
liveReattachOnDelegation : rouge sans le fix, vert avec.

Vérifs : tsc --noEmit exit 0 ; vitest run 48 fichiers / 444 tests OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 13:52:25 +02:00
parent 77e62e54c1
commit db19ef35f5
2 changed files with 188 additions and 1 deletions

View File

@ -329,6 +329,47 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
const [pendingResume, setPendingResume] = useState<PendingResume | null>(null);
const [resumeDetails, setResumeDetails] = useState<ConversationDetails | null>(null);
// ── Live re-attach on background wake (inter-agent delegation) ─────────────
// 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
// cell has mounted — the classic case being an inter-agent delegation: the
// orchestrator wakes a cold target with `node_id = None` (background) and
// publishes `AgentLaunched`. Without this, the cell keeps showing its
// previous (often dead) session and only re-subscribes to the live output
// once the user toggles the view (Plain↔agent), which remounts the terminal.
// We do that re-attach declaratively instead, so the delegated turn is
// visible live with no manual toggle. The counter is bumped ONLY here (the
// background-wake path), never on a normal fresh launch — so ordinary mounts
// are untouched and never thrash.
const [attachGen, setAttachGen] = useState(0);
useEffect(() => {
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
// this cell is not already bound to that session. `backgroundLive` is the
// same guard `doLaunch` and the dropdown use; crucially it ignores a session
// hosted by THIS cell (nodeId === id), so a fresh self-launch never triggers
// a re-attach loop.
const bg = backgroundLive(agentId);
if (!bg?.sessionId || bg.sessionId === session) return;
let cancelled = false;
void (async () => {
const attached = await agentGateway.attachLiveAgent!(projectId, agentId, id);
if (cancelled) return;
await vm.attachLiveAgentToCell(id, agentId, attached.sessionId ?? bg.sessionId);
if (!cancelled) setAttachGen((g) => g + 1);
})().catch(() => {
/* ignore — the view toggle remains the manual fallback */
});
return () => {
cancelled = true;
};
// `liveAgents` is the trigger (refreshed by the agentLaunched event below);
// `session` guards against re-firing once attached.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [liveAgents, agentId, session]);
// ── 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,
@ -571,7 +612,7 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
}}
>
<TerminalView
key={`${id}-${agentId ?? "plain"}`}
key={`${id}-${agentId ?? "plain"}-${attachGen}`}
cwd={cwd}
open={terminalOpener}
reattach={reattachOpener}