terminals: re-attach on reopen, and make orphaned shells findable
Closing a terminal panel abandoned its shell. TerminalWrapper deleted the panel -> session mapping on *unmount*, so any layout or route change generated a fresh uuid on the way back and left the old shell running: alive, unreachable, and never killed, because nothing has ever sent pty:close. The mapping now outlives the mount, so reopening a panel re-attaches to the shell you left — which is also what finally makes the sidecar's replay buffer worth having. It is persisted dashboard state, so this survives a reload too. That trades an invisible leak for a visible one: a panel deleted for good still leaves its shell behind. So `pty:list` now enumerates live sessions, and GET /api/terminal/sessions + DELETE /api/terminal/sessions/:id expose them. pty:close finally has a sender. Each session carries createdAt, lastActivityAt, pid, and the title the shell sets for itself via OSC 0/2 — usually the running command, which is what turns "some uuid" into "the one running claude" when you are deciding what to kill. Killing on unmount is still not an option: it needs the panel system to distinguish a real close from an incidental remount, which it cannot currently do. Also raises the sidecar replay buffer from 50KB to 512KB — 50KB was about one long agent turn, so reconnecting mid-task showed you the tail and nothing before it — and cuts the buffer on a line boundary rather than a byte offset. A blind slice can land inside an escape sequence, and the replay then opens with the tail of a colour or cursor-move code, which xterm renders as garbage or applies as a real instruction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useWorkspace } from '../../components/Workspace';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
@@ -18,9 +18,6 @@ export const TerminalWrapper = ({ panelId }: { panelId: string }) => {
|
||||
stateKey,
|
||||
EMPTY_TERMINALS,
|
||||
);
|
||||
const setTerminalsRef = useRef(setTerminals);
|
||||
setTerminalsRef.current = setTerminals;
|
||||
|
||||
const sessionId = terminals[panelId];
|
||||
|
||||
useEffect(() => {
|
||||
@@ -30,14 +27,15 @@ export const TerminalWrapper = ({ panelId }: { panelId: string }) => {
|
||||
}
|
||||
}, [panelId, sessionId, setTerminals]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setTerminalsRef.current((prev) => {
|
||||
const { [panelId]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
};
|
||||
}, [panelId]);
|
||||
// The panel → session mapping deliberately OUTLIVES the mount. It used to be deleted on unmount, so any
|
||||
// layout or route change generated a fresh uuid on the way back and abandoned the previous shell — alive,
|
||||
// unreachable, and never killed, because nothing sends pty:close. Keeping it means reopening a terminal
|
||||
// panel re-attaches to the shell you left running, which is also what makes the sidecar's replay buffer
|
||||
// worth having. The map is persisted dashboard state, so this survives a reload too.
|
||||
//
|
||||
// The cost is that a panel deleted for good leaves its entry behind; the running-shells list is how you
|
||||
// find and kill those. Killing on unmount is not an option until the panel system can tell a real close
|
||||
// from an incidental remount.
|
||||
|
||||
const [, setConnState] = useGlobal<TerminalConnectionState>(`terminal-conn-${panelId}`, 'disconnected');
|
||||
const onConnectionChange = useCallback((state: TerminalConnectionState) => setConnState(state), [setConnState]);
|
||||
|
||||
Reference in New Issue
Block a user