Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions web/e2e/terminal-isolation.spec.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
287 changes: 146 additions & 141 deletions web/src/components/Terminal.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,16 @@
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 {
workspaceName: string
initialCommand?: string
}

let ghosttyInitialized = false
let ghosttyInitPromise: Promise<void> | null = null

async function ensureGhosttyInit(): Promise<void> {
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<HTMLDivElement>(null)
const termRef = useRef<GhosttyTerminal | null>(null)
const ghosttyRef = useRef<Ghostty | null>(null)
const fitAddonRef = useRef<FitAddon | null>(null)
const wsRef = useRef<WebSocket | null>(null)
const initialCommandSent = useRef(false)
Expand All @@ -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 = () => {
Expand All @@ -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 (
<div className="relative h-full w-full bg-[#0d1117] rounded-lg overflow-hidden cursor-default" data-testid="terminal-container">
<>
<div
ref={terminalRef}
className="absolute inset-0"
Expand All @@ -221,6 +214,18 @@ export function Terminal({ workspaceName, initialCommand }: TerminalProps) {
</span>
</div>
)}
</>
)
}

export function Terminal({ workspaceName, initialCommand }: TerminalProps) {
return (
<div className="relative h-full w-full bg-[#0d1117] rounded-lg overflow-hidden cursor-default" data-testid="terminal-container">
<TerminalInstance
key={workspaceName}
workspaceName={workspaceName}
initialCommand={initialCommand}
/>
</div>
)
}
Expand Down
4 changes: 2 additions & 2 deletions web/src/pages/WorkspaceDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -541,7 +541,7 @@ export function WorkspaceDetail() {
<span className="text-sm font-medium">Agent Terminal</span>
</div>
<div className="flex-1">
<Terminal workspaceName={name!} initialCommand={chatMode.command} />
<Terminal key={`agent-${name}`} workspaceName={name!} initialCommand={chatMode.command} />
</div>
</div>
)
Expand Down Expand Up @@ -666,7 +666,7 @@ export function WorkspaceDetail() {
{!isRunning ? (
renderStartPrompt()
) : (
<Terminal key={isHostWorkspace ? HOST_WORKSPACE_NAME : workspace!.name} workspaceName={isHostWorkspace ? HOST_WORKSPACE_NAME : workspace!.name} />
<Terminal key={`terminal-${isHostWorkspace ? HOST_WORKSPACE_NAME : workspace!.name}`} workspaceName={isHostWorkspace ? HOST_WORKSPACE_NAME : workspace!.name} />
)}
</div>
)}
Expand Down
Loading