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
@@ -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);
});
});