feat(web): persistent workspace terminal across tab switches
This commit is contained in:
@@ -26,6 +26,7 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
Button,
|
||||
cn,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
@@ -681,11 +682,19 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value='terminal'
|
||||
className='m-0 min-h-0 flex-1 overflow-hidden'
|
||||
forceMount
|
||||
className={cn(
|
||||
'm-0 min-h-0 flex-1 overflow-hidden',
|
||||
activeWorkspaceTab !== 'terminal' && 'hidden',
|
||||
)}
|
||||
>
|
||||
<WorkspaceTerminal
|
||||
jobId={jobId}
|
||||
active={activeWorkspaceTab === 'terminal' && workspaceReady}
|
||||
active={workspaceReady}
|
||||
visible={activeWorkspaceTab === 'terminal'}
|
||||
waitingLabel={
|
||||
workspacePending ? 'Waiting for workspace…' : undefined
|
||||
}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value='diff' className='m-0 min-h-0 flex-1'>
|
||||
|
||||
@@ -5,12 +5,18 @@ import { XtermSession } from './xterm-session';
|
||||
export const WorkspaceTerminal = ({
|
||||
jobId,
|
||||
active,
|
||||
visible = true,
|
||||
waitingLabel,
|
||||
}: {
|
||||
jobId: string;
|
||||
active: boolean;
|
||||
visible?: boolean;
|
||||
waitingLabel?: string;
|
||||
}) => (
|
||||
<XtermSession
|
||||
active={active}
|
||||
visible={visible}
|
||||
waitingLabel={waitingLabel}
|
||||
tokenUrl={`/api/agent-jobs/${jobId}/terminal-token`}
|
||||
sessionKey={jobId}
|
||||
/>
|
||||
|
||||
@@ -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,17 +239,44 @@ 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 === 'idle'
|
||||
? (waitingLabel ? 'Waiting…' : 'Idle')
|
||||
: status === 'connecting'
|
||||
? 'Connecting…'
|
||||
: status === 'closed'
|
||||
|
||||
@@ -3,10 +3,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { XtermSession } from '../../src/components/agent-workspace/xterm-session';
|
||||
|
||||
const { openSpy, wsInstances, fetchSpy } = vi.hoisted(() => ({
|
||||
const { openSpy, wsInstances, fetchSpy, fitSpy } = vi.hoisted(() => ({
|
||||
openSpy: vi.fn(),
|
||||
wsInstances: [] as Array<{ url: string }>,
|
||||
wsInstances: [] as Array<{
|
||||
url: string;
|
||||
send: ReturnType<typeof vi.fn>;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
}>,
|
||||
fetchSpy: vi.fn(),
|
||||
fitSpy: vi.fn(),
|
||||
}));
|
||||
|
||||
class FakeTerminal {
|
||||
@@ -30,7 +35,7 @@ class FakeTerminal {
|
||||
vi.mock('@xterm/xterm', () => ({ Terminal: FakeTerminal }));
|
||||
vi.mock('@xterm/addon-fit', () => ({
|
||||
FitAddon: class {
|
||||
fit = vi.fn();
|
||||
fit = fitSpy;
|
||||
},
|
||||
}));
|
||||
vi.mock('@xterm/addon-web-links', () => ({
|
||||
@@ -60,6 +65,7 @@ class FakeWebSocket {
|
||||
beforeEach(() => {
|
||||
wsInstances.length = 0;
|
||||
openSpy.mockClear();
|
||||
fitSpy.mockClear();
|
||||
fetchSpy.mockReset();
|
||||
fetchSpy.mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -127,4 +133,64 @@ describe('XtermSession', () => {
|
||||
expect(wsInstances[0]?.url).toBe('ws://x');
|
||||
expect(openSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refits and sends a resize frame when it becomes visible', async () => {
|
||||
const { rerender } = render(
|
||||
<XtermSession
|
||||
active
|
||||
visible={false}
|
||||
tokenUrl='/api/box/terminal-token'
|
||||
sessionKey='box-1'
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(wsInstances).toHaveLength(1));
|
||||
const ws = wsInstances[0]!;
|
||||
// Ignore fits/sends from the initial connect; only the visible toggle matters.
|
||||
fitSpy.mockClear();
|
||||
ws.send.mockClear();
|
||||
|
||||
rerender(
|
||||
<XtermSession
|
||||
active
|
||||
visible
|
||||
tokenUrl='/api/box/terminal-token'
|
||||
sessionKey='box-1'
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(fitSpy).toHaveBeenCalled());
|
||||
await waitFor(() =>
|
||||
expect(ws.send).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"type":"resize"'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the WebSocket open when hidden while still active', async () => {
|
||||
const { rerender } = render(
|
||||
<XtermSession
|
||||
active
|
||||
visible
|
||||
tokenUrl='/api/box/terminal-token'
|
||||
sessionKey='box-1'
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(wsInstances).toHaveLength(1));
|
||||
const ws = wsInstances[0]!;
|
||||
|
||||
rerender(
|
||||
<XtermSession
|
||||
active
|
||||
visible={false}
|
||||
tokenUrl='/api/box/terminal-token'
|
||||
sessionKey='box-1'
|
||||
/>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(ws.close).not.toHaveBeenCalled();
|
||||
expect(wsInstances).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user