feat(web): terminal copy-on-select toggle and web-links polish

Wire copy-on-select in XtermSession (shared by the workspace terminal
and the /machine terminal): a header checkbox toggles the behavior and
persists to localStorage under spoon.terminal.copyOnSelect. On mount the
stored value takes precedence over the copyOnSelect prop default. When
enabled, onSelectionChange copies the current selection via
navigator.clipboard.writeText — guarded (empty selections skipped;
clipboard-unavailable/denied errors swallowed). The handler reads the
toggle through a ref so live flips apply without reconnecting.

WebLinksAddon remains loaded so printed URLs are clickable.

Manual verification: in /machine and a workspace terminal, enabling
copy-on-select and selecting text places it on the clipboard; a printed
URL is clickable and opens in a new tab.
This commit is contained in:
Gabriel Brown
2026-07-11 13:17:46 -04:00
parent 148e060a9b
commit 12b1c98cfc
2 changed files with 248 additions and 20 deletions
@@ -1,9 +1,17 @@
import { render, screen, waitFor } from '@testing-library/react';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { XtermSession } from '../../src/components/agent-workspace/xterm-session';
const { openSpy, wsInstances, fetchSpy, fitSpy } = vi.hoisted(() => ({
const {
openSpy,
wsInstances,
fetchSpy,
fitSpy,
selectionHandlers,
getSelectionSpy,
writeTextSpy,
} = vi.hoisted(() => ({
openSpy: vi.fn(),
wsInstances: [] as Array<{
url: string;
@@ -12,6 +20,11 @@ const { openSpy, wsInstances, fetchSpy, fitSpy } = vi.hoisted(() => ({
}>,
fetchSpy: vi.fn(),
fitSpy: vi.fn(),
selectionHandlers: [] as (() => void)[],
getSelectionSpy: vi.fn<() => string>(() => ''),
writeTextSpy: vi.fn<(text: string) => Promise<void>>(() =>
Promise.resolve(),
),
}));
class FakeTerminal {
@@ -22,7 +35,10 @@ class FakeTerminal {
loadAddon = vi.fn();
onData = vi.fn();
onResize = vi.fn();
onSelectionChange = vi.fn();
onSelectionChange = vi.fn((cb: () => void) => {
selectionHandlers.push(cb);
});
getSelection = getSelectionSpy;
dispose = vi.fn();
refresh = vi.fn();
write = vi.fn();
@@ -64,8 +80,32 @@ class FakeWebSocket {
beforeEach(() => {
wsInstances.length = 0;
selectionHandlers.length = 0;
openSpy.mockClear();
fitSpy.mockClear();
getSelectionSpy.mockReset();
getSelectionSpy.mockReturnValue('');
writeTextSpy.mockReset();
writeTextSpy.mockResolvedValue(undefined);
const store = new Map<string, string>();
const localStorageMock = {
getItem: (key: string) => (store.has(key) ? store.get(key)! : null),
setItem: (key: string, value: string) => store.set(key, String(value)),
removeItem: (key: string) => store.delete(key),
clear: () => store.clear(),
key: (index: number) => Array.from(store.keys())[index] ?? null,
get length() {
return store.size;
},
};
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: localStorageMock,
});
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: writeTextSpy },
});
fetchSpy.mockReset();
fetchSpy.mockResolvedValue({
ok: true,
@@ -93,6 +133,7 @@ beforeEach(() => {
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
@@ -193,4 +234,133 @@ describe('XtermSession', () => {
expect(ws.close).not.toHaveBeenCalled();
expect(wsInstances).toHaveLength(1);
});
it('copies the selection to the clipboard when copy-on-select is enabled', async () => {
render(
<XtermSession
active
copyOnSelect
tokenUrl='/api/box/terminal-token'
sessionKey='box-1'
/>,
);
await waitFor(() => expect(selectionHandlers.length).toBeGreaterThan(0));
getSelectionSpy.mockReturnValue('hello world');
selectionHandlers.forEach((cb) => cb());
await waitFor(() =>
expect(writeTextSpy).toHaveBeenCalledWith('hello world'),
);
});
it('does not copy when copy-on-select is disabled', async () => {
render(
<XtermSession active tokenUrl='/api/box/terminal-token' sessionKey='box-1' />,
);
await waitFor(() => expect(selectionHandlers.length).toBeGreaterThan(0));
getSelectionSpy.mockReturnValue('should not copy');
selectionHandlers.forEach((cb) => cb());
await Promise.resolve();
expect(writeTextSpy).not.toHaveBeenCalled();
});
it('skips empty selections even when enabled', async () => {
render(
<XtermSession
active
copyOnSelect
tokenUrl='/api/box/terminal-token'
sessionKey='box-1'
/>,
);
await waitFor(() => expect(selectionHandlers.length).toBeGreaterThan(0));
getSelectionSpy.mockReturnValue('');
selectionHandlers.forEach((cb) => cb());
await Promise.resolve();
expect(writeTextSpy).not.toHaveBeenCalled();
});
it('swallows clipboard errors without crashing', async () => {
writeTextSpy.mockRejectedValue(new Error('denied'));
render(
<XtermSession
active
copyOnSelect
tokenUrl='/api/box/terminal-token'
sessionKey='box-1'
/>,
);
await waitFor(() => expect(selectionHandlers.length).toBeGreaterThan(0));
getSelectionSpy.mockReturnValue('boom');
selectionHandlers.forEach((cb) => cb());
await waitFor(() => expect(writeTextSpy).toHaveBeenCalledWith('boom'));
// Rejection is caught; the component stays mounted.
expect(screen.getByRole('checkbox')).toBeInTheDocument();
});
it('renders the header toggle and persists flips to localStorage', async () => {
render(
<XtermSession active tokenUrl='/api/box/terminal-token' sessionKey='box-1' />,
);
await waitFor(() => expect(selectionHandlers.length).toBeGreaterThan(0));
const checkbox = screen.getByRole('checkbox');
expect(checkbox).not.toBeChecked();
// Off: a selection is not copied.
getSelectionSpy.mockReturnValue('nope');
selectionHandlers.forEach((cb) => cb());
await Promise.resolve();
expect(writeTextSpy).not.toHaveBeenCalled();
// Flip on: persists and now copies.
fireEvent.click(checkbox);
expect(checkbox).toBeChecked();
expect(
window.localStorage.getItem('spoon.terminal.copyOnSelect'),
).toBe('true');
getSelectionSpy.mockReturnValue('now copied');
selectionHandlers.forEach((cb) => cb());
await waitFor(() =>
expect(writeTextSpy).toHaveBeenCalledWith('now copied'),
);
// Flip off again: persists false.
fireEvent.click(checkbox);
expect(checkbox).not.toBeChecked();
expect(
window.localStorage.getItem('spoon.terminal.copyOnSelect'),
).toBe('false');
});
it('lets the persisted localStorage value override the prop default', () => {
window.localStorage.setItem('spoon.terminal.copyOnSelect', 'true');
render(
<XtermSession active tokenUrl='/api/box/terminal-token' sessionKey='box-1' />,
);
const checkbox = screen.getByRole('checkbox');
expect(checkbox).toBeChecked();
window.localStorage.setItem('spoon.terminal.copyOnSelect', 'false');
render(
<XtermSession
active
copyOnSelect
tokenUrl='/api/box/terminal-token'
sessionKey='box-2'
/>,
);
// The second instance's checkbox reads the persisted false over prop=true.
const checkboxes = screen.getAllByRole('checkbox');
expect(checkboxes[checkboxes.length - 1]).not.toBeChecked();
});
});