fix(terminals): decouple PTY lifecycle from view lifecycle (no kill on navigation)
Navigating (layout/tab switch) tore the xterm view down and called handle.close(), killing the backend PTY and cutting off running AIs. Now the view's cleanup only detaches; only an explicit user action kills a PTY. Backend: - PortablePtyAdapter: per-session scrollback ring buffer (~100KB, most recent) + re-subscribable fan-out broadcast replacing the single-take output_rx. Reader thread feeds both the ring buffer and current subscribers; on EOF it closes subscribers (streams end) while keeping scrollback for late re-attach. - PtyPort: new scrollback() method; subscribe_output is now re-subscribable (all impls + test fakes updated). - reattach_terminal IPC command: returns scrollback and re-wires a fresh output channel on the live session without re-spawning. - CloseRequested hook kills all live PTYs cleanly on app shutdown. - TerminalSessions::handles() to enumerate live sessions at shutdown. Frontend: - TerminalHandle.detach(); TerminalGateway/AgentGateway.reattach() + mocks. - TerminalView cleanup detaches (never close); on mount it re-attaches to a persisted session (repainting scrollback) instead of opening a new PTY. - LayoutGrid persists the cell's session id via setSession; AgentsPanel tracks per-agent session ids — both drive reattach-vs-open. Tests: ring buffer bounds to 100KB keeping newest bytes; scrollback retained; re-subscription delivers post-reattach output; TerminalView detaches (not closes) on unmount and reattaches with a known session; mock detach/reattach. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -111,4 +111,61 @@ describe("MockTerminalGateway", () => {
|
||||
// Exactly one delivery so far: the greeting.
|
||||
expect(onData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("detach stops delivery to the old view but keeps the session alive", async () => {
|
||||
const gw = new MockTerminalGateway();
|
||||
const first: Uint8Array[] = [];
|
||||
const handle = await gw.openTerminal(
|
||||
{ cwd: "/c", rows: 24, cols: 80 },
|
||||
(b) => first.push(b),
|
||||
);
|
||||
await flushMicrotasks();
|
||||
first.length = 0;
|
||||
|
||||
handle.detach();
|
||||
// Output produced after detach must NOT reach the detached view.
|
||||
await handle.write(new TextEncoder().encode("after-detach"));
|
||||
expect(first).toHaveLength(0);
|
||||
|
||||
// But the session is still alive: reattach succeeds.
|
||||
await expect(
|
||||
gw.reattach(handle.sessionId, () => {}),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("reattach replays scrollback and resumes live output", async () => {
|
||||
const gw = new MockTerminalGateway();
|
||||
const handle = await gw.openTerminal(
|
||||
{ cwd: "/work", rows: 24, cols: 80 },
|
||||
() => {},
|
||||
);
|
||||
await flushMicrotasks();
|
||||
await handle.write(new TextEncoder().encode("typed"));
|
||||
handle.detach();
|
||||
|
||||
const fresh: Uint8Array[] = [];
|
||||
const { handle: h2, scrollback } = await gw.reattach(
|
||||
handle.sessionId,
|
||||
(b) => fresh.push(b),
|
||||
);
|
||||
// Scrollback carries the prior greeting + echoed input.
|
||||
const sb = decode([scrollback]);
|
||||
expect(sb).toContain("/work");
|
||||
expect(sb).toContain("typed");
|
||||
// New output now flows to the re-attached view.
|
||||
await h2.write(new TextEncoder().encode("more"));
|
||||
expect(decode(fresh)).toBe("more");
|
||||
});
|
||||
|
||||
it("reattach to a closed session rejects (PTY is gone)", async () => {
|
||||
const gw = new MockTerminalGateway();
|
||||
const handle = await gw.openTerminal(
|
||||
{ cwd: "/c", rows: 24, cols: 80 },
|
||||
() => {},
|
||||
);
|
||||
await handle.close();
|
||||
await expect(gw.reattach(handle.sessionId, () => {})).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user