feat(terminals): reprise de conversation par cellule + fix ordre d'écriture

Permet de recharger la conversation CLI précédente de chaque cellule à la
réouverture du projet, de façon universelle (indépendant du modèle/CLI).

- profil AgentRuntime: bloc déclaratif optionnel `session { assignFlag, resumeFlag }`
- LeafCell: `conversationId` (persistant, distinct du SessionId PTY) + `agentWasRunning`
- runtime: SessionPlan (None/Assign/Resume) + composition pure des args
- LaunchAgent: décide Assign vs Resume, génère l'UUID, remonte l'id assigné
  (persistance par l'appelant via setCellConversation — découplage SRP)
- close: SnapshotRunningAgents fige `agentWasRunning` avant le kill-all
  (statut clot/en cours universel, sans parsing CLI)
- SessionInspector: port optionnel best-effort + adapter ClaudeTranscriptInspector
- popup de reprise par cellule (statut + sujet/tokens si dispo), intercalée
  avant le Resume auto, jamais sur le chemin reattach

fix(terminals): sérialise les écritures PTY (file FIFO par handle) — corrige
les caractères mélangés/accents dus au réordonnancement des invoke Tauri concurrents

fix(layout): l'opération `move` préservait mal les champs du leaf (perdait `agent`)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 22:27:08 +02:00
parent d11eaaa8c0
commit 3ed0f6b45f
61 changed files with 5098 additions and 98 deletions

View File

@ -201,11 +201,13 @@ export function applyOperation(
if (target === undefined) throw notFound(op.to);
if (target !== null) throw invalid("target cell is occupied");
const root = mapNode(tree.root, (n) => {
// Preserve agent / conversationId / agentWasRunning on both ends — only
// the ephemeral `session` moves (mirror of the Rust `move_session`).
if (n.type === "leaf" && n.node.id === op.from) {
return { type: "leaf", node: { id: n.node.id, session: null } };
return { type: "leaf", node: { ...n.node, session: null } };
}
if (n.type === "leaf" && n.node.id === op.to) {
return { type: "leaf", node: { id: n.node.id, session } };
return { type: "leaf", node: { ...n.node, session } };
}
return n;
});
@ -242,6 +244,40 @@ export function applyOperation(
if (!found) throw notFound(op.target);
return { root };
}
case "setCellConversation": {
let found = false;
const root = mapNode(tree.root, (n) => {
if (n.type === "leaf" && n.node.id === op.target) {
found = true;
const { conversationId: _c, ...rest } = n.node;
// Omit when null (mirrors the backend `skip_serializing_if`).
const updated = op.conversationId !== null
? { ...rest, conversationId: op.conversationId }
: rest;
return { type: "leaf", node: updated };
}
return n;
});
if (!found) throw notFound(op.target);
return { root };
}
case "setAgentRunning": {
let found = false;
const root = mapNode(tree.root, (n) => {
if (n.type === "leaf" && n.node.id === op.target) {
found = true;
const { agentWasRunning: _r, ...rest } = n.node;
// Omit when false (mirrors the backend `skip_serializing_if`).
const updated = op.running
? { ...rest, agentWasRunning: true }
: rest;
return { type: "leaf", node: updated };
}
return n;
});
if (!found) throw notFound(op.target);
return { root };
}
}
}
@ -255,6 +291,34 @@ export function leaves(tree: LayoutTree): LeafCell[] {
return out;
}
/**
* Sessions hosted by the children a `merge` would **drop** — every child of
* `container` except the kept one, walked recursively (a dropped child may be a
* nested split with several terminals). Used by the close button to tear down
* the discarded PTYs instead of orphaning them, mirroring `setCellAgent`. Returns
* `[]` if the container isn't found or holds no sessions.
*/
export function droppedSessions(
tree: LayoutTree,
container: string,
keepIndex: number,
): string[] {
const out: string[] = [];
mapNode(tree.root, (n) => {
if (n.type === "split" && n.node.id === container) {
n.node.children.forEach((c, i) => {
if (i === keepIndex) return;
mapNode(c.node, (m) => {
if (m.type === "leaf" && m.node.session) out.push(m.node.session);
return m;
});
});
}
return n;
});
return out;
}
/** Convenience: builds a `split` operation splitting `target` in `direction`. */
export function splitOp(target: string, direction: Direction): LayoutOperation {
return {