diff --git a/web/e2e/terminal-isolation.spec.ts b/web/e2e/terminal-isolation.spec.ts new file mode 100644 index 00000000..ac23cb19 --- /dev/null +++ b/web/e2e/terminal-isolation.spec.ts @@ -0,0 +1,40 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Terminal Session Isolation', () => { + test('rapid tab switching within same workspace', async ({ page }) => { + await page.goto('/workspaces/test?tab=terminal'); + await page.waitForTimeout(3000); + + const terminalContainer = page.locator('[data-testid="terminal-container"]'); + await terminalContainer.click(); + await page.waitForTimeout(300); + + await page.keyboard.type('echo "RAPID_SWITCH_TEST"', { delay: 30 }); + await page.keyboard.press('Enter'); + await page.waitForTimeout(500); + + await page.screenshot({ path: 'test-results/rapid-1-terminal-with-text.png', fullPage: true }); + + const sessionsTab = page.locator('button:has-text("Sessions")'); + await sessionsTab.click(); + await page.waitForTimeout(300); + + await page.screenshot({ path: 'test-results/rapid-2-sessions-tab.png', fullPage: true }); + + const terminalTab = page.locator('button:has-text("Terminal")'); + await terminalTab.click(); + await page.waitForTimeout(1000); + + await page.screenshot({ path: 'test-results/rapid-3-back-to-terminal.png', fullPage: true }); + + for (let i = 0; i < 3; i++) { + await sessionsTab.click(); + await page.waitForTimeout(200); + await terminalTab.click(); + await page.waitForTimeout(200); + } + + await page.waitForTimeout(1000); + await page.screenshot({ path: 'test-results/rapid-4-after-rapid-switch.png', fullPage: true }); + }); +}); diff --git a/web/src/components/Terminal.tsx b/web/src/components/Terminal.tsx index d3a8830b..a2985873 100644 --- a/web/src/components/Terminal.tsx +++ b/web/src/components/Terminal.tsx @@ -1,5 +1,5 @@ -import { useEffect, useRef, useCallback, useState } from 'react' -import { init, Terminal as GhosttyTerminal, FitAddon } from 'ghostty-web' +import { useEffect, useRef, useState } from 'react' +import { Ghostty, Terminal as GhosttyTerminal, FitAddon } from 'ghostty-web' import { getTerminalUrl } from '@/lib/api' interface TerminalProps { @@ -7,22 +7,10 @@ interface TerminalProps { initialCommand?: string } -let ghosttyInitialized = false -let ghosttyInitPromise: Promise | null = null - -async function ensureGhosttyInit(): Promise { - if (ghosttyInitialized) return - if (ghosttyInitPromise) return ghosttyInitPromise - - ghosttyInitPromise = init().then(() => { - ghosttyInitialized = true - }) - return ghosttyInitPromise -} - -export function Terminal({ workspaceName, initialCommand }: TerminalProps) { +function TerminalInstance({ workspaceName, initialCommand }: TerminalProps) { const terminalRef = useRef(null) const termRef = useRef(null) + const ghosttyRef = useRef(null) const fitAddonRef = useRef(null) const wsRef = useRef(null) const initialCommandSent = useRef(false) @@ -31,136 +19,141 @@ export function Terminal({ workspaceName, initialCommand }: TerminalProps) { const [isInitialized, setIsInitialized] = useState(false) const [hasReceivedData, setHasReceivedData] = useState(false) - const connect = useCallback(async () => { - if (!terminalRef.current) return + useEffect(() => { + let cancelled = false + + const connect = async () => { + if (!terminalRef.current || cancelled) return + + const ghostty = await Ghostty.load() + if (cancelled) return + + ghosttyRef.current = ghostty + setIsInitialized(true) + + const term = new GhosttyTerminal({ + ghostty, + cursorBlink: false, + cursorStyle: 'block', + fontSize: 14, + fontFamily: 'Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', + scrollback: 10000, + theme: { + background: '#0d1117', + foreground: '#c9d1d9', + cursor: '#58a6ff', + cursorAccent: '#0d1117', + selectionBackground: '#264f78', + selectionForeground: '#ffffff', + black: '#484f58', + red: '#ff7b72', + green: '#3fb950', + yellow: '#d29922', + blue: '#58a6ff', + magenta: '#bc8cff', + cyan: '#39c5cf', + white: '#b1bac4', + brightBlack: '#6e7681', + brightRed: '#ffa198', + brightGreen: '#56d364', + brightYellow: '#e3b341', + brightBlue: '#79c0ff', + brightMagenta: '#d2a8ff', + brightCyan: '#56d4dd', + brightWhite: '#f0f6fc', + }, + }) - // Dispose any existing terminal and clear DOM - if (termRef.current) { - termRef.current.dispose() - termRef.current = null - } - if (wsRef.current) { - wsRef.current.close() - wsRef.current = null - } - terminalRef.current.innerHTML = '' - - await ensureGhosttyInit() - setIsInitialized(true) - - const term = new GhosttyTerminal({ - cursorBlink: false, - cursorStyle: 'block', - fontSize: 14, - fontFamily: 'Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', - scrollback: 10000, - theme: { - background: '#0d1117', - foreground: '#c9d1d9', - cursor: '#58a6ff', - cursorAccent: '#0d1117', - selectionBackground: '#264f78', - selectionForeground: '#ffffff', - black: '#484f58', - red: '#ff7b72', - green: '#3fb950', - yellow: '#d29922', - blue: '#58a6ff', - magenta: '#bc8cff', - cyan: '#39c5cf', - white: '#b1bac4', - brightBlack: '#6e7681', - brightRed: '#ffa198', - brightGreen: '#56d364', - brightYellow: '#e3b341', - brightBlue: '#79c0ff', - brightMagenta: '#d2a8ff', - brightCyan: '#56d4dd', - brightWhite: '#f0f6fc', - }, - }) - termRef.current = term - - const fitAddon = new FitAddon() - fitAddonRef.current = fitAddon - term.loadAddon(fitAddon) - - term.open(terminalRef.current) - - if (term.textarea) { - term.textarea.style.opacity = '0' - term.textarea.style.position = 'absolute' - term.textarea.style.left = '-9999px' - term.textarea.style.top = '-9999px' - } + if (cancelled) { + term.dispose() + return + } - requestAnimationFrame(() => { - fitAddon.fit() - }) + termRef.current = term - const wsUrl = getTerminalUrl(workspaceName) - const ws = new WebSocket(wsUrl) - wsRef.current = ws + const fitAddon = new FitAddon() + fitAddonRef.current = fitAddon + term.loadAddon(fitAddon) - ws.onopen = () => { - setIsConnected(true) - const { cols, rows } = term - ws.send(JSON.stringify({ type: 'resize', cols, rows })) + term.open(terminalRef.current) - if (initialCommand && !initialCommandSent.current) { - initialCommandSent.current = true - setTimeout(() => { - ws.send(initialCommand + '\n') - }, 500) + if (term.textarea) { + term.textarea.style.opacity = '0' + term.textarea.style.position = 'absolute' + term.textarea.style.left = '-9999px' + term.textarea.style.top = '-9999px' } - } - ws.onmessage = (event) => { - setHasReceivedData(true) - if (event.data instanceof Blob) { - event.data.text().then((text) => { - term.write(text) - }) - } else if (event.data instanceof ArrayBuffer) { - term.write(new Uint8Array(event.data)) - } else { - term.write(event.data) - } - } + requestAnimationFrame(() => { + if (!cancelled) fitAddon.fit() + }) + + const wsUrl = getTerminalUrl(workspaceName) + const ws = new WebSocket(wsUrl) + wsRef.current = ws + + ws.onopen = () => { + if (cancelled) return + setIsConnected(true) + const { cols, rows } = term + ws.send(JSON.stringify({ type: 'resize', cols, rows })) - ws.onclose = (event) => { - setIsConnected(false) - term.writeln('') - if (event.code === 1000) { - term.writeln('\x1b[38;5;245mSession ended\x1b[0m') - } else if (event.code === 404 || event.reason?.includes('not found')) { - term.writeln('\x1b[31mWorkspace not found or not running\x1b[0m') - } else { - term.writeln(`\x1b[31mDisconnected (code: ${event.code})\x1b[0m`) + if (initialCommand && !initialCommandSent.current) { + initialCommandSent.current = true + setTimeout(() => { + if (!cancelled) ws.send(initialCommand + '\n') + }, 500) + } } - } - ws.onerror = () => { - setIsConnected(false) - term.writeln('\x1b[31mConnection error - is the workspace running?\x1b[0m') - } + ws.onmessage = (event) => { + if (cancelled) return + setHasReceivedData(true) + if (event.data instanceof Blob) { + event.data.text().then((text) => { + if (!cancelled) term.write(text) + }) + } else if (event.data instanceof ArrayBuffer) { + term.write(new Uint8Array(event.data)) + } else { + term.write(event.data) + } + } - term.onData((data) => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(data) + ws.onclose = (event) => { + if (cancelled) return + setIsConnected(false) + term.writeln('') + if (event.code === 1000) { + term.writeln('\x1b[38;5;245mSession ended\x1b[0m') + } else if (event.code === 404 || event.reason?.includes('not found')) { + term.writeln('\x1b[31mWorkspace not found or not running\x1b[0m') + } else { + term.writeln(`\x1b[31mDisconnected (code: ${event.code})\x1b[0m`) + } } - }) - term.onResize(({ cols, rows }) => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: 'resize', cols, rows })) + ws.onerror = () => { + if (cancelled) return + setIsConnected(false) + term.writeln('\x1b[31mConnection error - is the workspace running?\x1b[0m') } - }) - term.focus() - }, [workspaceName, initialCommand]) + term.onData((data) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(data) + } + }) + + term.onResize(({ cols, rows }) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'resize', cols, rows })) + } + }) + + term.focus() + } - useEffect(() => { connect() const handleFit = () => { @@ -184,21 +177,21 @@ export function Terminal({ workspaceName, initialCommand }: TerminalProps) { window.addEventListener('resize', debouncedFit) return () => { + cancelled = true window.removeEventListener('resize', debouncedFit) - if (resizeObserverRef.current) { - resizeObserverRef.current.disconnect() - } - if (wsRef.current) { - wsRef.current.close() - } - if (termRef.current) { - termRef.current.dispose() - } + resizeObserverRef.current?.disconnect() + resizeObserverRef.current = null + wsRef.current?.close() + wsRef.current = null + termRef.current?.dispose() + termRef.current = null + fitAddonRef.current = null + ghosttyRef.current = null } - }, [connect]) + }, [workspaceName, initialCommand]) return ( -
+ <>
)} + + ) +} + +export function Terminal({ workspaceName, initialCommand }: TerminalProps) { + return ( +
+
) } diff --git a/web/src/pages/WorkspaceDetail.tsx b/web/src/pages/WorkspaceDetail.tsx index 932f208f..9ea4b607 100644 --- a/web/src/pages/WorkspaceDetail.tsx +++ b/web/src/pages/WorkspaceDetail.tsx @@ -541,7 +541,7 @@ export function WorkspaceDetail() { Agent Terminal
- +
) @@ -666,7 +666,7 @@ export function WorkspaceDetail() { {!isRunning ? ( renderStartPrompt() ) : ( - + )} )} diff --git a/web/vite.config.ts b/web/vite.config.ts index b6086761..968bd20f 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -10,4 +10,14 @@ export default defineConfig({ '@': path.resolve(__dirname, './src'), }, }, + server: { + allowedHosts: true, + proxy: { + '/rpc': { + target: 'http://localhost:3111', + changeOrigin: true, + ws: true, + }, + }, + }, })