From d29fdf5b1a58f495b13ad0f1c6c7a4dd1457bd44 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+Gricha@users.noreply.github.com> Date: Wed, 7 Jan 2026 04:08:46 +0000 Subject: [PATCH 1/2] Fix terminal session isolation between workspaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The terminal content was persisting when switching between workspace terminals because ghostty-web uses a module-level singleton WASM instance that all Terminal instances shared. Fix: Create a separate Ghostty WASM instance for each Terminal by calling Ghostty.load() directly instead of using the shared init(). Also includes: - E2E tests for terminal isolation - Vite proxy config for local development - allowedHosts config for remote access 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- web/e2e/terminal-isolation.spec.ts | 94 ++++++++++ web/src/components/Terminal.tsx | 287 +++++++++++++++-------------- web/src/pages/WorkspaceDetail.tsx | 4 +- web/vite.config.ts | 10 + 4 files changed, 252 insertions(+), 143 deletions(-) create mode 100644 web/e2e/terminal-isolation.spec.ts diff --git a/web/e2e/terminal-isolation.spec.ts b/web/e2e/terminal-isolation.spec.ts new file mode 100644 index 00000000..82c555bc --- /dev/null +++ b/web/e2e/terminal-isolation.spec.ts @@ -0,0 +1,94 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Terminal Session Isolation', () => { + test('terminal content should not persist when using sidebar navigation', async ({ page }) => { + const MARKER_TEXT = 'SIDEBAR_NAV_MARKER_99999'; + + await page.goto('/workspaces/test?tab=terminal'); + await page.waitForTimeout(3000); + + await page.screenshot({ path: 'test-results/sidebar-1-initial.png', fullPage: true }); + + const terminalContainer = page.locator('[data-testid="terminal-container"]'); + await expect(terminalContainer).toBeVisible(); + + await terminalContainer.click(); + await page.waitForTimeout(500); + + for (let i = 0; i < 5; i++) { + await page.keyboard.type(`echo "LINE_${i}_${MARKER_TEXT}"`, { delay: 30 }); + await page.keyboard.press('Enter'); + await page.waitForTimeout(300); + } + + await page.waitForTimeout(1000); + await page.screenshot({ path: 'test-results/sidebar-1-with-content.png', fullPage: true }); + + const devLink = page.locator('a[href="/workspaces/dev"]').first(); + await devLink.click(); + await page.waitForTimeout(500); + + const terminalTab = page.locator('button:has-text("Terminal")'); + await terminalTab.click(); + await page.waitForTimeout(2000); + + await page.screenshot({ path: 'test-results/sidebar-2-dev-terminal.png', fullPage: true }); + + const testLink = page.locator('a[href="/workspaces/test"]').first(); + await testLink.click(); + await page.waitForTimeout(500); + + const terminalTab2 = page.locator('button:has-text("Terminal")'); + await terminalTab2.click(); + await page.waitForTimeout(2000); + + await page.screenshot({ path: 'test-results/sidebar-3-back-to-test.png', fullPage: true }); + + const websiteLink = page.locator('a[href="/workspaces/website"]').first(); + await websiteLink.click(); + await page.waitForTimeout(500); + + const terminalTab3 = page.locator('button:has-text("Terminal")'); + await terminalTab3.click(); + await page.waitForTimeout(2000); + + await page.screenshot({ path: 'test-results/sidebar-4-website-terminal.png', fullPage: true }); + }); + + 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, + }, + }, + }, }) From 82b906d4b64893149434487ca435a08269694c21 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+Gricha@users.noreply.github.com> Date: Wed, 7 Jan 2026 04:16:59 +0000 Subject: [PATCH 2/2] Remove multi-workspace e2e test (requires specific workspaces) --- web/e2e/terminal-isolation.spec.ts | 54 ------------------------------ 1 file changed, 54 deletions(-) diff --git a/web/e2e/terminal-isolation.spec.ts b/web/e2e/terminal-isolation.spec.ts index 82c555bc..ac23cb19 100644 --- a/web/e2e/terminal-isolation.spec.ts +++ b/web/e2e/terminal-isolation.spec.ts @@ -1,60 +1,6 @@ import { test, expect } from '@playwright/test'; test.describe('Terminal Session Isolation', () => { - test('terminal content should not persist when using sidebar navigation', async ({ page }) => { - const MARKER_TEXT = 'SIDEBAR_NAV_MARKER_99999'; - - await page.goto('/workspaces/test?tab=terminal'); - await page.waitForTimeout(3000); - - await page.screenshot({ path: 'test-results/sidebar-1-initial.png', fullPage: true }); - - const terminalContainer = page.locator('[data-testid="terminal-container"]'); - await expect(terminalContainer).toBeVisible(); - - await terminalContainer.click(); - await page.waitForTimeout(500); - - for (let i = 0; i < 5; i++) { - await page.keyboard.type(`echo "LINE_${i}_${MARKER_TEXT}"`, { delay: 30 }); - await page.keyboard.press('Enter'); - await page.waitForTimeout(300); - } - - await page.waitForTimeout(1000); - await page.screenshot({ path: 'test-results/sidebar-1-with-content.png', fullPage: true }); - - const devLink = page.locator('a[href="/workspaces/dev"]').first(); - await devLink.click(); - await page.waitForTimeout(500); - - const terminalTab = page.locator('button:has-text("Terminal")'); - await terminalTab.click(); - await page.waitForTimeout(2000); - - await page.screenshot({ path: 'test-results/sidebar-2-dev-terminal.png', fullPage: true }); - - const testLink = page.locator('a[href="/workspaces/test"]').first(); - await testLink.click(); - await page.waitForTimeout(500); - - const terminalTab2 = page.locator('button:has-text("Terminal")'); - await terminalTab2.click(); - await page.waitForTimeout(2000); - - await page.screenshot({ path: 'test-results/sidebar-3-back-to-test.png', fullPage: true }); - - const websiteLink = page.locator('a[href="/workspaces/website"]').first(); - await websiteLink.click(); - await page.waitForTimeout(500); - - const terminalTab3 = page.locator('button:has-text("Terminal")'); - await terminalTab3.click(); - await page.waitForTimeout(2000); - - await page.screenshot({ path: 'test-results/sidebar-4-website-terminal.png', fullPage: true }); - }); - test('rapid tab switching within same workspace', async ({ page }) => { await page.goto('/workspaces/test?tab=terminal'); await page.waitForTimeout(3000);