feat(web): persistent workspace terminal across tab switches

This commit is contained in:
Gabriel Brown
2026-07-11 13:05:49 -04:00
parent 33e7732566
commit 148e060a9b
4 changed files with 127 additions and 13 deletions
@@ -71,6 +71,7 @@ const lightTheme: ITheme = {
export const XtermSession = ({
active,
visible = true,
tokenUrl,
sessionKey,
waitingLabel,
@@ -78,6 +79,7 @@ export const XtermSession = ({
label = 'workspace shell',
}: {
active: boolean;
visible?: boolean;
tokenUrl: string;
sessionKey: string;
waitingLabel?: string;
@@ -86,9 +88,11 @@ export const XtermSession = ({
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<{ fit: () => void } | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const { resolvedTheme } = useTheme();
const themeIsLight = resolvedTheme === 'light';
const [status, setStatus] = useState<XtermStatus>('connecting');
const [status, setStatus] = useState<XtermStatus>('idle');
const [errorText, setErrorText] = useState<string>();
const [reconnectKey, setReconnectKey] = useState(0);
@@ -158,6 +162,7 @@ export const XtermSession = ({
term.loadAddon(new WebLinksAddon());
term.open(container);
termRef.current = term;
fitRef.current = fit;
// Task 11 will wire clipboard copy-on-select behavior here. Accept the
// prop now so callers (and the effect deps) are stable across that change.
@@ -193,6 +198,7 @@ export const XtermSession = ({
});
ws = new WebSocket(url);
wsRef.current = ws;
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
if (isAborted()) return;
@@ -233,24 +239,51 @@ export const XtermSession = ({
ws?.close();
termRef.current?.dispose();
termRef.current = null;
fitRef.current = null;
wsRef.current = null;
};
// resolvedTheme intentionally excluded: handled by the theme effect above.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active, sessionKey, reconnectKey]);
// A hidden (display:none) container measures 0×0, so xterm mis-sizes while the
// tab is inactive. When the terminal becomes visible again, refit inside a
// frame (so layout has settled) and push the new dimensions to the PTY. The
// WebSocket stays open across visibility changes — this never reconnects.
useEffect(() => {
if (!visible || !active) return;
const frame = requestAnimationFrame(() => {
try {
fitRef.current?.fit();
} catch {
// ignore transient layout errors
}
const ws = wsRef.current;
const term = termRef.current;
if (ws?.readyState === WebSocket.OPEN && term) {
ws.send(
JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }),
);
}
});
return () => cancelAnimationFrame(frame);
}, [visible, active]);
return (
<div className='relative flex h-full min-h-0 flex-col'>
<div className='border-border flex h-10 flex-none items-center justify-between gap-3 border-b px-3'>
<p className='text-muted-foreground text-xs'>
{status === 'connected'
? `Connected · ${label}`
: status === 'connecting'
? 'Connecting…'
: status === 'closed'
? 'Session ended'
: status === 'unconfigured'
? 'Terminal not configured'
: 'Connection error'}
: status === 'idle'
? (waitingLabel ? 'Waiting…' : 'Idle')
: status === 'connecting'
? 'Connecting…'
: status === 'closed'
? 'Session ended'
: status === 'unconfigured'
? 'Terminal not configured'
: 'Connection error'}
</p>
{status === 'closed' || status === 'error' ? (
<Button