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

@ -30,6 +30,7 @@ import type {
} from "@/domain";
import type {
AgentGateway,
ConversationDetails,
CreateAgentInput,
CreateSkillInput,
CreateTemplateInput,
@ -323,7 +324,17 @@ export class MockAgentGateway implements AgentGateway {
queueMicrotask(() =>
session.emit(enc.encode(`agent ${agentId} @ ${cwd}\r\n`)),
);
return makeMockHandle(session, () => this.sessions.delete(sessionId));
const handle = makeMockHandle(session, () => this.sessions.delete(sessionId));
// Simulate session assignment (T4b): a fresh cell (no conversation id) gets a
// newly-minted id surfaced on the handle so the caller persists it; a cell
// that already carries an id resumes it and nothing new is assigned.
if (!options.conversationId) {
return {
...handle,
assignedConversationId: `mock-conversation-${sessionId}`,
};
}
return handle;
}
async reattach(
@ -344,6 +355,31 @@ export class MockAgentGateway implements AgentGateway {
scrollback,
};
}
/**
* Best-effort conversation details, keyed by conversation id (T7). Empty by
* default (degraded mode); a test seeds enriched details via
* {@link _setConversationDetails}.
*/
private conversationDetails = new Map<string, ConversationDetails>();
/** Seeds the (best-effort) details returned for a given conversation id. */
_setConversationDetails(
conversationId: string,
details: ConversationDetails,
): void {
this.conversationDetails.set(conversationId, details);
}
async inspectConversation(
_projectId: string,
_agentId: string,
conversationId: string,
): Promise<ConversationDetails> {
// Best-effort: an unknown conversation yields empty details (degraded mode),
// never an error — mirroring the backend contract.
return structuredClone(this.conversationDetails.get(conversationId) ?? {});
}
}
/**
@ -421,6 +457,16 @@ export class MockTerminalGateway implements TerminalGateway {
scrollback,
};
}
async closeTerminal(sessionId: string): Promise<void> {
// Best-effort / idempotent: kill the PTY by id (mirrors the handle's close)
// so a later reattach to it fails. No-op if the session is already gone.
const session = this.sessions.get(sessionId);
if (session) {
session.closed = true;
this.sessions.delete(sessionId);
}
}
}
export class MockProjectGateway implements ProjectGateway {