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:
@ -20,7 +20,12 @@ import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { Agent } from "@/domain";
|
||||
import type { LayoutNode } from "@/domain";
|
||||
import { TerminalView } from "@/features/terminals";
|
||||
import type {
|
||||
ConversationDetails,
|
||||
OpenTerminalOptions,
|
||||
TerminalHandle,
|
||||
} from "@/ports";
|
||||
import { ResumeConversationPopup, TerminalView } from "@/features/terminals";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { normalizeWeights, resizeAdjacent } from "./layout";
|
||||
import { useLayout, type LayoutViewModel } from "./useLayout";
|
||||
@ -89,6 +94,8 @@ function NodeView({ node, cwd, vm, parentSplit, projectId }: NodeViewProps) {
|
||||
id={node.node.id}
|
||||
session={node.node.session ?? null}
|
||||
agent={node.node.agent ?? null}
|
||||
conversationId={node.node.conversationId ?? null}
|
||||
agentWasRunning={node.node.agentWasRunning ?? false}
|
||||
cwd={cwd}
|
||||
vm={vm}
|
||||
parentSplit={parentSplit}
|
||||
@ -106,13 +113,28 @@ interface LeafViewProps {
|
||||
id: string;
|
||||
session: string | null;
|
||||
agent: string | null;
|
||||
conversationId: string | null;
|
||||
agentWasRunning: boolean;
|
||||
cwd: string;
|
||||
vm: LayoutViewModel;
|
||||
parentSplit: { container: string; index: number; siblings: number } | null;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
function LeafView({ id, session, agent, cwd, vm, parentSplit, projectId }: LeafViewProps) {
|
||||
/**
|
||||
* A launch deferred by the resume popup (T7): TerminalView asked the opener to
|
||||
* launch (fresh open or reattach-failed fallback) for a cell that carries a
|
||||
* `conversationId`. We hold the open request here until the user picks
|
||||
* Reprendre / Nouvelle conversation, then resolve the promise with the handle.
|
||||
*/
|
||||
interface PendingResume {
|
||||
opts: OpenTerminalOptions;
|
||||
onData: (bytes: Uint8Array) => void;
|
||||
resolve: (handle: TerminalHandle) => void;
|
||||
reject: (e: unknown) => void;
|
||||
}
|
||||
|
||||
function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm, parentSplit, projectId }: LeafViewProps) {
|
||||
// A cell can be closed only when it lives inside a (binary) split: closing it
|
||||
// collapses the parent split, keeping the *sibling*. Splits are always binary
|
||||
// in this model (a split wraps a leaf into a 2-child container), so the kept
|
||||
@ -136,10 +158,77 @@ function LeafView({ id, session, agent, cwd, vm, parentSplit, projectId }: LeafV
|
||||
|
||||
// Build the terminal opener based on whether an agent is pinned.
|
||||
const agentId = agent ?? null;
|
||||
|
||||
// ── Resume popup state (T7) ───────────────────────────────────────────────
|
||||
// When an agent cell carries a persisted conversation id and its PTY session
|
||||
// is dead, the opener is about to relaunch in Resume mode. We intercept that
|
||||
// launch with a popup so the user can choose Resume vs a fresh conversation.
|
||||
// The reattach path (live PTY) never goes through this opener, so the popup is
|
||||
// naturally skipped there.
|
||||
const [pendingResume, setPendingResume] = useState<PendingResume | null>(null);
|
||||
const [resumeDetails, setResumeDetails] = useState<ConversationDetails | null>(null);
|
||||
|
||||
/** Performs the actual launch and persists any assigned id (T4b loop). */
|
||||
const doLaunch = async (
|
||||
opts: OpenTerminalOptions,
|
||||
onData: (bytes: Uint8Array) => void,
|
||||
convId: string | undefined,
|
||||
): Promise<TerminalHandle> => {
|
||||
const handle = await agentGateway!.launchAgent(
|
||||
projectId,
|
||||
agentId!,
|
||||
{ ...opts, conversationId: convId },
|
||||
onData,
|
||||
);
|
||||
// First launch on a fresh cell mints a conversation id: persist it on the
|
||||
// leaf so the next open resumes (T4b — closes the persistence loop).
|
||||
if (handle.assignedConversationId) {
|
||||
void vm.setCellConversation(id, handle.assignedConversationId);
|
||||
}
|
||||
return handle;
|
||||
};
|
||||
|
||||
const terminalOpener = agentGateway && agentId
|
||||
? (opts: Parameters<typeof agentGateway.launchAgent>[2], onData: (bytes: Uint8Array) => void) =>
|
||||
agentGateway.launchAgent(projectId, agentId, opts, onData)
|
||||
? (opts: OpenTerminalOptions, onData: (bytes: Uint8Array) => void): Promise<TerminalHandle> => {
|
||||
// No persisted conversation ⇒ fresh cell: open straight away (the launch
|
||||
// may assign a new id). No popup on this path.
|
||||
if (!conversationId) {
|
||||
return doLaunch(opts, onData, undefined);
|
||||
}
|
||||
// Resume case: defer the launch behind the popup. Fetch the best-effort
|
||||
// enriched details (last topic + tokens) to enrich it; failure or empty
|
||||
// ⇒ degraded mode (status only). Inspection never blocks the resume.
|
||||
Promise.resolve(
|
||||
agentGateway.inspectConversation?.(projectId, agentId, conversationId),
|
||||
)
|
||||
.then((details) => setResumeDetails(details ?? {}))
|
||||
.catch(() => setResumeDetails({}));
|
||||
return new Promise<TerminalHandle>((resolve, reject) => {
|
||||
setPendingResume({ opts, onData, resolve, reject });
|
||||
});
|
||||
}
|
||||
: undefined;
|
||||
|
||||
/** "Reprendre" → launch resuming the existing conversation id. */
|
||||
const onResume = () => {
|
||||
const p = pendingResume;
|
||||
if (!p) return;
|
||||
setPendingResume(null);
|
||||
setResumeDetails(null);
|
||||
doLaunch(p.opts, p.onData, conversationId ?? undefined).then(p.resolve, p.reject);
|
||||
};
|
||||
|
||||
/** "Nouvelle conversation" → clear the id first (Assign), then launch fresh. */
|
||||
const onNewConversation = () => {
|
||||
const p = pendingResume;
|
||||
if (!p) return;
|
||||
setPendingResume(null);
|
||||
setResumeDetails(null);
|
||||
// Clear the persisted conversation id BEFORE launching so the backend treats
|
||||
// it as a fresh cell and assigns a new conversation.
|
||||
void vm.setCellConversation(id, null);
|
||||
doLaunch(p.opts, p.onData, undefined).then(p.resolve, p.reject);
|
||||
};
|
||||
// Agent cells re-attach through the agent gateway; plain cells fall back to
|
||||
// the terminal gateway's reattach (handled by TerminalView's default).
|
||||
const reattachOpener = agentGateway && agentId
|
||||
@ -240,6 +329,14 @@ function LeafView({ id, session, agent, cwd, vm, parentSplit, projectId }: LeafV
|
||||
sessionId={session}
|
||||
onSessionId={(sid) => void vm.setSession(id, sid)}
|
||||
/>
|
||||
{pendingResume && (
|
||||
<ResumeConversationPopup
|
||||
agentWasRunning={agentWasRunning}
|
||||
details={resumeDetails}
|
||||
onResume={onResume}
|
||||
onNewConversation={onNewConversation}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user