- {launch.sandboxId ? (
+ {launch.target?.sandboxId ? (
diff --git a/src/features/dashboard/terminal/dashboard-terminal.tsx b/src/features/dashboard/terminal/dashboard-terminal.tsx
index 3269dc675..3f2b2207c 100644
--- a/src/features/dashboard/terminal/dashboard-terminal.tsx
+++ b/src/features/dashboard/terminal/dashboard-terminal.tsx
@@ -1,14 +1,15 @@
'use client'
-import { Terminal as XTerm } from '@xterm/xterm'
-import type Sandbox from 'e2b'
-import type { CommandHandle } from 'e2b'
+import { type CommandHandle, type Sandbox, TimeoutError } from 'e2b'
import { useCallback, useEffect, useRef, useState } from 'react'
+import { attachTerminalWithRetry } from './attach-terminal'
import {
- DEFAULT_COLS,
DEFAULT_CWD,
- DEFAULT_ROWS,
- MAX_TERMINAL_TRANSCRIPT_CHARS,
+ TERMINAL_ATTACH_ATTEMPT_TIMEOUT_MS,
+ TERMINAL_ATTACH_MAX_RETRIES,
+ TERMINAL_ATTACH_RETRY_BASE_DELAY_MS,
+ TERMINAL_ATTACH_RETRY_MAX_DELAY_MS,
+ TERMINAL_AUTOSTART_DEBOUNCE_MS,
} from './constants'
import DashboardTerminalCommandDialog from './dashboard-terminal-command-dialog'
import { openTerminalSandbox } from './sandbox-session'
@@ -17,41 +18,42 @@ import {
resolveTerminalTemplateOverride,
} from './template'
import TerminalPanel from './terminal-panel'
-import { calculateTerminalSize } from './terminal-size'
import type {
PendingTerminalLaunch,
StartTerminalOptions,
+ TerminalLaunchTarget,
+ TerminalSandboxResolver,
TerminalStatus,
} from './types'
+import { useTerminalInstance } from './use-terminal-instance'
-const INITIAL_TERMINAL_TEXT =
- 'Open a terminal to start a persistent E2B sandbox.\r\n'
-const TERMINAL_THEME = {
- background: '#000000',
- cursor: '#ffffff',
- foreground: '#ffffff',
- selectionBackground: '#ffffff40',
-}
+const FLUSH_INPUT_INTERVAL_MS = 10
interface DashboardTerminalProps {
autoStart?: boolean
- initialCommand?: string
- initialSandboxId?: string
- initialTemplate?: string
+ getSandbox?: TerminalSandboxResolver
+ launchTarget?: TerminalLaunchTarget
+ onSandboxAttached?: (sandboxId: string) => void
+ onSandboxAttachFailed?: (target: TerminalLaunchTarget | undefined) => void
+ sandboxScoped?: boolean
teamId: string
+ teamSlug: string
}
export default function DashboardTerminal({
autoStart = false,
- initialCommand = '',
- initialSandboxId,
- initialTemplate,
+ getSandbox,
+ launchTarget,
+ onSandboxAttached,
+ onSandboxAttachFailed,
+ sandboxScoped = false,
teamId,
+ teamSlug,
}: DashboardTerminalProps) {
const [status, setStatus] = useState
('idle')
const [activeSandboxId, setActiveSandboxId] = useState()
const [template, setTemplate] = useState(
- normalizeTerminalTemplate(initialTemplate) ?? 'base'
+ normalizeTerminalTemplate(launchTarget?.template) ?? 'base'
)
const [pendingLaunch, setPendingLaunch] =
useState(null)
@@ -59,73 +61,168 @@ export default function DashboardTerminal({
const sandboxRef = useRef(null)
const ptyRef = useRef(null)
const pidRef = useRef(undefined)
- const xtermRef = useRef(null)
- const terminalContainerRef = useRef(null)
- const terminalTranscriptRef = useRef(INITIAL_TERMINAL_TEXT)
- const terminalSizeRef = useRef({ cols: DEFAULT_COLS, rows: DEFAULT_ROWS })
- const decoderRef = useRef(new TextDecoder())
+ const pendingInputRef = useRef([])
+ const inputFlushTimerRef = useRef(null)
+ const inputFlushInFlightRef = useRef(false)
+ const inputGenerationRef = useRef(0)
const pendingCommandsRef = useRef([])
- const inputQueueRef = useRef(Promise.resolve())
- const didAutoStartRef = useRef(false)
const isStartingRef = useRef(false)
+ const retryResolveRef = useRef<(() => void) | null>(null)
+ const retryTimerRef = useRef(null)
const startGenerationRef = useRef(0)
- const resizeTerminal = useCallback(() => {
- const nextSize = calculateTerminalSize(
- terminalContainerRef.current,
- xtermRef.current
- )
- terminalSizeRef.current = nextSize
- xtermRef.current?.resize(nextSize.cols, nextSize.rows)
+ const clearAttachRetryTimer = useCallback(() => {
+ if (!retryTimerRef.current) return
- if (sandboxRef.current && pidRef.current) {
- void sandboxRef.current.pty.resize(pidRef.current, nextSize)
- }
-
- return nextSize
+ window.clearTimeout(retryTimerRef.current)
+ retryTimerRef.current = null
+ retryResolveRef.current?.()
+ retryResolveRef.current = null
}, [])
- const appendOutput = useCallback((chunk: string | Uint8Array) => {
- const text =
- typeof chunk === 'string'
- ? chunk
- : decoderRef.current.decode(chunk, { stream: true })
-
- terminalTranscriptRef.current = (
- terminalTranscriptRef.current + text
- ).slice(-MAX_TERMINAL_TRANSCRIPT_CHARS)
- xtermRef.current?.write(chunk, () => {
- xtermRef.current?.scrollToBottom()
- })
+ const waitForAttachRetry = useCallback(
+ (delayMs: number) =>
+ new Promise((resolve) => {
+ retryResolveRef.current = resolve
+ retryTimerRef.current = window.setTimeout(() => {
+ retryTimerRef.current = null
+ retryResolveRef.current = null
+ resolve()
+ }, delayMs)
+ }),
+ []
+ )
+
+ const requestPtyKill = useCallback(
+ ({ pid, sandboxId }: { pid: number; sandboxId: string }) => {
+ void fetch('/api/trpc/sandbox.killTerminalPty?batch=1', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ 0: {
+ json: {
+ pid,
+ sandboxId,
+ teamSlug,
+ },
+ },
+ }),
+ keepalive: true,
+ })
+ },
+ [teamSlug]
+ )
+
+ const clearPendingInput = useCallback(() => {
+ if (inputFlushTimerRef.current) {
+ window.clearTimeout(inputFlushTimerRef.current)
+ inputFlushTimerRef.current = null
+ }
+ inputGenerationRef.current += 1
+ inputFlushInFlightRef.current = false
+ pendingInputRef.current = []
}, [])
- const disconnectTerminal = useCallback(async () => {
- const pty = ptyRef.current
- ptyRef.current = null
- if (!pty) return
+ const flushInputToPty = useCallback((terminalPid = pidRef.current) => {
+ inputFlushTimerRef.current = null
- try {
- await pty.disconnect()
- } catch {
- // Best-effort cleanup. The sandbox is intentionally left alive to pause.
+ if (inputFlushInFlightRef.current) return
+
+ if (!sandboxRef.current || !terminalPid) {
+ pendingInputRef.current = []
+ return
+ }
+
+ const pendingInput = pendingInputRef.current
+ pendingInputRef.current = []
+ if (!pendingInput.length) return
+
+ const byteLength = pendingInput.reduce(
+ (total, chunk) => total + chunk.byteLength,
+ 0
+ )
+ const data = new Uint8Array(byteLength)
+ let offset = 0
+ for (const chunk of pendingInput) {
+ data.set(chunk, offset)
+ offset += chunk.byteLength
}
+
+ const sandbox = sandboxRef.current
+ const inputGeneration = inputGenerationRef.current
+ inputFlushInFlightRef.current = true
+
+ void sandbox.pty
+ .sendInput(terminalPid, data)
+ .catch(() => undefined)
+ .finally(() => {
+ if (inputGenerationRef.current !== inputGeneration) return
+
+ inputFlushInFlightRef.current = false
+
+ if (pidRef.current === terminalPid && pendingInputRef.current.length) {
+ flushInputToPty(terminalPid)
+ }
+ })
}, [])
const sendInputToPty = useCallback(
(value: string | Uint8Array, terminalPid = pidRef.current) => {
if (!value || !sandboxRef.current || !terminalPid) return
- const sandbox = sandboxRef.current
const data =
typeof value === 'string' ? new TextEncoder().encode(value) : value
- inputQueueRef.current = inputQueueRef.current
- .catch(() => undefined)
- .then(() => sandbox.pty.sendInput(terminalPid, data))
+ pendingInputRef.current.push(data)
+
+ if (inputFlushTimerRef.current) return
+
+ inputFlushTimerRef.current = window.setTimeout(() => {
+ flushInputToPty(terminalPid)
+ }, FLUSH_INPUT_INTERVAL_MS)
},
- []
+ [flushInputToPty]
)
+ const resizePty = useCallback((size: { cols: number; rows: number }) => {
+ if (sandboxRef.current && pidRef.current) {
+ void sandboxRef.current.pty.resize(pidRef.current, size)
+ }
+ }, [])
+
+ const {
+ appendOutput,
+ copyTerminalText,
+ focusTerminal,
+ resetTerminal,
+ resizeTerminal,
+ terminalContainerRef,
+ } = useTerminalInstance({
+ onInput: sendInputToPty,
+ onResize: resizePty,
+ })
+
+ const closeTerminal = useCallback(async () => {
+ clearAttachRetryTimer()
+ clearPendingInput()
+
+ const pty = ptyRef.current
+ const sandboxId = sandboxRef.current?.sandboxId
+ ptyRef.current = null
+ pidRef.current = undefined
+ if (!pty) return
+
+ if (sandboxId) {
+ requestPtyKill({ pid: pty.pid, sandboxId })
+ }
+
+ try {
+ await pty.kill()
+ } catch {
+ // Best-effort cleanup. The sandbox is intentionally left alive.
+ }
+ }, [clearAttachRetryTimer, clearPendingInput, requestPtyKill])
+
const runCommand = useCallback(
(command: string, terminalPid?: number) => {
const normalizedCommand = command.trim()
@@ -147,7 +244,7 @@ export default function DashboardTerminal({
const url = new URL(window.location.href)
let changed = false
- if (url.searchParams.get('sandboxId') !== sandboxId) {
+ if (!sandboxScoped && url.searchParams.get('sandboxId') !== sandboxId) {
url.searchParams.set('sandboxId', sandboxId)
changed = true
}
@@ -161,14 +258,15 @@ export default function DashboardTerminal({
window.history.replaceState(window.history.state, '', url)
}
},
- []
+ [sandboxScoped]
)
const startTerminal = useCallback(
async (options: StartTerminalOptions = {}) => {
if (isStartingRef.current) return
+ const target = options.target
const nextTemplate = resolveTerminalTemplateOverride(
- options.template,
+ target?.template,
template
)
@@ -178,71 +276,109 @@ export default function DashboardTerminal({
return
}
+ const requestedSandboxId = target?.sandboxId
isStartingRef.current = true
const startGeneration = startGenerationRef.current + 1
startGenerationRef.current = startGeneration
const isCurrentStart = () =>
startGenerationRef.current === startGeneration
- await disconnectTerminal()
+ await closeTerminal()
sandboxRef.current = null
pidRef.current = undefined
- decoderRef.current = new TextDecoder()
- inputQueueRef.current = Promise.resolve()
- terminalTranscriptRef.current = ''
- xtermRef.current?.reset()
+ resetTerminal()
setStatus('starting')
- setActiveSandboxId(options.sandboxId)
+ setActiveSandboxId(requestedSandboxId)
setTemplate(nextTemplate)
appendOutput('Opening terminal...\r\n')
- try {
- const { sandbox } = await openTerminalSandbox({
- forceNewSandbox: options.forceNewSandbox,
- onStatus: appendOutput,
- sandboxId: options.sandboxId,
- teamId,
- template: nextTemplate,
- })
+ const openSandboxAndPty = async () => {
+ let sandbox: Sandbox
+
+ if (getSandbox) {
+ appendOutput('Connecting to sandbox...\r\n')
+ sandbox = await getSandbox()
+ } else {
+ const terminalSandbox = await openTerminalSandbox({
+ forceNewSandbox: options.forceNewSandbox,
+ onStatus: appendOutput,
+ requestTimeoutMs: requestedSandboxId
+ ? TERMINAL_ATTACH_ATTEMPT_TIMEOUT_MS
+ : undefined,
+ shouldStoreSession: !sandboxScoped,
+ sandboxId: requestedSandboxId,
+ teamId,
+ template: nextTemplate,
+ })
+ sandbox = terminalSandbox.sandbox
+ }
- if (!isCurrentStart()) return
+ if (!isCurrentStart()) return null
- sandboxRef.current = sandbox
- setActiveSandboxId(sandbox.sandboxId)
- updateTerminalUrl({
- // Keep ?command= until the confirmed command has an attached sandbox.
- clearCommand: pendingCommandsRef.current.length > 0,
- sandboxId: sandbox.sandboxId,
- })
appendOutput(`Sandbox ${sandbox.sandboxId} is running.\r\n`)
-
appendOutput('Opening PTY...\r\n')
const terminalSize = resizeTerminal()
const pty = await sandbox.pty.create({
cols: terminalSize.cols,
rows: terminalSize.rows,
timeoutMs: 0,
+ requestTimeoutMs: TERMINAL_ATTACH_ATTEMPT_TIMEOUT_MS,
cwd: DEFAULT_CWD,
onData: (data) => {
appendOutput(data)
},
})
+ return { pty, sandbox }
+ }
+
+ const canRetryAttach = Boolean(requestedSandboxId || getSandbox)
+
+ try {
+ const result = await attachTerminalWithRetry({
+ canRetry: canRetryAttach,
+ isCurrent: isCurrentStart,
+ isRetryableError: (error) => error instanceof TimeoutError,
+ maxRetries: TERMINAL_ATTACH_MAX_RETRIES,
+ onRetry: (retryDelay) => {
+ appendOutput(
+ `Terminal attach timed out. Retrying in ${Math.round(
+ retryDelay / 1000
+ )}s...\r\n`
+ )
+ },
+ open: openSandboxAndPty,
+ retryBaseDelayMs: TERMINAL_ATTACH_RETRY_BASE_DELAY_MS,
+ retryMaxDelayMs: TERMINAL_ATTACH_RETRY_MAX_DELAY_MS,
+ waitForRetry: waitForAttachRetry,
+ })
+
+ if (!result) return
+ const { sandbox, pty } = result
+
if (!isCurrentStart()) {
try {
- await pty.disconnect()
+ await pty.kill()
} catch {
// The start was superseded or unmounted; best-effort PTY cleanup.
}
return
}
+ sandboxRef.current = sandbox
+ setActiveSandboxId(sandbox.sandboxId)
+ updateTerminalUrl({
+ // Keep ?command= until the confirmed command has an attached sandbox.
+ clearCommand: pendingCommandsRef.current.length > 0,
+ sandboxId: sandbox.sandboxId,
+ })
ptyRef.current = pty
pidRef.current = pty.pid
resizeTerminal()
setStatus('ready')
appendOutput(`PTY ${pty.pid} attached.\r\n`)
- xtermRef.current?.focus()
+ focusTerminal()
+ onSandboxAttached?.(sandbox.sandboxId)
const pendingCommands = pendingCommandsRef.current
pendingCommandsRef.current = []
@@ -253,6 +389,7 @@ export default function DashboardTerminal({
if (!isCurrentStart()) return
setStatus('error')
+ onSandboxAttachFailed?.(target)
appendOutput(
`\r\nFailed to start terminal: ${
error instanceof Error ? error.message : 'Unknown error'
@@ -260,26 +397,32 @@ export default function DashboardTerminal({
)
} finally {
if (isCurrentStart()) {
- // Only the latest start owns the shared starting flag.
isStartingRef.current = false
}
}
},
[
appendOutput,
- disconnectTerminal,
+ closeTerminal,
resizeTerminal,
+ resetTerminal,
+ focusTerminal,
+ getSandbox,
runCommand,
+ sandboxScoped,
teamId,
template,
+ onSandboxAttached,
+ onSandboxAttachFailed,
updateTerminalUrl,
+ waitForAttachRetry,
]
)
const queueTerminalCommand = useCallback(
(command: string, options: StartTerminalOptions = {}) => {
const nextTemplate = resolveTerminalTemplateOverride(
- options.template,
+ options.target?.template,
template
)
@@ -294,8 +437,10 @@ export default function DashboardTerminal({
// sending anything into the PTY.
setPendingLaunch({
command: command.trim(),
- sandboxId: options.sandboxId,
- template: nextTemplate,
+ target: {
+ ...options.target,
+ template: nextTemplate,
+ },
})
return
}
@@ -303,7 +448,10 @@ export default function DashboardTerminal({
if (status === 'idle' || status === 'error' || options.forceNewSandbox) {
void startTerminal({
...options,
- template: nextTemplate,
+ target: {
+ ...options.target,
+ template: nextTemplate,
+ },
})
}
},
@@ -313,11 +461,9 @@ export default function DashboardTerminal({
const confirmPendingLaunch = useCallback(() => {
if (!pendingLaunch) return
- const {
- command,
- sandboxId: launchSandboxId,
- template: launchTemplate,
- } = pendingLaunch
+ const { command, target: launchTarget } = pendingLaunch
+ const launchTemplate = launchTarget?.template ?? 'base'
+ const launchSandboxId = launchTarget?.sandboxId
if (
status === 'ready' &&
@@ -327,7 +473,10 @@ export default function DashboardTerminal({
setPendingLaunch(null)
runCommand(command)
if (activeSandboxId) {
- updateTerminalUrl({ clearCommand: true, sandboxId: activeSandboxId })
+ updateTerminalUrl({
+ clearCommand: true,
+ sandboxId: activeSandboxId,
+ })
}
return
}
@@ -340,8 +489,7 @@ export default function DashboardTerminal({
pendingCommandsRef.current = [command]
void startTerminal({
forceNewSandbox: !launchSandboxId && template !== launchTemplate,
- sandboxId: launchSandboxId,
- template: launchTemplate,
+ target: launchTarget,
})
}, [
activeSandboxId,
@@ -353,120 +501,88 @@ export default function DashboardTerminal({
updateTerminalUrl,
])
- const copyTerminalText = async () => {
- const value =
- xtermRef.current?.getSelection() || terminalTranscriptRef.current
- if (!value) return
-
- try {
- await navigator.clipboard.writeText(value)
- } catch {
- appendOutput('\r\nCould not copy terminal output to clipboard.\r\n')
- } finally {
- xtermRef.current?.focus()
+ const reconnectTarget = sandboxScoped
+ ? launchTarget
+ : activeSandboxId
+ ? { sandboxId: activeSandboxId, template }
+ : undefined
+ const reconnectSandboxId = sandboxScoped
+ ? launchTarget?.sandboxId
+ : activeSandboxId
+ const restartLabel = sandboxScoped
+ ? 'Reconnect terminal'
+ : 'Start new terminal sandbox'
+ const restartDisabled =
+ status === 'starting' ||
+ (sandboxScoped && !reconnectSandboxId && !getSandbox)
+
+ const restartTerminal = useCallback(() => {
+ if (sandboxScoped) {
+ if (!reconnectSandboxId && !getSandbox) return
+
+ void startTerminal({
+ target: reconnectTarget,
+ })
+ return
}
- }
-
- useEffect(() => {
- if (!autoStart || didAutoStartRef.current) return
- didAutoStartRef.current = true
- queueTerminalCommand(initialCommand, {
- sandboxId: initialSandboxId,
- template: initialTemplate,
- })
+ void startTerminal({ forceNewSandbox: true })
}, [
- autoStart,
- initialCommand,
- initialSandboxId,
- initialTemplate,
- queueTerminalCommand,
+ getSandbox,
+ reconnectTarget,
+ reconnectSandboxId,
+ sandboxScoped,
+ startTerminal,
])
useEffect(() => {
- return () => {
- startGenerationRef.current += 1
- void disconnectTerminal()
- }
- }, [disconnectTerminal])
-
- useEffect(() => {
- const container = terminalContainerRef.current
- if (!container) return
-
- const terminal = new XTerm({
- cols: terminalSizeRef.current.cols,
- rows: terminalSizeRef.current.rows,
- cursorBlink: true,
- cursorStyle: 'block',
- fontFamily:
- 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
- fontSize: 13,
- lineHeight: 1.54,
- scrollback: 10_000,
- theme: TERMINAL_THEME,
- })
+ if (!autoStart || status !== 'idle' || isStartingRef.current) return
- xtermRef.current = terminal
- terminal.open(container)
- terminal.write(terminalTranscriptRef.current)
- const dataSubscription = terminal.onData((data) => {
- sendInputToPty(data)
- })
+ const autoStartTimer = window.setTimeout(() => {
+ if (isStartingRef.current || ptyRef.current) return
- requestAnimationFrame(() => {
- resizeTerminal()
- terminal.focus()
- })
- const resizeTimer = window.setTimeout(() => {
- resizeTerminal()
- }, 100)
+ queueTerminalCommand(launchTarget?.command ?? '', {
+ target: launchTarget,
+ })
+ }, TERMINAL_AUTOSTART_DEBOUNCE_MS)
return () => {
- window.clearTimeout(resizeTimer)
- dataSubscription.dispose()
- terminal.dispose()
- if (xtermRef.current === terminal) {
- xtermRef.current = null
- }
+ window.clearTimeout(autoStartTimer)
}
- }, [resizeTerminal, sendInputToPty])
+ }, [autoStart, launchTarget, queueTerminalCommand, status])
useEffect(() => {
- const container = terminalContainerRef.current
- const resizeObserver =
- container && typeof ResizeObserver !== 'undefined'
- ? new ResizeObserver(() => {
- resizeTerminal()
- })
- : null
+ const handlePageHide = () => {
+ const pty = ptyRef.current
+ const sandboxId = sandboxRef.current?.sandboxId
- if (container) {
- resizeObserver?.observe(container)
- }
+ if (!pty || !sandboxId) return
- const handleWindowResize = () => {
- resizeTerminal()
+ requestPtyKill({ pid: pty.pid, sandboxId })
}
- window.addEventListener('resize', handleWindowResize)
+ window.addEventListener('pagehide', handlePageHide)
return () => {
- resizeObserver?.disconnect()
- window.removeEventListener('resize', handleWindowResize)
+ window.removeEventListener('pagehide', handlePageHide)
+ startGenerationRef.current += 1
+ clearAttachRetryTimer()
+ clearPendingInput()
+ void closeTerminal()
}
- }, [resizeTerminal])
+ }, [clearAttachRetryTimer, clearPendingInput, closeTerminal, requestPtyKill])
return (
<>
xtermRef.current?.focus()}
+ onFocusTerminal={focusTerminal}
onCopyTerminalText={() => void copyTerminalText()}
- onStartTerminal={(options) => void startTerminal(options)}
+ onRestartTerminal={restartTerminal}
/>
void
+ requestTimeoutMs?: number
+ shouldStoreSession?: boolean
sandboxId?: string
teamId: string
template: string
@@ -19,6 +21,8 @@ interface OpenTerminalSandboxOptions {
export async function openTerminalSandbox({
forceNewSandbox = false,
onStatus,
+ requestTimeoutMs,
+ shouldStoreSession,
sandboxId,
teamId,
template,
@@ -34,7 +38,9 @@ export async function openTerminalSandbox({
if (sandboxId) {
onStatus(`Connecting to terminal sandbox ${sandboxId}...\r\n`)
- const sandbox = await connectTerminalSandbox(sandboxId, headers)
+ const sandbox = await connectTerminalSandbox(sandboxId, headers, {
+ requestTimeoutMs,
+ })
return {
sandbox,
@@ -55,7 +61,8 @@ export async function openTerminalSandbox({
try {
sandbox = await connectTerminalSandbox(
storedTerminalSession.sandboxId,
- headers
+ headers,
+ { requestTimeoutMs }
)
} catch {
clearStoredTerminalSession(userId)
@@ -68,10 +75,12 @@ export async function openTerminalSandbox({
sandbox = await createTerminalSandbox({ headers, template, userId })
}
- writeStoredTerminalSession(userId, {
- sandboxId: sandbox.sandboxId,
- template,
- })
+ if (shouldStoreSession ?? true) {
+ writeStoredTerminalSession(userId, {
+ sandboxId: sandbox.sandboxId,
+ template,
+ })
+ }
return {
sandbox,
@@ -80,11 +89,13 @@ export async function openTerminalSandbox({
function connectTerminalSandbox(
sandboxId: string,
- headers: Record
+ headers: Record,
+ options: { requestTimeoutMs?: number } = {}
) {
return Sandbox.connect(sandboxId, {
domain: process.env.NEXT_PUBLIC_E2B_DOMAIN,
timeoutMs: TERMINAL_SANDBOX_TIMEOUT_MS,
+ requestTimeoutMs: options.requestTimeoutMs,
headers: {
...headers,
},
diff --git a/src/features/dashboard/terminal/terminal-panel.tsx b/src/features/dashboard/terminal/terminal-panel.tsx
index 0236076dc..5e3050a2e 100644
--- a/src/features/dashboard/terminal/terminal-panel.tsx
+++ b/src/features/dashboard/terminal/terminal-panel.tsx
@@ -1,75 +1,49 @@
import type { RefObject } from 'react'
+import { SandboxInspectFrame as DashboardPanelFrame } from '@/features/dashboard/shared'
import { IconButton } from '@/ui/primitives/icon-button'
import {
CopyIcon,
RefreshIcon,
TerminalCustomIcon,
} from '@/ui/primitives/icons'
-import type { StartTerminalOptions, TerminalStatus } from './types'
interface TerminalPanelProps {
sandboxId?: string
- template: string
- status: TerminalStatus
+ template?: string
+ restartDisabled: boolean
+ restartLabel: string
terminalContainerRef: RefObject
onFocusTerminal: () => void
onCopyTerminalText: () => void
- onStartTerminal: (options?: StartTerminalOptions) => void
+ onRestartTerminal: () => void
}
export default function TerminalPanel({
sandboxId,
template,
- status,
+ restartDisabled,
+ restartLabel,
terminalContainerRef,
onFocusTerminal,
onCopyTerminalText,
- onStartTerminal,
+ onRestartTerminal,
}: TerminalPanelProps) {
return (
-
-
-
-
-
- Terminal
-
-
- {template}
-
- {sandboxId ? (
-
- {sandboxId}
-
- ) : null}
-
-
-
- event.preventDefault()}
- onClick={onCopyTerminalText}
- >
-
-
- onStartTerminal({ forceNewSandbox: true })}
- >
-
-
-
-
-
+
+ }
+ >
-
+
+ )
+}
+
+function TerminalPanelHeader({
+ sandboxId,
+ template,
+ restartDisabled,
+ restartLabel,
+ onCopyTerminalText,
+ onRestartTerminal,
+}: Pick<
+ TerminalPanelProps,
+ | 'sandboxId'
+ | 'template'
+ | 'restartDisabled'
+ | 'restartLabel'
+ | 'onCopyTerminalText'
+ | 'onRestartTerminal'
+>) {
+ return (
+
+
+
+
+ Terminal
+
+ {template ? (
+
+ {template}
+
+ ) : null}
+ {sandboxId ? (
+
+ {sandboxId}
+
+ ) : null}
+
+
+
+ event.preventDefault()}
+ onClick={onCopyTerminalText}
+ >
+
+
+
+
+
+
+
)
}
diff --git a/src/features/dashboard/terminal/types.ts b/src/features/dashboard/terminal/types.ts
index 4dd6b42df..e1ca2422d 100644
--- a/src/features/dashboard/terminal/types.ts
+++ b/src/features/dashboard/terminal/types.ts
@@ -1,3 +1,5 @@
+import type { Sandbox } from 'e2b'
+
export type TerminalStatus = 'idle' | 'starting' | 'ready' | 'error'
export type StoredTerminalSession = {
@@ -7,12 +9,18 @@ export type StoredTerminalSession = {
export type StartTerminalOptions = {
forceNewSandbox?: boolean
- sandboxId?: string
- template?: string
+ target?: TerminalLaunchTarget
}
export type PendingTerminalLaunch = {
command: string
+ target?: TerminalLaunchTarget
+}
+
+export type TerminalLaunchTarget = {
+ command?: string
sandboxId?: string
- template: string
+ template?: string
}
+
+export type TerminalSandboxResolver = () => Promise
diff --git a/src/features/dashboard/terminal/use-terminal-instance.ts b/src/features/dashboard/terminal/use-terminal-instance.ts
new file mode 100644
index 000000000..95b70196b
--- /dev/null
+++ b/src/features/dashboard/terminal/use-terminal-instance.ts
@@ -0,0 +1,158 @@
+import '@xterm/xterm/css/xterm.css'
+import { Terminal as XTerm } from '@xterm/xterm'
+import { useCallback, useEffect, useRef } from 'react'
+import {
+ DEFAULT_COLS,
+ DEFAULT_ROWS,
+ MAX_TERMINAL_TRANSCRIPT_CHARS,
+} from './constants'
+import { calculateTerminalSize } from './terminal-size'
+
+const INITIAL_TERMINAL_TEXT =
+ 'Open a terminal to start a persistent E2B sandbox.\r\n'
+const TERMINAL_THEME = {
+ background: '#000000',
+ cursor: '#ffffff',
+ foreground: '#ffffff',
+ selectionBackground: '#ffffff40',
+}
+
+interface UseTerminalInstanceOptions {
+ onInput: (data: string) => void
+ onResize: (size: { cols: number; rows: number }) => void
+}
+
+export function useTerminalInstance({
+ onInput,
+ onResize,
+}: UseTerminalInstanceOptions) {
+ const xtermRef = useRef(null)
+ const terminalContainerRef = useRef(null)
+ const terminalTranscriptRef = useRef(INITIAL_TERMINAL_TEXT)
+ const terminalSizeRef = useRef({ cols: DEFAULT_COLS, rows: DEFAULT_ROWS })
+ const decoderRef = useRef(new TextDecoder())
+
+ const resizeTerminal = useCallback(() => {
+ const nextSize = calculateTerminalSize(
+ terminalContainerRef.current,
+ xtermRef.current
+ )
+ terminalSizeRef.current = nextSize
+ xtermRef.current?.resize(nextSize.cols, nextSize.rows)
+ onResize(nextSize)
+
+ return nextSize
+ }, [onResize])
+
+ const appendOutput = useCallback((chunk: string | Uint8Array) => {
+ const text =
+ typeof chunk === 'string'
+ ? chunk
+ : decoderRef.current.decode(chunk, { stream: true })
+
+ terminalTranscriptRef.current = (
+ terminalTranscriptRef.current + text
+ ).slice(-MAX_TERMINAL_TRANSCRIPT_CHARS)
+ xtermRef.current?.write(chunk, () => {
+ xtermRef.current?.scrollToBottom()
+ })
+ }, [])
+
+ const resetTerminal = useCallback(() => {
+ decoderRef.current = new TextDecoder()
+ terminalTranscriptRef.current = ''
+ xtermRef.current?.reset()
+ }, [])
+
+ const focusTerminal = useCallback(() => {
+ xtermRef.current?.focus()
+ }, [])
+
+ const copyTerminalText = useCallback(async () => {
+ const value =
+ xtermRef.current?.getSelection() || terminalTranscriptRef.current
+ if (!value) return
+
+ try {
+ await navigator.clipboard.writeText(value)
+ } catch {
+ appendOutput('\r\nCould not copy terminal output to clipboard.\r\n')
+ } finally {
+ focusTerminal()
+ }
+ }, [appendOutput, focusTerminal])
+
+ useEffect(() => {
+ const container = terminalContainerRef.current
+ if (!container) return
+
+ const terminal = new XTerm({
+ cols: terminalSizeRef.current.cols,
+ rows: terminalSizeRef.current.rows,
+ cursorBlink: true,
+ cursorStyle: 'block',
+ fontFamily:
+ 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
+ fontSize: 13,
+ lineHeight: 1.54,
+ scrollback: 10_000,
+ theme: TERMINAL_THEME,
+ })
+
+ xtermRef.current = terminal
+ terminal.open(container)
+ terminal.write(terminalTranscriptRef.current)
+ const dataSubscription = terminal.onData(onInput)
+
+ requestAnimationFrame(() => {
+ resizeTerminal()
+ terminal.focus()
+ })
+ const resizeTimer = window.setTimeout(() => {
+ resizeTerminal()
+ }, 100)
+
+ return () => {
+ window.clearTimeout(resizeTimer)
+ dataSubscription.dispose()
+ terminal.dispose()
+ if (xtermRef.current === terminal) {
+ xtermRef.current = null
+ }
+ }
+ }, [onInput, resizeTerminal])
+
+ useEffect(() => {
+ const container = terminalContainerRef.current
+ const resizeObserver =
+ container && typeof ResizeObserver !== 'undefined'
+ ? new ResizeObserver(() => {
+ resizeTerminal()
+ })
+ : null
+
+ if (container) {
+ resizeObserver?.observe(container)
+ }
+
+ const handleWindowResize = () => {
+ resizeTerminal()
+ }
+
+ window.addEventListener('resize', handleWindowResize)
+
+ return () => {
+ resizeObserver?.disconnect()
+ window.removeEventListener('resize', handleWindowResize)
+ }
+ }, [resizeTerminal])
+
+ return {
+ appendOutput,
+ copyTerminalText,
+ focusTerminal,
+ resetTerminal,
+ resizeTerminal,
+ terminalContainerRef,
+ }
+}
diff --git a/tests/unit/dashboard-terminal.test.ts b/tests/unit/dashboard-terminal.test.ts
index 54675e595..a229962cc 100644
--- a/tests/unit/dashboard-terminal.test.ts
+++ b/tests/unit/dashboard-terminal.test.ts
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SUPABASE_TEAM_HEADER, SUPABASE_TOKEN_HEADER } from '@/configs/api'
+import { attachTerminalWithRetry } from '@/features/dashboard/terminal/attach-terminal'
import { TERMINAL_SESSION_STORAGE_PREFIX } from '@/features/dashboard/terminal/constants'
import { openTerminalSandbox } from '@/features/dashboard/terminal/sandbox-session'
import {
@@ -211,6 +212,142 @@ describe('dashboard terminal helpers', () => {
})
})
+ describe('attachTerminalWithRetry', () => {
+ const retryOptions = {
+ maxRetries: 2,
+ retryBaseDelayMs: 100,
+ retryMaxDelayMs: 500,
+ }
+
+ it('returns the first successful attach without retrying', async () => {
+ const attachResult = { pty: 'pty', sandbox: 'sandbox' }
+ const waitForRetry = vi.fn()
+ const onRetry = vi.fn()
+
+ await expect(
+ attachTerminalWithRetry({
+ canRetry: true,
+ isCurrent: () => true,
+ isRetryableError: () => true,
+ ...retryOptions,
+ onRetry,
+ open: vi.fn().mockResolvedValue(attachResult),
+ waitForRetry,
+ })
+ ).resolves.toBe(attachResult)
+
+ expect(onRetry).not.toHaveBeenCalled()
+ expect(waitForRetry).not.toHaveBeenCalled()
+ })
+
+ it('retries retryable attach failures with exponential backoff', async () => {
+ const attachResult = { pty: 'pty', sandbox: 'sandbox' }
+ const retryableError = new Error('timeout')
+ const open = vi
+ .fn()
+ .mockRejectedValueOnce(retryableError)
+ .mockRejectedValueOnce(retryableError)
+ .mockResolvedValueOnce(attachResult)
+ const waitForRetry = vi.fn().mockResolvedValue(undefined)
+ const onRetry = vi.fn()
+
+ await expect(
+ attachTerminalWithRetry({
+ canRetry: true,
+ isCurrent: () => true,
+ isRetryableError: (error) => error === retryableError,
+ ...retryOptions,
+ onRetry,
+ open,
+ waitForRetry,
+ })
+ ).resolves.toBe(attachResult)
+
+ expect(open).toHaveBeenCalledTimes(3)
+ expect(onRetry).toHaveBeenNthCalledWith(1, 100)
+ expect(onRetry).toHaveBeenNthCalledWith(2, 200)
+ expect(waitForRetry).toHaveBeenNthCalledWith(1, 100)
+ expect(waitForRetry).toHaveBeenNthCalledWith(2, 200)
+ })
+
+ it('caps exponential backoff at the configured max delay', async () => {
+ const attachResult = { pty: 'pty', sandbox: 'sandbox' }
+ const retryableError = new Error('timeout')
+ const open = vi
+ .fn()
+ .mockRejectedValueOnce(retryableError)
+ .mockRejectedValueOnce(retryableError)
+ .mockRejectedValueOnce(retryableError)
+ .mockResolvedValueOnce(attachResult)
+ const waitForRetry = vi.fn().mockResolvedValue(undefined)
+ const onRetry = vi.fn()
+
+ await expect(
+ attachTerminalWithRetry({
+ canRetry: true,
+ isCurrent: () => true,
+ isRetryableError: (error) => error === retryableError,
+ maxRetries: 3,
+ onRetry,
+ open,
+ retryBaseDelayMs: 100,
+ retryMaxDelayMs: 150,
+ waitForRetry,
+ })
+ ).resolves.toBe(attachResult)
+
+ expect(open).toHaveBeenCalledTimes(4)
+ expect(onRetry).toHaveBeenNthCalledWith(1, 100)
+ expect(onRetry).toHaveBeenNthCalledWith(2, 150)
+ expect(onRetry).toHaveBeenNthCalledWith(3, 150)
+ expect(waitForRetry).toHaveBeenNthCalledWith(1, 100)
+ expect(waitForRetry).toHaveBeenNthCalledWith(2, 150)
+ expect(waitForRetry).toHaveBeenNthCalledWith(3, 150)
+ })
+
+ it('does not retry non-retryable attach failures', async () => {
+ const error = new Error('permission denied')
+ const waitForRetry = vi.fn()
+
+ await expect(
+ attachTerminalWithRetry({
+ canRetry: true,
+ isCurrent: () => true,
+ isRetryableError: () => false,
+ ...retryOptions,
+ onRetry: vi.fn(),
+ open: vi.fn().mockRejectedValue(error),
+ waitForRetry,
+ })
+ ).rejects.toBe(error)
+
+ expect(waitForRetry).not.toHaveBeenCalled()
+ })
+
+ it('stops retrying when the caller is no longer current', async () => {
+ let isCurrent = true
+ const open = vi.fn().mockRejectedValue(new Error('timeout'))
+ const waitForRetry = vi.fn().mockImplementation(async () => {
+ isCurrent = false
+ })
+
+ await expect(
+ attachTerminalWithRetry({
+ canRetry: true,
+ isCurrent: () => isCurrent,
+ isRetryableError: () => true,
+ ...retryOptions,
+ onRetry: vi.fn(),
+ open,
+ waitForRetry,
+ })
+ ).resolves.toBeNull()
+
+ expect(open).toHaveBeenCalledTimes(1)
+ expect(waitForRetry).toHaveBeenCalledWith(100)
+ })
+ })
+
describe('openTerminalSandbox', () => {
it('connects to an explicit sandbox without writing a stored session', async () => {
const statuses: string[] = []