Skip to content

Commit 9bdca05

Browse files
grichaclaude
andauthored
Fix terminal session isolation between workspaces (#22)
* Fix terminal session isolation between workspaces 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 <noreply@anthropic.com> * Remove multi-workspace e2e test (requires specific workspaces) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent a28ed3f commit 9bdca05

File tree

4 files changed

+198
-143
lines changed

4 files changed

+198
-143
lines changed

web/e2e/terminal-isolation.spec.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
test.describe('Terminal Session Isolation', () => {
4+
test('rapid tab switching within same workspace', async ({ page }) => {
5+
await page.goto('/workspaces/test?tab=terminal');
6+
await page.waitForTimeout(3000);
7+
8+
const terminalContainer = page.locator('[data-testid="terminal-container"]');
9+
await terminalContainer.click();
10+
await page.waitForTimeout(300);
11+
12+
await page.keyboard.type('echo "RAPID_SWITCH_TEST"', { delay: 30 });
13+
await page.keyboard.press('Enter');
14+
await page.waitForTimeout(500);
15+
16+
await page.screenshot({ path: 'test-results/rapid-1-terminal-with-text.png', fullPage: true });
17+
18+
const sessionsTab = page.locator('button:has-text("Sessions")');
19+
await sessionsTab.click();
20+
await page.waitForTimeout(300);
21+
22+
await page.screenshot({ path: 'test-results/rapid-2-sessions-tab.png', fullPage: true });
23+
24+
const terminalTab = page.locator('button:has-text("Terminal")');
25+
await terminalTab.click();
26+
await page.waitForTimeout(1000);
27+
28+
await page.screenshot({ path: 'test-results/rapid-3-back-to-terminal.png', fullPage: true });
29+
30+
for (let i = 0; i < 3; i++) {
31+
await sessionsTab.click();
32+
await page.waitForTimeout(200);
33+
await terminalTab.click();
34+
await page.waitForTimeout(200);
35+
}
36+
37+
await page.waitForTimeout(1000);
38+
await page.screenshot({ path: 'test-results/rapid-4-after-rapid-switch.png', fullPage: true });
39+
});
40+
});

web/src/components/Terminal.tsx

Lines changed: 146 additions & 141 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,16 @@
1-
import { useEffect, useRef, useCallback, useState } from 'react'
2-
import { init, Terminal as GhosttyTerminal, FitAddon } from 'ghostty-web'
1+
import { useEffect, useRef, useState } from 'react'
2+
import { Ghostty, Terminal as GhosttyTerminal, FitAddon } from 'ghostty-web'
33
import { getTerminalUrl } from '@/lib/api'
44

55
interface TerminalProps {
66
workspaceName: string
77
initialCommand?: string
88
}
99

10-
let ghosttyInitialized = false
11-
let ghosttyInitPromise: Promise<void> | null = null
12-
13-
async function ensureGhosttyInit(): Promise<void> {
14-
if (ghosttyInitialized) return
15-
if (ghosttyInitPromise) return ghosttyInitPromise
16-
17-
ghosttyInitPromise = init().then(() => {
18-
ghosttyInitialized = true
19-
})
20-
return ghosttyInitPromise
21-
}
22-
23-
export function Terminal({ workspaceName, initialCommand }: TerminalProps) {
10+
function TerminalInstance({ workspaceName, initialCommand }: TerminalProps) {
2411
const terminalRef = useRef<HTMLDivElement>(null)
2512
const termRef = useRef<GhosttyTerminal | null>(null)
13+
const ghosttyRef = useRef<Ghostty | null>(null)
2614
const fitAddonRef = useRef<FitAddon | null>(null)
2715
const wsRef = useRef<WebSocket | null>(null)
2816
const initialCommandSent = useRef(false)
@@ -31,136 +19,141 @@ export function Terminal({ workspaceName, initialCommand }: TerminalProps) {
3119
const [isInitialized, setIsInitialized] = useState(false)
3220
const [hasReceivedData, setHasReceivedData] = useState(false)
3321

34-
const connect = useCallback(async () => {
35-
if (!terminalRef.current) return
22+
useEffect(() => {
23+
let cancelled = false
24+
25+
const connect = async () => {
26+
if (!terminalRef.current || cancelled) return
27+
28+
const ghostty = await Ghostty.load()
29+
if (cancelled) return
30+
31+
ghosttyRef.current = ghostty
32+
setIsInitialized(true)
33+
34+
const term = new GhosttyTerminal({
35+
ghostty,
36+
cursorBlink: false,
37+
cursorStyle: 'block',
38+
fontSize: 14,
39+
fontFamily: 'Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
40+
scrollback: 10000,
41+
theme: {
42+
background: '#0d1117',
43+
foreground: '#c9d1d9',
44+
cursor: '#58a6ff',
45+
cursorAccent: '#0d1117',
46+
selectionBackground: '#264f78',
47+
selectionForeground: '#ffffff',
48+
black: '#484f58',
49+
red: '#ff7b72',
50+
green: '#3fb950',
51+
yellow: '#d29922',
52+
blue: '#58a6ff',
53+
magenta: '#bc8cff',
54+
cyan: '#39c5cf',
55+
white: '#b1bac4',
56+
brightBlack: '#6e7681',
57+
brightRed: '#ffa198',
58+
brightGreen: '#56d364',
59+
brightYellow: '#e3b341',
60+
brightBlue: '#79c0ff',
61+
brightMagenta: '#d2a8ff',
62+
brightCyan: '#56d4dd',
63+
brightWhite: '#f0f6fc',
64+
},
65+
})
3666

37-
// Dispose any existing terminal and clear DOM
38-
if (termRef.current) {
39-
termRef.current.dispose()
40-
termRef.current = null
41-
}
42-
if (wsRef.current) {
43-
wsRef.current.close()
44-
wsRef.current = null
45-
}
46-
terminalRef.current.innerHTML = ''
47-
48-
await ensureGhosttyInit()
49-
setIsInitialized(true)
50-
51-
const term = new GhosttyTerminal({
52-
cursorBlink: false,
53-
cursorStyle: 'block',
54-
fontSize: 14,
55-
fontFamily: 'Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
56-
scrollback: 10000,
57-
theme: {
58-
background: '#0d1117',
59-
foreground: '#c9d1d9',
60-
cursor: '#58a6ff',
61-
cursorAccent: '#0d1117',
62-
selectionBackground: '#264f78',
63-
selectionForeground: '#ffffff',
64-
black: '#484f58',
65-
red: '#ff7b72',
66-
green: '#3fb950',
67-
yellow: '#d29922',
68-
blue: '#58a6ff',
69-
magenta: '#bc8cff',
70-
cyan: '#39c5cf',
71-
white: '#b1bac4',
72-
brightBlack: '#6e7681',
73-
brightRed: '#ffa198',
74-
brightGreen: '#56d364',
75-
brightYellow: '#e3b341',
76-
brightBlue: '#79c0ff',
77-
brightMagenta: '#d2a8ff',
78-
brightCyan: '#56d4dd',
79-
brightWhite: '#f0f6fc',
80-
},
81-
})
82-
termRef.current = term
83-
84-
const fitAddon = new FitAddon()
85-
fitAddonRef.current = fitAddon
86-
term.loadAddon(fitAddon)
87-
88-
term.open(terminalRef.current)
89-
90-
if (term.textarea) {
91-
term.textarea.style.opacity = '0'
92-
term.textarea.style.position = 'absolute'
93-
term.textarea.style.left = '-9999px'
94-
term.textarea.style.top = '-9999px'
95-
}
67+
if (cancelled) {
68+
term.dispose()
69+
return
70+
}
9671

97-
requestAnimationFrame(() => {
98-
fitAddon.fit()
99-
})
72+
termRef.current = term
10073

101-
const wsUrl = getTerminalUrl(workspaceName)
102-
const ws = new WebSocket(wsUrl)
103-
wsRef.current = ws
74+
const fitAddon = new FitAddon()
75+
fitAddonRef.current = fitAddon
76+
term.loadAddon(fitAddon)
10477

105-
ws.onopen = () => {
106-
setIsConnected(true)
107-
const { cols, rows } = term
108-
ws.send(JSON.stringify({ type: 'resize', cols, rows }))
78+
term.open(terminalRef.current)
10979

110-
if (initialCommand && !initialCommandSent.current) {
111-
initialCommandSent.current = true
112-
setTimeout(() => {
113-
ws.send(initialCommand + '\n')
114-
}, 500)
80+
if (term.textarea) {
81+
term.textarea.style.opacity = '0'
82+
term.textarea.style.position = 'absolute'
83+
term.textarea.style.left = '-9999px'
84+
term.textarea.style.top = '-9999px'
11585
}
116-
}
11786

118-
ws.onmessage = (event) => {
119-
setHasReceivedData(true)
120-
if (event.data instanceof Blob) {
121-
event.data.text().then((text) => {
122-
term.write(text)
123-
})
124-
} else if (event.data instanceof ArrayBuffer) {
125-
term.write(new Uint8Array(event.data))
126-
} else {
127-
term.write(event.data)
128-
}
129-
}
87+
requestAnimationFrame(() => {
88+
if (!cancelled) fitAddon.fit()
89+
})
90+
91+
const wsUrl = getTerminalUrl(workspaceName)
92+
const ws = new WebSocket(wsUrl)
93+
wsRef.current = ws
94+
95+
ws.onopen = () => {
96+
if (cancelled) return
97+
setIsConnected(true)
98+
const { cols, rows } = term
99+
ws.send(JSON.stringify({ type: 'resize', cols, rows }))
130100

131-
ws.onclose = (event) => {
132-
setIsConnected(false)
133-
term.writeln('')
134-
if (event.code === 1000) {
135-
term.writeln('\x1b[38;5;245mSession ended\x1b[0m')
136-
} else if (event.code === 404 || event.reason?.includes('not found')) {
137-
term.writeln('\x1b[31mWorkspace not found or not running\x1b[0m')
138-
} else {
139-
term.writeln(`\x1b[31mDisconnected (code: ${event.code})\x1b[0m`)
101+
if (initialCommand && !initialCommandSent.current) {
102+
initialCommandSent.current = true
103+
setTimeout(() => {
104+
if (!cancelled) ws.send(initialCommand + '\n')
105+
}, 500)
106+
}
140107
}
141-
}
142108

143-
ws.onerror = () => {
144-
setIsConnected(false)
145-
term.writeln('\x1b[31mConnection error - is the workspace running?\x1b[0m')
146-
}
109+
ws.onmessage = (event) => {
110+
if (cancelled) return
111+
setHasReceivedData(true)
112+
if (event.data instanceof Blob) {
113+
event.data.text().then((text) => {
114+
if (!cancelled) term.write(text)
115+
})
116+
} else if (event.data instanceof ArrayBuffer) {
117+
term.write(new Uint8Array(event.data))
118+
} else {
119+
term.write(event.data)
120+
}
121+
}
147122

148-
term.onData((data) => {
149-
if (ws.readyState === WebSocket.OPEN) {
150-
ws.send(data)
123+
ws.onclose = (event) => {
124+
if (cancelled) return
125+
setIsConnected(false)
126+
term.writeln('')
127+
if (event.code === 1000) {
128+
term.writeln('\x1b[38;5;245mSession ended\x1b[0m')
129+
} else if (event.code === 404 || event.reason?.includes('not found')) {
130+
term.writeln('\x1b[31mWorkspace not found or not running\x1b[0m')
131+
} else {
132+
term.writeln(`\x1b[31mDisconnected (code: ${event.code})\x1b[0m`)
133+
}
151134
}
152-
})
153135

154-
term.onResize(({ cols, rows }) => {
155-
if (ws.readyState === WebSocket.OPEN) {
156-
ws.send(JSON.stringify({ type: 'resize', cols, rows }))
136+
ws.onerror = () => {
137+
if (cancelled) return
138+
setIsConnected(false)
139+
term.writeln('\x1b[31mConnection error - is the workspace running?\x1b[0m')
157140
}
158-
})
159141

160-
term.focus()
161-
}, [workspaceName, initialCommand])
142+
term.onData((data) => {
143+
if (ws.readyState === WebSocket.OPEN) {
144+
ws.send(data)
145+
}
146+
})
147+
148+
term.onResize(({ cols, rows }) => {
149+
if (ws.readyState === WebSocket.OPEN) {
150+
ws.send(JSON.stringify({ type: 'resize', cols, rows }))
151+
}
152+
})
153+
154+
term.focus()
155+
}
162156

163-
useEffect(() => {
164157
connect()
165158

166159
const handleFit = () => {
@@ -184,21 +177,21 @@ export function Terminal({ workspaceName, initialCommand }: TerminalProps) {
184177
window.addEventListener('resize', debouncedFit)
185178

186179
return () => {
180+
cancelled = true
187181
window.removeEventListener('resize', debouncedFit)
188-
if (resizeObserverRef.current) {
189-
resizeObserverRef.current.disconnect()
190-
}
191-
if (wsRef.current) {
192-
wsRef.current.close()
193-
}
194-
if (termRef.current) {
195-
termRef.current.dispose()
196-
}
182+
resizeObserverRef.current?.disconnect()
183+
resizeObserverRef.current = null
184+
wsRef.current?.close()
185+
wsRef.current = null
186+
termRef.current?.dispose()
187+
termRef.current = null
188+
fitAddonRef.current = null
189+
ghosttyRef.current = null
197190
}
198-
}, [connect])
191+
}, [workspaceName, initialCommand])
199192

200193
return (
201-
<div className="relative h-full w-full bg-[#0d1117] rounded-lg overflow-hidden cursor-default" data-testid="terminal-container">
194+
<>
202195
<div
203196
ref={terminalRef}
204197
className="absolute inset-0"
@@ -221,6 +214,18 @@ export function Terminal({ workspaceName, initialCommand }: TerminalProps) {
221214
</span>
222215
</div>
223216
)}
217+
</>
218+
)
219+
}
220+
221+
export function Terminal({ workspaceName, initialCommand }: TerminalProps) {
222+
return (
223+
<div className="relative h-full w-full bg-[#0d1117] rounded-lg overflow-hidden cursor-default" data-testid="terminal-container">
224+
<TerminalInstance
225+
key={workspaceName}
226+
workspaceName={workspaceName}
227+
initialCommand={initialCommand}
228+
/>
224229
</div>
225230
)
226231
}

web/src/pages/WorkspaceDetail.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -541,7 +541,7 @@ export function WorkspaceDetail() {
541541
<span className="text-sm font-medium">Agent Terminal</span>
542542
</div>
543543
<div className="flex-1">
544-
<Terminal workspaceName={name!} initialCommand={chatMode.command} />
544+
<Terminal key={`agent-${name}`} workspaceName={name!} initialCommand={chatMode.command} />
545545
</div>
546546
</div>
547547
)
@@ -666,7 +666,7 @@ export function WorkspaceDetail() {
666666
{!isRunning ? (
667667
renderStartPrompt()
668668
) : (
669-
<Terminal key={isHostWorkspace ? HOST_WORKSPACE_NAME : workspace!.name} workspaceName={isHostWorkspace ? HOST_WORKSPACE_NAME : workspace!.name} />
669+
<Terminal key={`terminal-${isHostWorkspace ? HOST_WORKSPACE_NAME : workspace!.name}`} workspaceName={isHostWorkspace ? HOST_WORKSPACE_NAME : workspace!.name} />
670670
)}
671671
</div>
672672
)}

0 commit comments

Comments
 (0)