feat(web): persistent workspace terminal across tab switches
This commit is contained in:
@@ -26,6 +26,7 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
Button,
|
Button,
|
||||||
|
cn,
|
||||||
Tabs,
|
Tabs,
|
||||||
TabsContent,
|
TabsContent,
|
||||||
TabsList,
|
TabsList,
|
||||||
@@ -681,11 +682,19 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent
|
<TabsContent
|
||||||
value='terminal'
|
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
|
<WorkspaceTerminal
|
||||||
jobId={jobId}
|
jobId={jobId}
|
||||||
active={activeWorkspaceTab === 'terminal' && workspaceReady}
|
active={workspaceReady}
|
||||||
|
visible={activeWorkspaceTab === 'terminal'}
|
||||||
|
waitingLabel={
|
||||||
|
workspacePending ? 'Waiting for workspace…' : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value='diff' className='m-0 min-h-0 flex-1'>
|
<TabsContent value='diff' className='m-0 min-h-0 flex-1'>
|
||||||
|
|||||||
@@ -5,12 +5,18 @@ import { XtermSession } from './xterm-session';
|
|||||||
export const WorkspaceTerminal = ({
|
export const WorkspaceTerminal = ({
|
||||||
jobId,
|
jobId,
|
||||||
active,
|
active,
|
||||||
|
visible = true,
|
||||||
|
waitingLabel,
|
||||||
}: {
|
}: {
|
||||||
jobId: string;
|
jobId: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
visible?: boolean;
|
||||||
|
waitingLabel?: string;
|
||||||
}) => (
|
}) => (
|
||||||
<XtermSession
|
<XtermSession
|
||||||
active={active}
|
active={active}
|
||||||
|
visible={visible}
|
||||||
|
waitingLabel={waitingLabel}
|
||||||
tokenUrl={`/api/agent-jobs/${jobId}/terminal-token`}
|
tokenUrl={`/api/agent-jobs/${jobId}/terminal-token`}
|
||||||
sessionKey={jobId}
|
sessionKey={jobId}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ const lightTheme: ITheme = {
|
|||||||
|
|
||||||
export const XtermSession = ({
|
export const XtermSession = ({
|
||||||
active,
|
active,
|
||||||
|
visible = true,
|
||||||
tokenUrl,
|
tokenUrl,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
waitingLabel,
|
waitingLabel,
|
||||||
@@ -78,6 +79,7 @@ export const XtermSession = ({
|
|||||||
label = 'workspace shell',
|
label = 'workspace shell',
|
||||||
}: {
|
}: {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
visible?: boolean;
|
||||||
tokenUrl: string;
|
tokenUrl: string;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
waitingLabel?: string;
|
waitingLabel?: string;
|
||||||
@@ -86,9 +88,11 @@ export const XtermSession = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const termRef = useRef<Terminal | null>(null);
|
const termRef = useRef<Terminal | null>(null);
|
||||||
|
const fitRef = useRef<{ fit: () => void } | null>(null);
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
const themeIsLight = resolvedTheme === 'light';
|
const themeIsLight = resolvedTheme === 'light';
|
||||||
const [status, setStatus] = useState<XtermStatus>('connecting');
|
const [status, setStatus] = useState<XtermStatus>('idle');
|
||||||
const [errorText, setErrorText] = useState<string>();
|
const [errorText, setErrorText] = useState<string>();
|
||||||
const [reconnectKey, setReconnectKey] = useState(0);
|
const [reconnectKey, setReconnectKey] = useState(0);
|
||||||
|
|
||||||
@@ -158,6 +162,7 @@ export const XtermSession = ({
|
|||||||
term.loadAddon(new WebLinksAddon());
|
term.loadAddon(new WebLinksAddon());
|
||||||
term.open(container);
|
term.open(container);
|
||||||
termRef.current = term;
|
termRef.current = term;
|
||||||
|
fitRef.current = fit;
|
||||||
|
|
||||||
// Task 11 will wire clipboard copy-on-select behavior here. Accept the
|
// 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.
|
// prop now so callers (and the effect deps) are stable across that change.
|
||||||
@@ -193,6 +198,7 @@ export const XtermSession = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
ws = new WebSocket(url);
|
ws = new WebSocket(url);
|
||||||
|
wsRef.current = ws;
|
||||||
ws.binaryType = 'arraybuffer';
|
ws.binaryType = 'arraybuffer';
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
if (isAborted()) return;
|
if (isAborted()) return;
|
||||||
@@ -233,17 +239,44 @@ export const XtermSession = ({
|
|||||||
ws?.close();
|
ws?.close();
|
||||||
termRef.current?.dispose();
|
termRef.current?.dispose();
|
||||||
termRef.current = null;
|
termRef.current = null;
|
||||||
|
fitRef.current = null;
|
||||||
|
wsRef.current = null;
|
||||||
};
|
};
|
||||||
// resolvedTheme intentionally excluded: handled by the theme effect above.
|
// resolvedTheme intentionally excluded: handled by the theme effect above.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [active, sessionKey, reconnectKey]);
|
}, [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 (
|
return (
|
||||||
<div className='relative flex h-full min-h-0 flex-col'>
|
<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'>
|
<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'>
|
<p className='text-muted-foreground text-xs'>
|
||||||
{status === 'connected'
|
{status === 'connected'
|
||||||
? `Connected · ${label}`
|
? `Connected · ${label}`
|
||||||
|
: status === 'idle'
|
||||||
|
? (waitingLabel ? 'Waiting…' : 'Idle')
|
||||||
: status === 'connecting'
|
: status === 'connecting'
|
||||||
? 'Connecting…'
|
? 'Connecting…'
|
||||||
: status === 'closed'
|
: status === 'closed'
|
||||||
|
|||||||
@@ -3,10 +3,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
|
|
||||||
import { XtermSession } from '../../src/components/agent-workspace/xterm-session';
|
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(),
|
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(),
|
fetchSpy: vi.fn(),
|
||||||
|
fitSpy: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
class FakeTerminal {
|
class FakeTerminal {
|
||||||
@@ -30,7 +35,7 @@ class FakeTerminal {
|
|||||||
vi.mock('@xterm/xterm', () => ({ Terminal: FakeTerminal }));
|
vi.mock('@xterm/xterm', () => ({ Terminal: FakeTerminal }));
|
||||||
vi.mock('@xterm/addon-fit', () => ({
|
vi.mock('@xterm/addon-fit', () => ({
|
||||||
FitAddon: class {
|
FitAddon: class {
|
||||||
fit = vi.fn();
|
fit = fitSpy;
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
vi.mock('@xterm/addon-web-links', () => ({
|
vi.mock('@xterm/addon-web-links', () => ({
|
||||||
@@ -60,6 +65,7 @@ class FakeWebSocket {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
wsInstances.length = 0;
|
wsInstances.length = 0;
|
||||||
openSpy.mockClear();
|
openSpy.mockClear();
|
||||||
|
fitSpy.mockClear();
|
||||||
fetchSpy.mockReset();
|
fetchSpy.mockReset();
|
||||||
fetchSpy.mockResolvedValue({
|
fetchSpy.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -127,4 +133,64 @@ describe('XtermSession', () => {
|
|||||||
expect(wsInstances[0]?.url).toBe('ws://x');
|
expect(wsInstances[0]?.url).toBe('ws://x');
|
||||||
expect(openSpy).toHaveBeenCalled();
|
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