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:
120
frontend/src/features/terminals/ResumeConversationPopup.tsx
Normal file
120
frontend/src/features/terminals/ResumeConversationPopup.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Resume-conversation popup (T7). Shown for an agent cell whose PTY session is
|
||||
* dead but which carries a persisted `conversationId`, **before** the automatic
|
||||
* Resume launch fires. It lets the user choose between resuming the previous CLI
|
||||
* conversation or starting a fresh one.
|
||||
*
|
||||
* Pure presentation: it receives the status + best-effort details + the two
|
||||
* action callbacks. It never calls a gateway / `invoke()` itself — the parent
|
||||
* (`LayoutGrid`'s `LeafView`) wires it to the {@link AgentGateway} ports. The
|
||||
* status ("en cours" / "clot") is derived **only** from `agentWasRunning` (the
|
||||
* universal close-time flag), never from the inspector — a missing inspector
|
||||
* just hides the enriched topic/token lines (degraded mode).
|
||||
*/
|
||||
|
||||
import type { ConversationDetails } from "@/ports";
|
||||
|
||||
interface ResumeConversationPopupProps {
|
||||
/**
|
||||
* Whether the agent process was running when the cell was last closed
|
||||
* (`agentWasRunning`). `true` ⇒ "en cours"; `false`/absent ⇒ "clot". This is
|
||||
* the universal status; it is NOT derived from the inspector.
|
||||
*/
|
||||
agentWasRunning: boolean;
|
||||
/**
|
||||
* Best-effort enriched details (last topic + token indicator). `null` while
|
||||
* loading; an empty object once loaded with no details available (degraded
|
||||
* mode: only the status + the resume affordance are shown).
|
||||
*/
|
||||
details: ConversationDetails | null;
|
||||
/** Resume the previous conversation (keeps the conversation id ⇒ Resume). */
|
||||
onResume: () => void;
|
||||
/** Start a fresh conversation (clears the conversation id ⇒ Assign). */
|
||||
onNewConversation: () => void;
|
||||
}
|
||||
|
||||
export function ResumeConversationPopup({
|
||||
agentWasRunning,
|
||||
details,
|
||||
onResume,
|
||||
onNewConversation,
|
||||
}: ResumeConversationPopupProps) {
|
||||
const statusLabel = agentWasRunning ? "en cours" : "clot";
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="resume-conversation-popup"
|
||||
role="dialog"
|
||||
aria-label="Reprise de conversation"
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 5,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(0, 0, 0, 0.55)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
minWidth: 260,
|
||||
maxWidth: 360,
|
||||
padding: "16px 18px",
|
||||
background: "var(--color-surface, #1e1e1e)",
|
||||
color: "var(--color-content, #e0e0e0)",
|
||||
border: "1px solid var(--color-border, #3a3a3a)",
|
||||
borderRadius: 6,
|
||||
boxShadow: "0 8px 24px rgba(0, 0, 0, 0.5)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<p style={{ margin: "0 0 8px", fontWeight: 600 }}>
|
||||
Reprise de conversation
|
||||
</p>
|
||||
|
||||
<p style={{ margin: "0 0 6px" }}>
|
||||
Statut :{" "}
|
||||
<span data-testid="resume-status">{statusLabel}</span>
|
||||
</p>
|
||||
|
||||
{details?.lastTopic && (
|
||||
<p style={{ margin: "0 0 6px" }} data-testid="resume-last-topic">
|
||||
Dernier sujet : {details.lastTopic}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{details?.tokenCount !== undefined && (
|
||||
<p style={{ margin: "0 0 10px" }} data-testid="resume-token-count">
|
||||
Tokens : {details.tokenCount}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onResume}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "6px 10px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Reprendre la dernière conversation
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewConversation}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "6px 10px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Nouvelle conversation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -80,6 +80,7 @@ describe("TerminalView (with MockTerminalGateway)", () => {
|
||||
return handle;
|
||||
}),
|
||||
reattach: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
};
|
||||
|
||||
expect(() => renderView(terminal)).not.toThrow();
|
||||
@ -93,7 +94,7 @@ describe("TerminalView (with MockTerminalGateway)", () => {
|
||||
const close = vi.fn().mockResolvedValue(undefined);
|
||||
const handle = makeHandle({ detach, close });
|
||||
const openTerminal = vi.fn(async () => handle);
|
||||
const terminal: TerminalGateway = { openTerminal, reattach: vi.fn() };
|
||||
const terminal: TerminalGateway = { openTerminal, reattach: vi.fn(), closeTerminal: vi.fn() };
|
||||
|
||||
const { unmount } = renderView(terminal);
|
||||
|
||||
@ -129,7 +130,7 @@ describe("TerminalView (with MockTerminalGateway)", () => {
|
||||
},
|
||||
);
|
||||
const openTerminal = vi.fn(async () => handle);
|
||||
const terminal: TerminalGateway = { openTerminal, reattach };
|
||||
const terminal: TerminalGateway = { openTerminal, reattach, closeTerminal: vi.fn() };
|
||||
|
||||
renderView(terminal, "/cwd", { sessionId: "live-1" });
|
||||
|
||||
@ -147,7 +148,7 @@ describe("TerminalView (with MockTerminalGateway)", () => {
|
||||
it("persists a newly opened session id via onSessionId", async () => {
|
||||
const handle = makeHandle({ sessionId: "new-99" });
|
||||
const openTerminal = vi.fn(async () => handle);
|
||||
const terminal: TerminalGateway = { openTerminal, reattach: vi.fn() };
|
||||
const terminal: TerminalGateway = { openTerminal, reattach: vi.fn(), closeTerminal: vi.fn() };
|
||||
const onSessionId = vi.fn();
|
||||
|
||||
renderView(terminal, "/cwd", { onSessionId });
|
||||
|
||||
@ -202,19 +202,41 @@ export function TerminalView({
|
||||
.catch(onOpenError);
|
||||
}
|
||||
|
||||
// Refit + propagate size to the PTY on container resize.
|
||||
const ro = new ResizeObserver(() => {
|
||||
// Refit + propagate size to the PTY on container resize. ResizeObserver can
|
||||
// fire many times per frame, and during layout/tab transitions the container
|
||||
// momentarily reports a zero or transient size — fitting then would size
|
||||
// xterm's grid (and the PTY) to a stale value, leaving the repainted content
|
||||
// shifted/misaligned once the real size settles. So we: (1) coalesce bursts
|
||||
// into a single `requestAnimationFrame` that runs after layout settles,
|
||||
// (2) skip fitting while the container has no real size, and (3) push a PTY
|
||||
// resize only when rows/cols actually change (avoids redundant reflows).
|
||||
let rafId = 0;
|
||||
let lastRows = term.rows;
|
||||
let lastCols = term.cols;
|
||||
const refit = () => {
|
||||
rafId = 0;
|
||||
if (disposed) return;
|
||||
if (container.clientWidth === 0 || container.clientHeight === 0) return;
|
||||
try {
|
||||
fit.fit();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (handle) void handle.resize(term.rows, term.cols);
|
||||
if (handle && (term.rows !== lastRows || term.cols !== lastCols)) {
|
||||
lastRows = term.rows;
|
||||
lastCols = term.cols;
|
||||
void handle.resize(term.rows, term.cols);
|
||||
}
|
||||
};
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
rafId = requestAnimationFrame(refit);
|
||||
});
|
||||
ro.observe(container);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
ro.disconnect();
|
||||
onKey.dispose();
|
||||
// DETACH, never close: tearing the view down (navigation / layout change)
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
/** Terminals feature (L3): xterm.js wrapper bound to the `TerminalGateway`. */
|
||||
|
||||
export { TerminalView } from "./TerminalView";
|
||||
export { ResumeConversationPopup } from "./ResumeConversationPopup";
|
||||
|
||||
Reference in New Issue
Block a user