From a21aeaf72ad334782cdd3ead5d2d85a2704a91c2 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 27 May 2026 14:54:27 -0700 Subject: [PATCH 01/28] Share dashboard panel frame --- .../dashboard/sandbox/inspect/frame.tsx | 37 +----------------- src/features/dashboard/shared/index.ts | 1 + src/features/dashboard/shared/panel-frame.tsx | 38 +++++++++++++++++++ 3 files changed, 40 insertions(+), 36 deletions(-) create mode 100644 src/features/dashboard/shared/panel-frame.tsx diff --git a/src/features/dashboard/sandbox/inspect/frame.tsx b/src/features/dashboard/sandbox/inspect/frame.tsx index b6b560fdb..f3120b7fe 100644 --- a/src/features/dashboard/sandbox/inspect/frame.tsx +++ b/src/features/dashboard/sandbox/inspect/frame.tsx @@ -1,38 +1,3 @@ 'use client' -import { motion } from 'motion/react' -import type React from 'react' -import { cn } from '@/lib/utils' - -type SandboxInspectFrameProps = React.ComponentProps & { - header: React.ReactNode - classNames?: { - frame?: string - header?: string - } -} - -export default function SandboxInspectFrame({ - className, - classNames, - children, - header, - ...props -}: SandboxInspectFrameProps) { - return ( - -
- {header} -
- {children as React.ReactNode} -
- ) -} +export { DashboardPanelFrame as default } from '@/features/dashboard/shared' diff --git a/src/features/dashboard/shared/index.ts b/src/features/dashboard/shared/index.ts index 9722c392c..468d7b8c1 100644 --- a/src/features/dashboard/shared/index.ts +++ b/src/features/dashboard/shared/index.ts @@ -1,2 +1,3 @@ export { IdBadge } from './id-badge' +export { DashboardPanelFrame } from './panel-frame' export { UserAvatar } from './user-avatar' diff --git a/src/features/dashboard/shared/panel-frame.tsx b/src/features/dashboard/shared/panel-frame.tsx new file mode 100644 index 000000000..db6393c13 --- /dev/null +++ b/src/features/dashboard/shared/panel-frame.tsx @@ -0,0 +1,38 @@ +'use client' + +import { motion } from 'motion/react' +import type React from 'react' +import { cn } from '@/lib/utils' + +type DashboardPanelFrameProps = React.ComponentProps & { + header: React.ReactNode + classNames?: { + frame?: string + header?: string + } +} + +export function DashboardPanelFrame({ + className, + classNames, + children, + header, + ...props +}: DashboardPanelFrameProps) { + return ( + +
+ {header} +
+ {children as React.ReactNode} +
+ ) +} From c954aee1c3f75775ea2bc701ad69b9aab59d95cd Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 27 May 2026 15:05:28 -0700 Subject: [PATCH 02/28] Move xterm styles into terminal component --- src/app/dashboard/terminal/layout.tsx | 9 --------- src/features/dashboard/terminal/dashboard-terminal.tsx | 1 + 2 files changed, 1 insertion(+), 9 deletions(-) delete mode 100644 src/app/dashboard/terminal/layout.tsx diff --git a/src/app/dashboard/terminal/layout.tsx b/src/app/dashboard/terminal/layout.tsx deleted file mode 100644 index c6a1e579a..000000000 --- a/src/app/dashboard/terminal/layout.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import '@xterm/xterm/css/xterm.css' - -export default function TerminalLayout({ - children, -}: { - children: React.ReactNode -}) { - return children -} diff --git a/src/features/dashboard/terminal/dashboard-terminal.tsx b/src/features/dashboard/terminal/dashboard-terminal.tsx index 3269dc675..9cc4a4e53 100644 --- a/src/features/dashboard/terminal/dashboard-terminal.tsx +++ b/src/features/dashboard/terminal/dashboard-terminal.tsx @@ -1,5 +1,6 @@ 'use client' +import '@xterm/xterm/css/xterm.css' import { Terminal as XTerm } from '@xterm/xterm' import type Sandbox from 'e2b' import type { CommandHandle } from 'e2b' From 171563e29edf78f8a5276a2cfed02f609c0be145 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 27 May 2026 15:06:58 -0700 Subject: [PATCH 03/28] Render terminal in dashboard panel frame --- .../dashboard/terminal/terminal-panel.tsx | 92 ++++++++++--------- 1 file changed, 50 insertions(+), 42 deletions(-) diff --git a/src/features/dashboard/terminal/terminal-panel.tsx b/src/features/dashboard/terminal/terminal-panel.tsx index 0236076dc..773fa1152 100644 --- a/src/features/dashboard/terminal/terminal-panel.tsx +++ b/src/features/dashboard/terminal/terminal-panel.tsx @@ -1,4 +1,5 @@ import type { RefObject } from 'react' +import { DashboardPanelFrame } from '@/features/dashboard/shared' import { IconButton } from '@/ui/primitives/icon-button' import { CopyIcon, @@ -26,50 +27,57 @@ export default function TerminalPanel({ onCopyTerminalText, onStartTerminal, }: TerminalPanelProps) { - return ( -
-
-
- - - Terminal - - - {template} + const header = ( +
+
+ + + Terminal + + + {template} + + {sandboxId ? ( + + {sandboxId} - {sandboxId ? ( - - {sandboxId} - - ) : null} -
+ ) : null} +
-
- event.preventDefault()} - onClick={onCopyTerminalText} - > - - - onStartTerminal({ forceNewSandbox: true })} - > - - -
-
+
+ event.preventDefault()} + onClick={onCopyTerminalText} + > + + + onStartTerminal({ forceNewSandbox: true })} + > + + +
+ + ) + return ( +
-
+ ) } From dc9d1e9b1a2e6049e7118b89f1471b814350de15 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 27 May 2026 15:07:07 -0700 Subject: [PATCH 04/28] Use type-only terminal SDK imports --- src/features/dashboard/terminal/dashboard-terminal.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/features/dashboard/terminal/dashboard-terminal.tsx b/src/features/dashboard/terminal/dashboard-terminal.tsx index 9cc4a4e53..2742aac2c 100644 --- a/src/features/dashboard/terminal/dashboard-terminal.tsx +++ b/src/features/dashboard/terminal/dashboard-terminal.tsx @@ -2,8 +2,7 @@ import '@xterm/xterm/css/xterm.css' import { Terminal as XTerm } from '@xterm/xterm' -import type Sandbox from 'e2b' -import type { CommandHandle } from 'e2b' +import type { CommandHandle, Sandbox } from 'e2b' import { useCallback, useEffect, useRef, useState } from 'react' import { DEFAULT_COLS, From 7ec924dfd36887e9fb1f948b0cf9e20ca789cbbc Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 27 May 2026 15:07:27 -0700 Subject: [PATCH 05/28] Keep sandbox terminals out of stored sessions --- .../dashboard/terminal/dashboard-terminal.tsx | 8 ++++++-- src/features/dashboard/terminal/sandbox-session.ts | 12 ++++++++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/features/dashboard/terminal/dashboard-terminal.tsx b/src/features/dashboard/terminal/dashboard-terminal.tsx index 2742aac2c..8bca91edf 100644 --- a/src/features/dashboard/terminal/dashboard-terminal.tsx +++ b/src/features/dashboard/terminal/dashboard-terminal.tsx @@ -38,6 +38,7 @@ interface DashboardTerminalProps { initialCommand?: string initialSandboxId?: string initialTemplate?: string + sandboxScoped?: boolean teamId: string } @@ -46,6 +47,7 @@ export default function DashboardTerminal({ initialCommand = '', initialSandboxId, initialTemplate, + sandboxScoped = false, teamId, }: DashboardTerminalProps) { const [status, setStatus] = useState('idle') @@ -147,7 +149,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,7 +163,7 @@ export default function DashboardTerminal({ window.history.replaceState(window.history.state, '', url) } }, - [] + [sandboxScoped] ) const startTerminal = useCallback( @@ -200,6 +202,7 @@ export default function DashboardTerminal({ const { sandbox } = await openTerminalSandbox({ forceNewSandbox: options.forceNewSandbox, onStatus: appendOutput, + shouldStoreSession: !sandboxScoped, sandboxId: options.sandboxId, teamId, template: nextTemplate, @@ -270,6 +273,7 @@ export default function DashboardTerminal({ disconnectTerminal, resizeTerminal, runCommand, + sandboxScoped, teamId, template, updateTerminalUrl, diff --git a/src/features/dashboard/terminal/sandbox-session.ts b/src/features/dashboard/terminal/sandbox-session.ts index 2e6f86700..162ab1fc5 100644 --- a/src/features/dashboard/terminal/sandbox-session.ts +++ b/src/features/dashboard/terminal/sandbox-session.ts @@ -11,6 +11,7 @@ import { interface OpenTerminalSandboxOptions { forceNewSandbox?: boolean onStatus: (message: string) => void + shouldStoreSession?: boolean sandboxId?: string teamId: string template: string @@ -19,6 +20,7 @@ interface OpenTerminalSandboxOptions { export async function openTerminalSandbox({ forceNewSandbox = false, onStatus, + shouldStoreSession, sandboxId, teamId, template, @@ -68,10 +70,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, From ae2b295dcbf76c3e20d918989900916b5cff5901 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 27 May 2026 15:07:54 -0700 Subject: [PATCH 06/28] Make terminal restart action caller-controlled --- .../dashboard/terminal/dashboard-terminal.tsx | 25 ++++++++++++++-- .../dashboard/terminal/terminal-panel.tsx | 29 ++++++++++--------- 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/src/features/dashboard/terminal/dashboard-terminal.tsx b/src/features/dashboard/terminal/dashboard-terminal.tsx index 8bca91edf..bea57add4 100644 --- a/src/features/dashboard/terminal/dashboard-terminal.tsx +++ b/src/features/dashboard/terminal/dashboard-terminal.tsx @@ -357,6 +357,24 @@ export default function DashboardTerminal({ updateTerminalUrl, ]) + const reconnectSandboxId = sandboxScoped ? initialSandboxId : activeSandboxId + const restartLabel = sandboxScoped + ? 'Reconnect terminal' + : 'Start new terminal sandbox' + const restartDisabled = + status === 'starting' || (sandboxScoped && !reconnectSandboxId) + + const restartTerminal = useCallback(() => { + if (sandboxScoped) { + if (!reconnectSandboxId) return + + void startTerminal({ sandboxId: reconnectSandboxId }) + return + } + + void startTerminal({ forceNewSandbox: true }) + }, [reconnectSandboxId, sandboxScoped, startTerminal]) + const copyTerminalText = async () => { const value = xtermRef.current?.getSelection() || terminalTranscriptRef.current @@ -465,12 +483,13 @@ export default function DashboardTerminal({ <> xtermRef.current?.focus()} onCopyTerminalText={() => void copyTerminalText()} - onStartTerminal={(options) => void startTerminal(options)} + onRestartTerminal={restartTerminal} /> 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) { const header = (
@@ -34,9 +35,11 @@ export default function TerminalPanel({ Terminal - - {template} - + {template ? ( + + {template} + + ) : null} {sandboxId ? ( {sandboxId} @@ -60,10 +63,10 @@ export default function TerminalPanel({ type="button" variant="tertiary" className="size-7" - aria-label="Start new terminal sandbox" - title="Start new terminal sandbox" - disabled={status === 'starting'} - onClick={() => onStartTerminal({ forceNewSandbox: true })} + aria-label={restartLabel} + title={restartLabel} + disabled={restartDisabled} + onClick={onRestartTerminal} > From e104b6c514bb3f3589f73a942af099beab32a08d Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 27 May 2026 15:08:46 -0700 Subject: [PATCH 07/28] Retry timed-out sandbox terminal attaches --- src/features/dashboard/terminal/constants.ts | 2 + .../dashboard/terminal/dashboard-terminal.tsx | 103 +++++++++++++++--- .../dashboard/terminal/sandbox-session.ts | 13 ++- 3 files changed, 101 insertions(+), 17 deletions(-) diff --git a/src/features/dashboard/terminal/constants.ts b/src/features/dashboard/terminal/constants.ts index 514d3ac8c..c45a1580a 100644 --- a/src/features/dashboard/terminal/constants.ts +++ b/src/features/dashboard/terminal/constants.ts @@ -5,3 +5,5 @@ export const DEFAULT_PANEL_HEIGHT = 260 export const MAX_TERMINAL_TRANSCRIPT_CHARS = 200_000 export const TERMINAL_SESSION_STORAGE_PREFIX = 'dashboard-terminal-session' export const DEFAULT_CWD = '/home/user' +export const TERMINAL_ATTACH_ATTEMPT_TIMEOUT_MS = 15_000 +export const TERMINAL_ATTACH_RETRY_DELAYS_MS = [1500, 3000, 5000] as const diff --git a/src/features/dashboard/terminal/dashboard-terminal.tsx b/src/features/dashboard/terminal/dashboard-terminal.tsx index bea57add4..8805b7709 100644 --- a/src/features/dashboard/terminal/dashboard-terminal.tsx +++ b/src/features/dashboard/terminal/dashboard-terminal.tsx @@ -2,13 +2,15 @@ import '@xterm/xterm/css/xterm.css' import { Terminal as XTerm } from '@xterm/xterm' -import type { CommandHandle, Sandbox } from 'e2b' +import { type CommandHandle, type Sandbox, TimeoutError } from 'e2b' import { useCallback, useEffect, useRef, useState } from 'react' import { DEFAULT_COLS, DEFAULT_CWD, DEFAULT_ROWS, MAX_TERMINAL_TRANSCRIPT_CHARS, + TERMINAL_ATTACH_ATTEMPT_TIMEOUT_MS, + TERMINAL_ATTACH_RETRY_DELAYS_MS, } from './constants' import DashboardTerminalCommandDialog from './dashboard-terminal-command-dialog' import { openTerminalSandbox } from './sandbox-session' @@ -70,8 +72,32 @@ export default function DashboardTerminal({ 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 clearAttachRetryTimer = useCallback(() => { + if (!retryTimerRef.current) return + + window.clearTimeout(retryTimerRef.current) + retryTimerRef.current = null + retryResolveRef.current?.() + retryResolveRef.current = null + }, []) + + const waitForAttachRetry = useCallback( + (delayMs: number) => + new Promise((resolve) => { + retryResolveRef.current = resolve + retryTimerRef.current = window.setTimeout(() => { + retryTimerRef.current = null + retryResolveRef.current = null + resolve() + }, delayMs) + }), + [] + ) + const resizeTerminal = useCallback(() => { const nextSize = calculateTerminalSize( terminalContainerRef.current, @@ -102,6 +128,8 @@ export default function DashboardTerminal({ }, []) const disconnectTerminal = useCallback(async () => { + clearAttachRetryTimer() + const pty = ptyRef.current ptyRef.current = null if (!pty) return @@ -111,7 +139,7 @@ export default function DashboardTerminal({ } catch { // Best-effort cleanup. The sandbox is intentionally left alive to pause. } - }, []) + }, [clearAttachRetryTimer]) const sendInputToPty = useCallback( (value: string | Uint8Array, terminalPid = pidRef.current) => { @@ -198,39 +226,78 @@ export default function DashboardTerminal({ setTemplate(nextTemplate) appendOutput('Opening terminal...\r\n') - try { + const openSandboxAndPty = async () => { const { sandbox } = await openTerminalSandbox({ forceNewSandbox: options.forceNewSandbox, onStatus: appendOutput, + requestTimeoutMs: options.sandboxId + ? TERMINAL_ATTACH_ATTEMPT_TIMEOUT_MS + : undefined, shouldStoreSession: !sandboxScoped, sandboxId: options.sandboxId, teamId, template: nextTemplate, }) - 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(options.sandboxId) + + try { + type AttachResult = NonNullable< + Awaited> + > + let attachAttempt = 0 + let sandbox: AttachResult['sandbox'] + let pty: AttachResult['pty'] + + while (true) { + try { + const result = await openSandboxAndPty() + if (!result) return + sandbox = result.sandbox + pty = result.pty + break + } catch (error) { + const retryDelay = TERMINAL_ATTACH_RETRY_DELAYS_MS[attachAttempt] + if ( + !canRetryAttach || + !retryDelay || + !isCurrentStart() || + !(error instanceof TimeoutError) + ) { + throw error + } + + attachAttempt += 1 + appendOutput( + `Terminal attach timed out. Retrying in ${Math.round( + retryDelay / 1000 + )}s...\r\n` + ) + await waitForAttachRetry(retryDelay) + + if (!isCurrentStart()) return + } + } + if (!isCurrentStart()) { try { await pty.disconnect() @@ -240,6 +307,13 @@ export default function DashboardTerminal({ 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() @@ -263,7 +337,6 @@ export default function DashboardTerminal({ ) } finally { if (isCurrentStart()) { - // Only the latest start owns the shared starting flag. isStartingRef.current = false } } @@ -277,6 +350,7 @@ export default function DashboardTerminal({ teamId, template, updateTerminalUrl, + waitForAttachRetry, ] ) @@ -408,9 +482,10 @@ export default function DashboardTerminal({ useEffect(() => { return () => { startGenerationRef.current += 1 + clearAttachRetryTimer() void disconnectTerminal() } - }, [disconnectTerminal]) + }, [clearAttachRetryTimer, disconnectTerminal]) useEffect(() => { const container = terminalContainerRef.current diff --git a/src/features/dashboard/terminal/sandbox-session.ts b/src/features/dashboard/terminal/sandbox-session.ts index 162ab1fc5..fdfd3aec4 100644 --- a/src/features/dashboard/terminal/sandbox-session.ts +++ b/src/features/dashboard/terminal/sandbox-session.ts @@ -11,6 +11,7 @@ import { interface OpenTerminalSandboxOptions { forceNewSandbox?: boolean onStatus: (message: string) => void + requestTimeoutMs?: number shouldStoreSession?: boolean sandboxId?: string teamId: string @@ -20,6 +21,7 @@ interface OpenTerminalSandboxOptions { export async function openTerminalSandbox({ forceNewSandbox = false, onStatus, + requestTimeoutMs, shouldStoreSession, sandboxId, teamId, @@ -36,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, @@ -57,7 +61,8 @@ export async function openTerminalSandbox({ try { sandbox = await connectTerminalSandbox( storedTerminalSession.sandboxId, - headers + headers, + { requestTimeoutMs } ) } catch { clearStoredTerminalSession(userId) @@ -84,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, }, From 0efbfb41e7444b77c15add3b360c8e896099da06 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 27 May 2026 15:09:01 -0700 Subject: [PATCH 08/28] Debounce terminal autostart --- src/features/dashboard/terminal/constants.ts | 1 + .../dashboard/terminal/dashboard-terminal.tsx | 22 +++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/features/dashboard/terminal/constants.ts b/src/features/dashboard/terminal/constants.ts index c45a1580a..7c5fb2c85 100644 --- a/src/features/dashboard/terminal/constants.ts +++ b/src/features/dashboard/terminal/constants.ts @@ -5,5 +5,6 @@ export const DEFAULT_PANEL_HEIGHT = 260 export const MAX_TERMINAL_TRANSCRIPT_CHARS = 200_000 export const TERMINAL_SESSION_STORAGE_PREFIX = 'dashboard-terminal-session' export const DEFAULT_CWD = '/home/user' +export const TERMINAL_AUTOSTART_DEBOUNCE_MS = 300 export const TERMINAL_ATTACH_ATTEMPT_TIMEOUT_MS = 15_000 export const TERMINAL_ATTACH_RETRY_DELAYS_MS = [1500, 3000, 5000] as const diff --git a/src/features/dashboard/terminal/dashboard-terminal.tsx b/src/features/dashboard/terminal/dashboard-terminal.tsx index 8805b7709..f43921f2d 100644 --- a/src/features/dashboard/terminal/dashboard-terminal.tsx +++ b/src/features/dashboard/terminal/dashboard-terminal.tsx @@ -11,6 +11,7 @@ import { MAX_TERMINAL_TRANSCRIPT_CHARS, TERMINAL_ATTACH_ATTEMPT_TIMEOUT_MS, TERMINAL_ATTACH_RETRY_DELAYS_MS, + TERMINAL_AUTOSTART_DEBOUNCE_MS, } from './constants' import DashboardTerminalCommandDialog from './dashboard-terminal-command-dialog' import { openTerminalSandbox } from './sandbox-session' @@ -70,7 +71,6 @@ export default function DashboardTerminal({ const decoderRef = useRef(new TextDecoder()) 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) @@ -464,19 +464,27 @@ export default function DashboardTerminal({ } useEffect(() => { - if (!autoStart || didAutoStartRef.current) return + if (!autoStart || status !== 'idle' || isStartingRef.current) return - didAutoStartRef.current = true - queueTerminalCommand(initialCommand, { - sandboxId: initialSandboxId, - template: initialTemplate, - }) + const autoStartTimer = window.setTimeout(() => { + if (isStartingRef.current || ptyRef.current) return + + queueTerminalCommand(initialCommand, { + sandboxId: initialSandboxId, + template: initialTemplate, + }) + }, TERMINAL_AUTOSTART_DEBOUNCE_MS) + + return () => { + window.clearTimeout(autoStartTimer) + } }, [ autoStart, initialCommand, initialSandboxId, initialTemplate, queueTerminalCommand, + status, ]) useEffect(() => { From 06727761fdc6ef1153f4f47014639e9110230987 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 27 May 2026 15:09:27 -0700 Subject: [PATCH 09/28] Reset terminal input queue on disconnect --- src/features/dashboard/terminal/dashboard-terminal.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/features/dashboard/terminal/dashboard-terminal.tsx b/src/features/dashboard/terminal/dashboard-terminal.tsx index f43921f2d..35e42915e 100644 --- a/src/features/dashboard/terminal/dashboard-terminal.tsx +++ b/src/features/dashboard/terminal/dashboard-terminal.tsx @@ -132,6 +132,7 @@ export default function DashboardTerminal({ const pty = ptyRef.current ptyRef.current = null + inputQueueRef.current = Promise.resolve() if (!pty) return try { From b6af83c3aad9bcef31810e5af18aec5c94edbf87 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 27 May 2026 15:09:36 -0700 Subject: [PATCH 10/28] Show missing terminal sandbox access copy --- src/app/dashboard/terminal/page.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/app/dashboard/terminal/page.tsx b/src/app/dashboard/terminal/page.tsx index a5454a625..aedbad949 100644 --- a/src/app/dashboard/terminal/page.tsx +++ b/src/app/dashboard/terminal/page.tsx @@ -78,7 +78,15 @@ export default async function TerminalPage({ : teamsResult.data.find((candidate) => candidate.id === resolvedTeam?.id) if (!team) { - return + return ( + + ) } const templateAvailable = terminalSandboxId From c4257da90a0e63739346630b1a54439055ab7485 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 27 May 2026 15:09:55 -0700 Subject: [PATCH 11/28] Add terminal tab to sandbox details --- .../sandboxes/[sandboxId]/terminal/page.tsx | 11 ++++++++ src/configs/urls.ts | 2 ++ src/features/dashboard/sandbox/layout.tsx | 7 ++++++ .../dashboard/sandbox/terminal/view.tsx | 25 +++++++++++++++++++ 4 files changed, 45 insertions(+) create mode 100644 src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/terminal/page.tsx create mode 100644 src/features/dashboard/sandbox/terminal/view.tsx diff --git a/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/terminal/page.tsx b/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/terminal/page.tsx new file mode 100644 index 000000000..374231387 --- /dev/null +++ b/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/terminal/page.tsx @@ -0,0 +1,11 @@ +import SandboxTerminalView from '@/features/dashboard/sandbox/terminal/view' + +export default async function SandboxTerminalPage({ + params, +}: { + params: Promise<{ teamSlug: string; sandboxId: string }> +}) { + const { sandboxId } = await params + + return +} diff --git a/src/configs/urls.ts b/src/configs/urls.ts index 9412b2a97..8913fc0c8 100644 --- a/src/configs/urls.ts +++ b/src/configs/urls.ts @@ -35,6 +35,8 @@ export const PROTECTED_URLS = { `/dashboard/${teamSlug}/sandboxes/${sandboxId}/events`, SANDBOX_LOGS: (teamSlug: string, sandboxId: string) => `/dashboard/${teamSlug}/sandboxes/${sandboxId}/logs`, + SANDBOX_TERMINAL: (teamSlug: string, sandboxId: string) => + `/dashboard/${teamSlug}/sandboxes/${sandboxId}/terminal`, SANDBOX_FILESYSTEM: (teamSlug: string, sandboxId: string) => `/dashboard/${teamSlug}/sandboxes/${sandboxId}/filesystem`, diff --git a/src/features/dashboard/sandbox/layout.tsx b/src/features/dashboard/sandbox/layout.tsx index edfd18dec..85da45762 100644 --- a/src/features/dashboard/sandbox/layout.tsx +++ b/src/features/dashboard/sandbox/layout.tsx @@ -8,6 +8,7 @@ import { HistoryIcon, ListIcon, StorageIcon, + TerminalCustomIcon, TrendIcon, } from '@/ui/primitives/icons' import { useSandboxContext } from './context' @@ -68,6 +69,12 @@ export default function SandboxLayout({ href: PROTECTED_URLS.SANDBOX_LOGS(teamSlug, sandboxId), icon: , }, + { + id: 'terminal', + label: 'Terminal', + href: PROTECTED_URLS.SANDBOX_TERMINAL(teamSlug, sandboxId), + icon: , + }, { id: 'filesystem', label: 'Filesystem', diff --git a/src/features/dashboard/sandbox/terminal/view.tsx b/src/features/dashboard/sandbox/terminal/view.tsx new file mode 100644 index 000000000..8e6640d56 --- /dev/null +++ b/src/features/dashboard/sandbox/terminal/view.tsx @@ -0,0 +1,25 @@ +'use client' + +import { useDashboard } from '@/features/dashboard/context' +import DashboardTerminal from '@/features/dashboard/terminal/dashboard-terminal' + +interface SandboxTerminalViewProps { + sandboxId: string +} + +export default function SandboxTerminalView({ + sandboxId, +}: SandboxTerminalViewProps) { + const { team } = useDashboard() + + return ( +
+ +
+ ) +} From 5fcd614bb1f508d41f4685ead472883cbdbf8f9f Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 27 May 2026 15:11:24 -0700 Subject: [PATCH 12/28] Add terminal tab route boundaries --- .../sandboxes/[sandboxId]/terminal/error.tsx | 13 +++++++++++++ .../sandboxes/[sandboxId]/terminal/loading.tsx | 1 + 2 files changed, 14 insertions(+) create mode 100644 src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/terminal/error.tsx create mode 100644 src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/terminal/loading.tsx diff --git a/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/terminal/error.tsx b/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/terminal/error.tsx new file mode 100644 index 000000000..6f3fa608d --- /dev/null +++ b/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/terminal/error.tsx @@ -0,0 +1,13 @@ +'use client' + +import { DashboardRouteError } from '@/features/dashboard/shared/route-error' + +export default function SandboxTerminalPageError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + return +} diff --git a/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/terminal/loading.tsx b/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/terminal/loading.tsx new file mode 100644 index 000000000..249f11404 --- /dev/null +++ b/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/terminal/loading.tsx @@ -0,0 +1 @@ +export { default } from '@/features/dashboard/loading-layout' From 9195326e0fc8075db89809c6bd4f2a6c84b17080 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 27 May 2026 15:26:26 -0700 Subject: [PATCH 13/28] Extract terminal instance hook --- .../dashboard/terminal/dashboard-terminal.tsx | 178 ++++-------------- .../terminal/use-terminal-instance.ts | 158 ++++++++++++++++ 2 files changed, 191 insertions(+), 145 deletions(-) create mode 100644 src/features/dashboard/terminal/use-terminal-instance.ts diff --git a/src/features/dashboard/terminal/dashboard-terminal.tsx b/src/features/dashboard/terminal/dashboard-terminal.tsx index 35e42915e..6ead97535 100644 --- a/src/features/dashboard/terminal/dashboard-terminal.tsx +++ b/src/features/dashboard/terminal/dashboard-terminal.tsx @@ -1,14 +1,8 @@ 'use client' - -import '@xterm/xterm/css/xterm.css' -import { Terminal as XTerm } from '@xterm/xterm' import { type CommandHandle, type Sandbox, TimeoutError } from 'e2b' import { useCallback, useEffect, useRef, useState } from 'react' import { - DEFAULT_COLS, DEFAULT_CWD, - DEFAULT_ROWS, - MAX_TERMINAL_TRANSCRIPT_CHARS, TERMINAL_ATTACH_ATTEMPT_TIMEOUT_MS, TERMINAL_ATTACH_RETRY_DELAYS_MS, TERMINAL_AUTOSTART_DEBOUNCE_MS, @@ -20,21 +14,12 @@ import { resolveTerminalTemplateOverride, } from './template' import TerminalPanel from './terminal-panel' -import { calculateTerminalSize } from './terminal-size' import type { PendingTerminalLaunch, StartTerminalOptions, TerminalStatus, } from './types' - -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', -} +import { useTerminalInstance } from './use-terminal-instance' interface DashboardTerminalProps { autoStart?: boolean @@ -64,11 +49,6 @@ 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 pendingCommandsRef = useRef([]) const inputQueueRef = useRef(Promise.resolve()) const isStartingRef = useRef(false) @@ -98,34 +78,38 @@ export default function DashboardTerminal({ [] ) - const resizeTerminal = useCallback(() => { - const nextSize = calculateTerminalSize( - terminalContainerRef.current, - xtermRef.current - ) - terminalSizeRef.current = nextSize - xtermRef.current?.resize(nextSize.cols, nextSize.rows) + 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)) + }, + [] + ) + const resizePty = useCallback((size: { cols: number; rows: number }) => { if (sandboxRef.current && pidRef.current) { - void sandboxRef.current.pty.resize(pidRef.current, nextSize) + void sandboxRef.current.pty.resize(pidRef.current, size) } - - return nextSize }, []) - 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 { + appendOutput, + copyTerminalText, + focusTerminal, + resetTerminal, + resizeTerminal, + terminalContainerRef, + } = useTerminalInstance({ + onInput: sendInputToPty, + onResize: resizePty, + }) const disconnectTerminal = useCallback(async () => { clearAttachRetryTimer() @@ -142,21 +126,6 @@ export default function DashboardTerminal({ } }, [clearAttachRetryTimer]) - 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)) - }, - [] - ) - const runCommand = useCallback( (command: string, terminalPid?: number) => { const normalizedCommand = command.trim() @@ -218,10 +187,8 @@ export default function DashboardTerminal({ await disconnectTerminal() 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) setTemplate(nextTemplate) @@ -320,7 +287,7 @@ export default function DashboardTerminal({ resizeTerminal() setStatus('ready') appendOutput(`PTY ${pty.pid} attached.\r\n`) - xtermRef.current?.focus() + focusTerminal() const pendingCommands = pendingCommandsRef.current pendingCommandsRef.current = [] @@ -346,6 +313,8 @@ export default function DashboardTerminal({ appendOutput, disconnectTerminal, resizeTerminal, + resetTerminal, + focusTerminal, runCommand, sandboxScoped, teamId, @@ -450,20 +419,6 @@ export default function DashboardTerminal({ void startTerminal({ forceNewSandbox: true }) }, [reconnectSandboxId, sandboxScoped, startTerminal]) - 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() - } - } - useEffect(() => { if (!autoStart || status !== 'idle' || isStartingRef.current) return @@ -496,73 +451,6 @@ export default function DashboardTerminal({ } }, [clearAttachRetryTimer, 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, - }) - - xtermRef.current = terminal - terminal.open(container) - terminal.write(terminalTranscriptRef.current) - const dataSubscription = terminal.onData((data) => { - sendInputToPty(data) - }) - - 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 - } - } - }, [resizeTerminal, sendInputToPty]) - - 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 ( <> xtermRef.current?.focus()} + onFocusTerminal={focusTerminal} onCopyTerminalText={() => void copyTerminalText()} onRestartTerminal={restartTerminal} /> 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, + } +} From e61ed34b2f0d7fd1f8e4faa59d6e3536e4fbaf3d Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 27 May 2026 15:50:00 -0700 Subject: [PATCH 14/28] Remove sandbox inspect frame shim --- src/features/dashboard/sandbox/inspect/filesystem.tsx | 2 +- src/features/dashboard/sandbox/inspect/frame.tsx | 3 --- src/features/dashboard/sandbox/inspect/viewer.tsx | 2 +- 3 files changed, 2 insertions(+), 5 deletions(-) delete mode 100644 src/features/dashboard/sandbox/inspect/frame.tsx diff --git a/src/features/dashboard/sandbox/inspect/filesystem.tsx b/src/features/dashboard/sandbox/inspect/filesystem.tsx index 9ec6fee7b..158710950 100644 --- a/src/features/dashboard/sandbox/inspect/filesystem.tsx +++ b/src/features/dashboard/sandbox/inspect/filesystem.tsx @@ -2,9 +2,9 @@ import LoadingLayout from '@/features/dashboard/loading-layout' import SandboxInspectFilesystemHeader from '@/features/dashboard/sandbox/inspect/filesystem-header' +import { DashboardPanelFrame as SandboxInspectFrame } from '@/features/dashboard/shared' import { ScrollArea } from '@/ui/primitives/scroll-area' import { useSandboxContext } from '../context' -import SandboxInspectFrame from './frame' import { useDirectoryState } from './hooks/use-directory' import { useRootChildren } from './hooks/use-node' import SandboxInspectNode from './node' diff --git a/src/features/dashboard/sandbox/inspect/frame.tsx b/src/features/dashboard/sandbox/inspect/frame.tsx deleted file mode 100644 index f3120b7fe..000000000 --- a/src/features/dashboard/sandbox/inspect/frame.tsx +++ /dev/null @@ -1,3 +0,0 @@ -'use client' - -export { DashboardPanelFrame as default } from '@/features/dashboard/shared' diff --git a/src/features/dashboard/sandbox/inspect/viewer.tsx b/src/features/dashboard/sandbox/inspect/viewer.tsx index 559af2774..ccc3961cc 100644 --- a/src/features/dashboard/sandbox/inspect/viewer.tsx +++ b/src/features/dashboard/sandbox/inspect/viewer.tsx @@ -4,6 +4,7 @@ import { AnimatePresence } from 'motion/react' import { useEffect, useState } from 'react' import ShikiHighlighter, { type Language } from 'react-shiki' import { useShikiTheme } from '@/configs/shiki' +import { DashboardPanelFrame as SandboxInspectFrame } from '@/features/dashboard/shared' import { useIsMobile } from '@/lib/hooks/use-mobile' import { cn } from '@/lib/utils' import { Button } from '@/ui/primitives/button' @@ -16,7 +17,6 @@ import { type UnreadableFileTypeContentState, type UnreadableTooLargeContentState, } from './filesystem/store' -import SandboxInspectFrame from './frame' import { useContent } from './hooks/use-content' import { useFile } from './hooks/use-file' import { useErrorPaths, useSelectedPath } from './hooks/use-node' From 2c9d14635e13189f2a922653f35950b0d5aa0af6 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 27 May 2026 15:52:30 -0700 Subject: [PATCH 15/28] Use dashboard panel frame directly --- src/features/dashboard/sandbox/inspect/filesystem.tsx | 6 +++--- src/features/dashboard/sandbox/inspect/viewer.tsx | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/features/dashboard/sandbox/inspect/filesystem.tsx b/src/features/dashboard/sandbox/inspect/filesystem.tsx index 158710950..ce52b1fc6 100644 --- a/src/features/dashboard/sandbox/inspect/filesystem.tsx +++ b/src/features/dashboard/sandbox/inspect/filesystem.tsx @@ -2,7 +2,7 @@ import LoadingLayout from '@/features/dashboard/loading-layout' import SandboxInspectFilesystemHeader from '@/features/dashboard/sandbox/inspect/filesystem-header' -import { DashboardPanelFrame as SandboxInspectFrame } from '@/features/dashboard/shared' +import { DashboardPanelFrame } from '@/features/dashboard/shared' import { ScrollArea } from '@/ui/primitives/scroll-area' import { useSandboxContext } from '../context' import { useDirectoryState } from './hooks/use-directory' @@ -28,7 +28,7 @@ export default function SandboxInspectFilesystem({ return (
- )}
- +
) } diff --git a/src/features/dashboard/sandbox/inspect/viewer.tsx b/src/features/dashboard/sandbox/inspect/viewer.tsx index ccc3961cc..9748b16f9 100644 --- a/src/features/dashboard/sandbox/inspect/viewer.tsx +++ b/src/features/dashboard/sandbox/inspect/viewer.tsx @@ -4,7 +4,7 @@ import { AnimatePresence } from 'motion/react' import { useEffect, useState } from 'react' import ShikiHighlighter, { type Language } from 'react-shiki' import { useShikiTheme } from '@/configs/shiki' -import { DashboardPanelFrame as SandboxInspectFrame } from '@/features/dashboard/shared' +import { DashboardPanelFrame } from '@/features/dashboard/shared' import { useIsMobile } from '@/lib/hooks/use-mobile' import { cn } from '@/lib/utils' import { Button } from '@/ui/primitives/button' @@ -81,7 +81,7 @@ function SandboxInspectViewerContent({ } return ( - )} - + ) } From 56ae8bfdede7d0f24ab5470b79a6cca4a4058f7a Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 27 May 2026 15:58:43 -0700 Subject: [PATCH 16/28] Keep shared frame named for sandbox inspect --- src/features/dashboard/sandbox/inspect/filesystem.tsx | 6 +++--- src/features/dashboard/sandbox/inspect/viewer.tsx | 6 +++--- src/features/dashboard/shared/index.ts | 2 +- src/features/dashboard/shared/panel-frame.tsx | 6 +++--- src/features/dashboard/terminal/terminal-panel.tsx | 2 +- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/features/dashboard/sandbox/inspect/filesystem.tsx b/src/features/dashboard/sandbox/inspect/filesystem.tsx index ce52b1fc6..19b170135 100644 --- a/src/features/dashboard/sandbox/inspect/filesystem.tsx +++ b/src/features/dashboard/sandbox/inspect/filesystem.tsx @@ -2,7 +2,7 @@ import LoadingLayout from '@/features/dashboard/loading-layout' import SandboxInspectFilesystemHeader from '@/features/dashboard/sandbox/inspect/filesystem-header' -import { DashboardPanelFrame } from '@/features/dashboard/shared' +import { SandboxInspectFrame } from '@/features/dashboard/shared' import { ScrollArea } from '@/ui/primitives/scroll-area' import { useSandboxContext } from '../context' import { useDirectoryState } from './hooks/use-directory' @@ -28,7 +28,7 @@ export default function SandboxInspectFilesystem({ return (
- )}
- + ) } diff --git a/src/features/dashboard/sandbox/inspect/viewer.tsx b/src/features/dashboard/sandbox/inspect/viewer.tsx index 9748b16f9..729cb44ea 100644 --- a/src/features/dashboard/sandbox/inspect/viewer.tsx +++ b/src/features/dashboard/sandbox/inspect/viewer.tsx @@ -4,7 +4,7 @@ import { AnimatePresence } from 'motion/react' import { useEffect, useState } from 'react' import ShikiHighlighter, { type Language } from 'react-shiki' import { useShikiTheme } from '@/configs/shiki' -import { DashboardPanelFrame } from '@/features/dashboard/shared' +import { SandboxInspectFrame } from '@/features/dashboard/shared' import { useIsMobile } from '@/lib/hooks/use-mobile' import { cn } from '@/lib/utils' import { Button } from '@/ui/primitives/button' @@ -81,7 +81,7 @@ function SandboxInspectViewerContent({ } return ( - )} - + ) } diff --git a/src/features/dashboard/shared/index.ts b/src/features/dashboard/shared/index.ts index 468d7b8c1..1640efc39 100644 --- a/src/features/dashboard/shared/index.ts +++ b/src/features/dashboard/shared/index.ts @@ -1,3 +1,3 @@ export { IdBadge } from './id-badge' -export { DashboardPanelFrame } from './panel-frame' +export { SandboxInspectFrame } from './panel-frame' export { UserAvatar } from './user-avatar' diff --git a/src/features/dashboard/shared/panel-frame.tsx b/src/features/dashboard/shared/panel-frame.tsx index db6393c13..6102e23a5 100644 --- a/src/features/dashboard/shared/panel-frame.tsx +++ b/src/features/dashboard/shared/panel-frame.tsx @@ -4,7 +4,7 @@ import { motion } from 'motion/react' import type React from 'react' import { cn } from '@/lib/utils' -type DashboardPanelFrameProps = React.ComponentProps & { +type SandboxInspectFrameProps = React.ComponentProps & { header: React.ReactNode classNames?: { frame?: string @@ -12,13 +12,13 @@ type DashboardPanelFrameProps = React.ComponentProps & { } } -export function DashboardPanelFrame({ +export function SandboxInspectFrame({ className, classNames, children, header, ...props -}: DashboardPanelFrameProps) { +}: SandboxInspectFrameProps) { return ( Date: Wed, 27 May 2026 16:08:42 -0700 Subject: [PATCH 17/28] Extract terminal attach retry helper --- .../dashboard/terminal/attach-terminal.ts | 43 +++++++++ .../dashboard/terminal/dashboard-terminal.tsx | 44 +++------ tests/unit/dashboard-terminal.test.ts | 93 +++++++++++++++++++ 3 files changed, 150 insertions(+), 30 deletions(-) create mode 100644 src/features/dashboard/terminal/attach-terminal.ts diff --git a/src/features/dashboard/terminal/attach-terminal.ts b/src/features/dashboard/terminal/attach-terminal.ts new file mode 100644 index 000000000..fe47bd9aa --- /dev/null +++ b/src/features/dashboard/terminal/attach-terminal.ts @@ -0,0 +1,43 @@ +interface AttachTerminalWithRetryOptions { + canRetry: boolean + isCurrent: () => boolean + isRetryableError: (error: unknown) => boolean + onRetry: (delayMs: number) => void + open: () => Promise + retryDelaysMs: readonly number[] + waitForRetry: (delayMs: number) => Promise +} + +export async function attachTerminalWithRetry({ + canRetry, + isCurrent, + isRetryableError, + onRetry, + open, + retryDelaysMs, + waitForRetry, +}: AttachTerminalWithRetryOptions) { + let attachAttempt = 0 + + while (true) { + try { + return await open() + } catch (error) { + const retryDelay = retryDelaysMs[attachAttempt] + if ( + !canRetry || + !retryDelay || + !isCurrent() || + !isRetryableError(error) + ) { + throw error + } + + attachAttempt += 1 + onRetry(retryDelay) + await waitForRetry(retryDelay) + + if (!isCurrent()) return null + } + } +} diff --git a/src/features/dashboard/terminal/dashboard-terminal.tsx b/src/features/dashboard/terminal/dashboard-terminal.tsx index 6ead97535..a18767b15 100644 --- a/src/features/dashboard/terminal/dashboard-terminal.tsx +++ b/src/features/dashboard/terminal/dashboard-terminal.tsx @@ -1,6 +1,8 @@ 'use client' + import { type CommandHandle, type Sandbox, TimeoutError } from 'e2b' import { useCallback, useEffect, useRef, useState } from 'react' +import { attachTerminalWithRetry } from './attach-terminal' import { DEFAULT_CWD, TERMINAL_ATTACH_ATTEMPT_TIMEOUT_MS, @@ -229,42 +231,24 @@ export default function DashboardTerminal({ const canRetryAttach = Boolean(options.sandboxId) try { - type AttachResult = NonNullable< - Awaited> - > - let attachAttempt = 0 - let sandbox: AttachResult['sandbox'] - let pty: AttachResult['pty'] - - while (true) { - try { - const result = await openSandboxAndPty() - if (!result) return - sandbox = result.sandbox - pty = result.pty - break - } catch (error) { - const retryDelay = TERMINAL_ATTACH_RETRY_DELAYS_MS[attachAttempt] - if ( - !canRetryAttach || - !retryDelay || - !isCurrentStart() || - !(error instanceof TimeoutError) - ) { - throw error - } - - attachAttempt += 1 + const result = await attachTerminalWithRetry({ + canRetry: canRetryAttach, + isCurrent: isCurrentStart, + isRetryableError: (error) => error instanceof TimeoutError, + onRetry: (retryDelay) => { appendOutput( `Terminal attach timed out. Retrying in ${Math.round( retryDelay / 1000 )}s...\r\n` ) - await waitForAttachRetry(retryDelay) + }, + open: openSandboxAndPty, + retryDelaysMs: TERMINAL_ATTACH_RETRY_DELAYS_MS, + waitForRetry: waitForAttachRetry, + }) - if (!isCurrentStart()) return - } - } + if (!result) return + const { sandbox, pty } = result if (!isCurrentStart()) { try { diff --git a/tests/unit/dashboard-terminal.test.ts b/tests/unit/dashboard-terminal.test.ts index 54675e595..47f24797d 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,98 @@ describe('dashboard terminal helpers', () => { }) }) + describe('attachTerminalWithRetry', () => { + 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, + onRetry, + open: vi.fn().mockResolvedValue(attachResult), + retryDelaysMs: [100], + waitForRetry, + }) + ).resolves.toBe(attachResult) + + expect(onRetry).not.toHaveBeenCalled() + expect(waitForRetry).not.toHaveBeenCalled() + }) + + it('retries retryable attach failures using the configured delays', async () => { + const attachResult = { pty: 'pty', sandbox: 'sandbox' } + const retryableError = new Error('timeout') + const open = vi + .fn() + .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, + onRetry, + open, + retryDelaysMs: [100], + waitForRetry, + }) + ).resolves.toBe(attachResult) + + expect(open).toHaveBeenCalledTimes(2) + expect(onRetry).toHaveBeenCalledWith(100) + expect(waitForRetry).toHaveBeenCalledWith(100) + }) + + 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, + onRetry: vi.fn(), + open: vi.fn().mockRejectedValue(error), + retryDelaysMs: [100], + 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, + onRetry: vi.fn(), + open, + retryDelaysMs: [100, 200], + 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[] = [] From 19faacd143fe5436fc0e6f9414013693fe6892a3 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 27 May 2026 16:52:34 -0700 Subject: [PATCH 18/28] Allow zero-delay terminal attach retries --- .../dashboard/terminal/attach-terminal.ts | 2 +- tests/unit/dashboard-terminal.test.ts | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/features/dashboard/terminal/attach-terminal.ts b/src/features/dashboard/terminal/attach-terminal.ts index fe47bd9aa..876a3827c 100644 --- a/src/features/dashboard/terminal/attach-terminal.ts +++ b/src/features/dashboard/terminal/attach-terminal.ts @@ -26,7 +26,7 @@ export async function attachTerminalWithRetry({ const retryDelay = retryDelaysMs[attachAttempt] if ( !canRetry || - !retryDelay || + retryDelay == null || !isCurrent() || !isRetryableError(error) ) { diff --git a/tests/unit/dashboard-terminal.test.ts b/tests/unit/dashboard-terminal.test.ts index 47f24797d..0e0755f24 100644 --- a/tests/unit/dashboard-terminal.test.ts +++ b/tests/unit/dashboard-terminal.test.ts @@ -261,6 +261,33 @@ describe('dashboard terminal helpers', () => { expect(waitForRetry).toHaveBeenCalledWith(100) }) + it('allows immediate retries with a zero delay', async () => { + const attachResult = { pty: 'pty', sandbox: 'sandbox' } + const retryableError = new Error('timeout') + const open = vi + .fn() + .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, + onRetry, + open, + retryDelaysMs: [0], + waitForRetry, + }) + ).resolves.toBe(attachResult) + + expect(open).toHaveBeenCalledTimes(2) + expect(onRetry).toHaveBeenCalledWith(0) + expect(waitForRetry).toHaveBeenCalledWith(0) + }) + it('does not retry non-retryable attach failures', async () => { const error = new Error('permission denied') const waitForRetry = vi.fn() From 2165839f416c69168533f9050d12968e67016f1a Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 27 May 2026 17:27:34 -0700 Subject: [PATCH 19/28] Use bounded terminal attach backoff --- .../dashboard/terminal/attach-terminal.ts | 35 +++++++++++---- src/features/dashboard/terminal/constants.ts | 4 +- .../dashboard/terminal/dashboard-terminal.tsx | 8 +++- tests/unit/dashboard-terminal.test.ts | 43 +++++++++++++------ 4 files changed, 66 insertions(+), 24 deletions(-) diff --git a/src/features/dashboard/terminal/attach-terminal.ts b/src/features/dashboard/terminal/attach-terminal.ts index 876a3827c..26f3bfcea 100644 --- a/src/features/dashboard/terminal/attach-terminal.ts +++ b/src/features/dashboard/terminal/attach-terminal.ts @@ -2,9 +2,11 @@ interface AttachTerminalWithRetryOptions { canRetry: boolean isCurrent: () => boolean isRetryableError: (error: unknown) => boolean + maxRetries: number onRetry: (delayMs: number) => void open: () => Promise - retryDelaysMs: readonly number[] + retryBaseDelayMs: number + retryMaxDelayMs: number waitForRetry: (delayMs: number) => Promise } @@ -12,32 +14,49 @@ export async function attachTerminalWithRetry({ canRetry, isCurrent, isRetryableError, + maxRetries, onRetry, open, - retryDelaysMs, + retryBaseDelayMs, + retryMaxDelayMs, waitForRetry, }: AttachTerminalWithRetryOptions) { - let attachAttempt = 0 - - while (true) { + for (let attempt = 0; attempt <= maxRetries; attempt += 1) { try { return await open() } catch (error) { - const retryDelay = retryDelaysMs[attachAttempt] if ( !canRetry || - retryDelay == null || + attempt >= maxRetries || !isCurrent() || !isRetryableError(error) ) { throw error } - attachAttempt += 1 + const retryDelay = getRetryDelayMs({ + attempt, + baseDelayMs: retryBaseDelayMs, + maxDelayMs: retryMaxDelayMs, + }) onRetry(retryDelay) await waitForRetry(retryDelay) if (!isCurrent()) return null } } + + return null +} + +function getRetryDelayMs({ + attempt, + baseDelayMs, + maxDelayMs, +}: { + attempt: number + baseDelayMs: number + maxDelayMs: number +}) { + return Math.min(baseDelayMs * 2 ** attempt, maxDelayMs) } diff --git a/src/features/dashboard/terminal/constants.ts b/src/features/dashboard/terminal/constants.ts index 7c5fb2c85..1855f68d5 100644 --- a/src/features/dashboard/terminal/constants.ts +++ b/src/features/dashboard/terminal/constants.ts @@ -7,4 +7,6 @@ export const TERMINAL_SESSION_STORAGE_PREFIX = 'dashboard-terminal-session' export const DEFAULT_CWD = '/home/user' export const TERMINAL_AUTOSTART_DEBOUNCE_MS = 300 export const TERMINAL_ATTACH_ATTEMPT_TIMEOUT_MS = 15_000 -export const TERMINAL_ATTACH_RETRY_DELAYS_MS = [1500, 3000, 5000] as const +export const TERMINAL_ATTACH_MAX_RETRIES = 3 +export const TERMINAL_ATTACH_RETRY_BASE_DELAY_MS = 1500 +export const TERMINAL_ATTACH_RETRY_MAX_DELAY_MS = 5000 diff --git a/src/features/dashboard/terminal/dashboard-terminal.tsx b/src/features/dashboard/terminal/dashboard-terminal.tsx index a18767b15..8e866f466 100644 --- a/src/features/dashboard/terminal/dashboard-terminal.tsx +++ b/src/features/dashboard/terminal/dashboard-terminal.tsx @@ -6,7 +6,9 @@ import { attachTerminalWithRetry } from './attach-terminal' import { DEFAULT_CWD, TERMINAL_ATTACH_ATTEMPT_TIMEOUT_MS, - TERMINAL_ATTACH_RETRY_DELAYS_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' @@ -235,6 +237,7 @@ export default function DashboardTerminal({ 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( @@ -243,7 +246,8 @@ export default function DashboardTerminal({ ) }, open: openSandboxAndPty, - retryDelaysMs: TERMINAL_ATTACH_RETRY_DELAYS_MS, + retryBaseDelayMs: TERMINAL_ATTACH_RETRY_BASE_DELAY_MS, + retryMaxDelayMs: TERMINAL_ATTACH_RETRY_MAX_DELAY_MS, waitForRetry: waitForAttachRetry, }) diff --git a/tests/unit/dashboard-terminal.test.ts b/tests/unit/dashboard-terminal.test.ts index 0e0755f24..a229962cc 100644 --- a/tests/unit/dashboard-terminal.test.ts +++ b/tests/unit/dashboard-terminal.test.ts @@ -213,6 +213,12 @@ 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() @@ -223,9 +229,9 @@ describe('dashboard terminal helpers', () => { canRetry: true, isCurrent: () => true, isRetryableError: () => true, + ...retryOptions, onRetry, open: vi.fn().mockResolvedValue(attachResult), - retryDelaysMs: [100], waitForRetry, }) ).resolves.toBe(attachResult) @@ -234,12 +240,13 @@ describe('dashboard terminal helpers', () => { expect(waitForRetry).not.toHaveBeenCalled() }) - it('retries retryable attach failures using the configured delays', async () => { + 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() @@ -249,24 +256,28 @@ describe('dashboard terminal helpers', () => { canRetry: true, isCurrent: () => true, isRetryableError: (error) => error === retryableError, + ...retryOptions, onRetry, open, - retryDelaysMs: [100], waitForRetry, }) ).resolves.toBe(attachResult) - expect(open).toHaveBeenCalledTimes(2) - expect(onRetry).toHaveBeenCalledWith(100) - expect(waitForRetry).toHaveBeenCalledWith(100) + 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('allows immediate retries with a zero delay', async () => { + 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() @@ -276,16 +287,22 @@ describe('dashboard terminal helpers', () => { canRetry: true, isCurrent: () => true, isRetryableError: (error) => error === retryableError, + maxRetries: 3, onRetry, open, - retryDelaysMs: [0], + retryBaseDelayMs: 100, + retryMaxDelayMs: 150, waitForRetry, }) ).resolves.toBe(attachResult) - expect(open).toHaveBeenCalledTimes(2) - expect(onRetry).toHaveBeenCalledWith(0) - expect(waitForRetry).toHaveBeenCalledWith(0) + 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 () => { @@ -297,9 +314,9 @@ describe('dashboard terminal helpers', () => { canRetry: true, isCurrent: () => true, isRetryableError: () => false, + ...retryOptions, onRetry: vi.fn(), open: vi.fn().mockRejectedValue(error), - retryDelaysMs: [100], waitForRetry, }) ).rejects.toBe(error) @@ -319,9 +336,9 @@ describe('dashboard terminal helpers', () => { canRetry: true, isCurrent: () => isCurrent, isRetryableError: () => true, + ...retryOptions, onRetry: vi.fn(), open, - retryDelaysMs: [100, 200], waitForRetry, }) ).resolves.toBeNull() From 9d6b490738c272fbbf46e9d7290c3a4b06686316 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 27 May 2026 18:10:35 -0700 Subject: [PATCH 20/28] Pass terminal launch options as a target --- src/app/dashboard/terminal/page.tsx | 8 +- .../dashboard-terminal-command-dialog.tsx | 6 +- .../dashboard/terminal/dashboard-terminal.tsx | 89 +++++++++++-------- src/features/dashboard/terminal/types.ts | 10 ++- 4 files changed, 66 insertions(+), 47 deletions(-) diff --git a/src/app/dashboard/terminal/page.tsx b/src/app/dashboard/terminal/page.tsx index aedbad949..0c54d8b45 100644 --- a/src/app/dashboard/terminal/page.tsx +++ b/src/app/dashboard/terminal/page.tsx @@ -115,9 +115,11 @@ export default async function TerminalPage({
diff --git a/src/features/dashboard/terminal/dashboard-terminal-command-dialog.tsx b/src/features/dashboard/terminal/dashboard-terminal-command-dialog.tsx index 248c63c1c..a6d794bb8 100644 --- a/src/features/dashboard/terminal/dashboard-terminal-command-dialog.tsx +++ b/src/features/dashboard/terminal/dashboard-terminal-command-dialog.tsx @@ -39,18 +39,18 @@ export default function DashboardTerminalCommandDialog({ {launch ? (
- {launch.sandboxId ? ( + {launch.target?.sandboxId ? (

Sandbox

- {launch.sandboxId} + {launch.target.sandboxId}
) : null}

Template

- {launch.template} + {launch.target?.template ?? 'base'}
diff --git a/src/features/dashboard/terminal/dashboard-terminal.tsx b/src/features/dashboard/terminal/dashboard-terminal.tsx index 8e866f466..c16fa895d 100644 --- a/src/features/dashboard/terminal/dashboard-terminal.tsx +++ b/src/features/dashboard/terminal/dashboard-terminal.tsx @@ -21,31 +21,32 @@ import TerminalPanel from './terminal-panel' import type { PendingTerminalLaunch, StartTerminalOptions, + TerminalLaunchTarget, TerminalStatus, } from './types' import { useTerminalInstance } from './use-terminal-instance' interface DashboardTerminalProps { autoStart?: boolean - initialCommand?: string - initialSandboxId?: string - initialTemplate?: string + launchTarget?: TerminalLaunchTarget + onSandboxAttached?: (sandboxId: string) => void + onSandboxAttachFailed?: (target: TerminalLaunchTarget | undefined) => void sandboxScoped?: boolean teamId: string } export default function DashboardTerminal({ autoStart = false, - initialCommand = '', - initialSandboxId, - initialTemplate, + launchTarget, + onSandboxAttached, + onSandboxAttachFailed, sandboxScoped = false, teamId, }: 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) @@ -171,8 +172,9 @@ export default function DashboardTerminal({ const startTerminal = useCallback( async (options: StartTerminalOptions = {}) => { if (isStartingRef.current) return + const target = options.target const nextTemplate = resolveTerminalTemplateOverride( - options.template, + target?.template, template ) @@ -182,6 +184,7 @@ export default function DashboardTerminal({ return } + const requestedSandboxId = target?.sandboxId isStartingRef.current = true const startGeneration = startGenerationRef.current + 1 startGenerationRef.current = startGeneration @@ -194,7 +197,7 @@ export default function DashboardTerminal({ inputQueueRef.current = Promise.resolve() resetTerminal() setStatus('starting') - setActiveSandboxId(options.sandboxId) + setActiveSandboxId(requestedSandboxId) setTemplate(nextTemplate) appendOutput('Opening terminal...\r\n') @@ -202,11 +205,11 @@ export default function DashboardTerminal({ const { sandbox } = await openTerminalSandbox({ forceNewSandbox: options.forceNewSandbox, onStatus: appendOutput, - requestTimeoutMs: options.sandboxId + requestTimeoutMs: requestedSandboxId ? TERMINAL_ATTACH_ATTEMPT_TIMEOUT_MS : undefined, shouldStoreSession: !sandboxScoped, - sandboxId: options.sandboxId, + sandboxId: requestedSandboxId, teamId, template: nextTemplate, }) @@ -230,7 +233,7 @@ export default function DashboardTerminal({ return { pty, sandbox } } - const canRetryAttach = Boolean(options.sandboxId) + const canRetryAttach = Boolean(requestedSandboxId) try { const result = await attachTerminalWithRetry({ @@ -276,6 +279,7 @@ export default function DashboardTerminal({ setStatus('ready') appendOutput(`PTY ${pty.pid} attached.\r\n`) focusTerminal() + onSandboxAttached?.(sandbox.sandboxId) const pendingCommands = pendingCommandsRef.current pendingCommandsRef.current = [] @@ -286,6 +290,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' @@ -307,6 +312,8 @@ export default function DashboardTerminal({ sandboxScoped, teamId, template, + onSandboxAttached, + onSandboxAttachFailed, updateTerminalUrl, waitForAttachRetry, ] @@ -315,7 +322,7 @@ export default function DashboardTerminal({ const queueTerminalCommand = useCallback( (command: string, options: StartTerminalOptions = {}) => { const nextTemplate = resolveTerminalTemplateOverride( - options.template, + options.target?.template, template ) @@ -330,8 +337,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 } @@ -339,7 +348,10 @@ export default function DashboardTerminal({ if (status === 'idle' || status === 'error' || options.forceNewSandbox) { void startTerminal({ ...options, - template: nextTemplate, + target: { + ...options.target, + template: nextTemplate, + }, }) } }, @@ -349,11 +361,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' && @@ -363,7 +373,10 @@ export default function DashboardTerminal({ setPendingLaunch(null) runCommand(command) if (activeSandboxId) { - updateTerminalUrl({ clearCommand: true, sandboxId: activeSandboxId }) + updateTerminalUrl({ + clearCommand: true, + sandboxId: activeSandboxId, + }) } return } @@ -376,8 +389,7 @@ export default function DashboardTerminal({ pendingCommandsRef.current = [command] void startTerminal({ forceNewSandbox: !launchSandboxId && template !== launchTemplate, - sandboxId: launchSandboxId, - template: launchTemplate, + target: launchTarget, }) }, [ activeSandboxId, @@ -389,7 +401,14 @@ export default function DashboardTerminal({ updateTerminalUrl, ]) - const reconnectSandboxId = sandboxScoped ? initialSandboxId : activeSandboxId + const reconnectTarget = sandboxScoped + ? launchTarget + : activeSandboxId + ? { sandboxId: activeSandboxId, template } + : undefined + const reconnectSandboxId = sandboxScoped + ? launchTarget?.sandboxId + : activeSandboxId const restartLabel = sandboxScoped ? 'Reconnect terminal' : 'Start new terminal sandbox' @@ -400,12 +419,14 @@ export default function DashboardTerminal({ if (sandboxScoped) { if (!reconnectSandboxId) return - void startTerminal({ sandboxId: reconnectSandboxId }) + void startTerminal({ + target: reconnectTarget, + }) return } void startTerminal({ forceNewSandbox: true }) - }, [reconnectSandboxId, sandboxScoped, startTerminal]) + }, [reconnectTarget, reconnectSandboxId, sandboxScoped, startTerminal]) useEffect(() => { if (!autoStart || status !== 'idle' || isStartingRef.current) return @@ -413,23 +434,15 @@ export default function DashboardTerminal({ const autoStartTimer = window.setTimeout(() => { if (isStartingRef.current || ptyRef.current) return - queueTerminalCommand(initialCommand, { - sandboxId: initialSandboxId, - template: initialTemplate, + queueTerminalCommand(launchTarget?.command ?? '', { + target: launchTarget, }) }, TERMINAL_AUTOSTART_DEBOUNCE_MS) return () => { window.clearTimeout(autoStartTimer) } - }, [ - autoStart, - initialCommand, - initialSandboxId, - initialTemplate, - queueTerminalCommand, - status, - ]) + }, [autoStart, launchTarget, queueTerminalCommand, status]) useEffect(() => { return () => { diff --git a/src/features/dashboard/terminal/types.ts b/src/features/dashboard/terminal/types.ts index 4dd6b42df..8802682d1 100644 --- a/src/features/dashboard/terminal/types.ts +++ b/src/features/dashboard/terminal/types.ts @@ -7,12 +7,16 @@ 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 } From 3c962d0b3c22a3c410a521b37778f23d86afe4e0 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 27 May 2026 18:10:38 -0700 Subject: [PATCH 21/28] Resume paused sandbox inspect views for five minutes --- .../dashboard/sandbox/inspect/not-found.tsx | 183 +++++++++++++----- 1 file changed, 135 insertions(+), 48 deletions(-) diff --git a/src/features/dashboard/sandbox/inspect/not-found.tsx b/src/features/dashboard/sandbox/inspect/not-found.tsx index 05648b47d..58a5e6f9e 100644 --- a/src/features/dashboard/sandbox/inspect/not-found.tsx +++ b/src/features/dashboard/sandbox/inspect/not-found.tsx @@ -1,10 +1,14 @@ 'use client' +import Sandbox from 'e2b' import { useParams, useRouter } from 'next/navigation' +import type { ReactNode } from 'react' import { useCallback, useEffect, useState, useTransition } from 'react' -import { PROTECTED_URLS } from '@/configs/urls' +import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' -import { useSandboxInspectAnalytics } from '@/lib/hooks/use-analytics' +import { supabase } from '@/core/shared/clients/supabase/client' +import { useDashboard } from '@/features/dashboard/context' import { cn } from '@/lib/utils' import { Button } from '@/ui/primitives/button' import { @@ -12,21 +16,34 @@ import { ArrowUpIcon, HomeIcon, RefreshIcon, + RunningIcon, } from '@/ui/primitives/icons' import { useSandboxContext } from '../context' import SandboxInspectEmptyFrame from './empty' -export default function SandboxInspectNotFound() { +const SANDBOX_RESUME_TIMEOUT_MS = 5 * 60 * 1000 + +interface SandboxInspectNotFoundProps { + onResumeSandbox?: () => void + resource?: 'filesystem' | 'terminal' +} + +export default function SandboxInspectNotFound({ + onResumeSandbox, + resource = 'filesystem', +}: SandboxInspectNotFoundProps) { const router = useRouter() - const { isRunning } = useSandboxContext() + const { team } = useDashboard() + const { isRunning, sandboxInfo, refetchSandboxInfo } = useSandboxContext() const { teamSlug } = useParams() const [pendingPath, setPendingPath] = useState(undefined) + const [isResumePending, setIsResumePending] = useState(false) const [isPending, startTransition] = useTransition() const [isResetPending, resetTransition] = useTransition() - const save = async (newPath: string) => { + const save = useCallback(async (newPath: string) => { try { await fetch('/api/sandbox/inspect/root-path', { method: 'POST', @@ -42,7 +59,7 @@ export default function SandboxInspectNotFound() { `${error instanceof Error ? error.message : 'Failed to save root path'}` ) } - } + }, []) const setRootPath = useCallback( (newPath: string) => { @@ -52,73 +69,143 @@ export default function SandboxInspectNotFound() { router.refresh() }) }, - [router, startTransition] + [router, save] ) + const resumeSandbox = useCallback(async () => { + if (onResumeSandbox) { + onResumeSandbox() + return + } + + if (!sandboxInfo) return + + setIsResumePending(true) + try { + const { data } = await supabase.auth.getSession() + + if (!data.session) { + router.replace(AUTH_URLS.SIGN_IN) + return + } + + await Sandbox.connect(sandboxInfo.sandboxID, { + domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, + timeoutMs: SANDBOX_RESUME_TIMEOUT_MS, + headers: { + ...SUPABASE_AUTH_HEADERS(data.session.access_token, team.id), + }, + }) + + await refetchSandboxInfo() + } catch (error) { + l.error( + { + key: 'sandbox_inspect_not_found:resume_failed', + error: serializeErrorForLog(error), + sandbox_id: sandboxInfo.sandboxID, + }, + `${error instanceof Error ? error.message : 'Failed to resume sandbox'}` + ) + } finally { + setIsResumePending(false) + } + }, [onResumeSandbox, refetchSandboxInfo, router, sandboxInfo, team.id]) + useEffect(() => { if (!isPending) { setPendingPath(undefined) } }, [isPending]) + const isPaused = sandboxInfo?.state === 'paused' + const resourceName = resource === 'terminal' ? 'terminal' : 'filesystem' + const description = isRunning ? 'This directory appears to be empty or does not exist. You can reset to the default state, navigate to root, or refresh to try again.' - : 'It seems like the sandbox is not connected anymore. We cannot access the filesystem at this time.' + : isPaused + ? `Resume this sandbox to access the ${resourceName}.` + : `It seems like the sandbox is not connected anymore. We cannot access the ${resourceName} at this time.` - const actions = isRunning ? ( - <> -
- + let actions: ReactNode + + if (isRunning) { + actions = ( + <> +
+ + +
-
+ + ) + } else if (isPaused) { + actions = ( + + ) + } else { + actions = ( - - ) : ( - - ) + ) + } return ( From 2777f17af5294fab7191cf17f1de0e69403a8691 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 27 May 2026 18:10:42 -0700 Subject: [PATCH 22/28] Use sandbox context in terminal tab --- .../sandboxes/[sandboxId]/terminal/page.tsx | 10 +-- .../dashboard/sandbox/terminal/view.tsx | 67 ++++++++++++++++--- 2 files changed, 61 insertions(+), 16 deletions(-) diff --git a/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/terminal/page.tsx b/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/terminal/page.tsx index 374231387..39179e2db 100644 --- a/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/terminal/page.tsx +++ b/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/terminal/page.tsx @@ -1,11 +1,5 @@ import SandboxTerminalView from '@/features/dashboard/sandbox/terminal/view' -export default async function SandboxTerminalPage({ - params, -}: { - params: Promise<{ teamSlug: string; sandboxId: string }> -}) { - const { sandboxId } = await params - - return +export default function SandboxTerminalPage() { + return } diff --git a/src/features/dashboard/sandbox/terminal/view.tsx b/src/features/dashboard/sandbox/terminal/view.tsx index 8e6640d56..62014c8c1 100644 --- a/src/features/dashboard/sandbox/terminal/view.tsx +++ b/src/features/dashboard/sandbox/terminal/view.tsx @@ -1,25 +1,76 @@ 'use client' +import { type ReactNode, useState } from 'react' import { useDashboard } from '@/features/dashboard/context' +import LoadingLayout from '@/features/dashboard/loading-layout' import DashboardTerminal from '@/features/dashboard/terminal/dashboard-terminal' +import { useSandboxContext } from '../context' +import SandboxInspectNotFound from '../inspect/not-found' -interface SandboxTerminalViewProps { - sandboxId: string -} - -export default function SandboxTerminalView({ - sandboxId, -}: SandboxTerminalViewProps) { +export default function SandboxTerminalView() { const { team } = useDashboard() + const { + sandboxInfo, + isSandboxInfoLoading, + isSandboxNotFound, + refetchSandboxInfo, + } = useSandboxContext() + const [resumeRequestedSandboxId, setResumeRequestedSandboxId] = useState< + string | null + >(null) + + if (isSandboxInfoLoading && !sandboxInfo) { + return + } + + if (isSandboxNotFound || !sandboxInfo) { + return ( + + + + ) + } + + const shouldOpenTerminal = + sandboxInfo.state === 'running' || + resumeRequestedSandboxId === sandboxInfo.sandboxID + + if (!shouldOpenTerminal) { + return ( + + + setResumeRequestedSandboxId(sandboxInfo.sandboxID) + } + /> + + ) + } return (
void refetchSandboxInfo()} + onSandboxAttachFailed={() => void refetchSandboxInfo()} sandboxScoped teamId={team.id} />
) } + +function SandboxTerminalEmptyState({ children }: { children: ReactNode }) { + return ( +
+
+ {children} +
+
+ ) +} From d6f7003b0aa1fc92ea15089b813814fbd0341bb3 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 27 May 2026 19:29:12 -0700 Subject: [PATCH 23/28] Reset sandbox terminal resume after attach failure --- .../dashboard/sandbox/terminal/view.tsx | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/features/dashboard/sandbox/terminal/view.tsx b/src/features/dashboard/sandbox/terminal/view.tsx index 62014c8c1..59d49484b 100644 --- a/src/features/dashboard/sandbox/terminal/view.tsx +++ b/src/features/dashboard/sandbox/terminal/view.tsx @@ -1,6 +1,6 @@ 'use client' -import { type ReactNode, useState } from 'react' +import { type ReactNode, useCallback, useMemo, useState } from 'react' import { useDashboard } from '@/features/dashboard/context' import LoadingLayout from '@/features/dashboard/loading-layout' import DashboardTerminal from '@/features/dashboard/terminal/dashboard-terminal' @@ -19,6 +19,26 @@ export default function SandboxTerminalView() { string | null >(null) + const launchTarget = useMemo( + () => + sandboxInfo + ? { + sandboxId: sandboxInfo.sandboxID, + template: sandboxInfo.templateID, + } + : undefined, + [sandboxInfo] + ) + + const refetchSandbox = useCallback(() => { + void refetchSandboxInfo() + }, [refetchSandboxInfo]) + + const handleSandboxAttachFailed = useCallback(() => { + setResumeRequestedSandboxId(null) + void refetchSandboxInfo() + }, [refetchSandboxInfo]) + if (isSandboxInfoLoading && !sandboxInfo) { return } @@ -52,12 +72,9 @@ export default function SandboxTerminalView() {
void refetchSandboxInfo()} - onSandboxAttachFailed={() => void refetchSandboxInfo()} + launchTarget={launchTarget} + onSandboxAttached={refetchSandbox} + onSandboxAttachFailed={handleSandboxAttachFailed} sandboxScoped teamId={team.id} /> From 5ce2360cfc1a914feeaf2b2726f37290ec734e94 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 27 May 2026 19:29:53 -0700 Subject: [PATCH 24/28] Kill terminal PTYs on teardown --- .../api/sandbox/terminal/pty/kill/route.ts | 51 +++++++++++++++++++ .../dashboard/terminal/dashboard-terminal.tsx | 48 +++++++++++++---- 2 files changed, 90 insertions(+), 9 deletions(-) create mode 100644 src/app/api/sandbox/terminal/pty/kill/route.ts diff --git a/src/app/api/sandbox/terminal/pty/kill/route.ts b/src/app/api/sandbox/terminal/pty/kill/route.ts new file mode 100644 index 000000000..9c66a6c06 --- /dev/null +++ b/src/app/api/sandbox/terminal/pty/kill/route.ts @@ -0,0 +1,51 @@ +import 'server-cli-only' + +import Sandbox from 'e2b' +import { z } from 'zod' +import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { auth } from '@/core/server/auth' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' + +const BodySchema = z.object({ + pid: z.number().int().positive(), + sandboxId: z.string().min(1), + teamId: z.string().min(1), +}) + +export async function POST(request: Request) { + try { + const parsedBody = BodySchema.safeParse(await request.json()) + + if (!parsedBody.success) { + return Response.json({ error: 'Invalid request' }, { status: 400 }) + } + + const authContext = await auth.getAuthContext() + + if (!authContext) { + return Response.json({ error: 'Unauthenticated' }, { status: 401 }) + } + + const { pid, sandboxId, teamId } = parsedBody.data + const sandbox = await Sandbox.connect(sandboxId, { + domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, + headers: { + ...SUPABASE_AUTH_HEADERS(authContext.accessToken, teamId), + }, + }) + + await sandbox.pty.kill(pid) + + return Response.json({ ok: true }) + } catch (error) { + l.error( + { + key: 'terminal_pty_kill_route:unexpected_error', + error: serializeErrorForLog(error), + }, + `${error instanceof Error ? error.message : 'Failed to kill terminal PTY'}` + ) + + return Response.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/src/features/dashboard/terminal/dashboard-terminal.tsx b/src/features/dashboard/terminal/dashboard-terminal.tsx index c16fa895d..6f8dec8b1 100644 --- a/src/features/dashboard/terminal/dashboard-terminal.tsx +++ b/src/features/dashboard/terminal/dashboard-terminal.tsx @@ -83,6 +83,18 @@ export default function DashboardTerminal({ [] ) + const requestPtyKill = useCallback( + ({ pid, sandboxId }: { pid: number; sandboxId: string }) => { + void fetch('/api/sandbox/terminal/pty/kill', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pid, sandboxId, teamId }), + keepalive: true, + }) + }, + [teamId] + ) + const sendInputToPty = useCallback( (value: string | Uint8Array, terminalPid = pidRef.current) => { if (!value || !sandboxRef.current || !terminalPid) return @@ -116,20 +128,26 @@ export default function DashboardTerminal({ onResize: resizePty, }) - const disconnectTerminal = useCallback(async () => { + const closeTerminal = useCallback(async () => { clearAttachRetryTimer() const pty = ptyRef.current + const sandboxId = sandboxRef.current?.sandboxId ptyRef.current = null + pidRef.current = undefined inputQueueRef.current = Promise.resolve() if (!pty) return + if (sandboxId) { + requestPtyKill({ pid: pty.pid, sandboxId }) + } + try { - await pty.disconnect() + await pty.kill() } catch { - // Best-effort cleanup. The sandbox is intentionally left alive to pause. + // Best-effort cleanup. The sandbox is intentionally left alive. } - }, [clearAttachRetryTimer]) + }, [clearAttachRetryTimer, requestPtyKill]) const runCommand = useCallback( (command: string, terminalPid?: number) => { @@ -191,7 +209,7 @@ export default function DashboardTerminal({ const isCurrentStart = () => startGenerationRef.current === startGeneration - await disconnectTerminal() + await closeTerminal() sandboxRef.current = null pidRef.current = undefined inputQueueRef.current = Promise.resolve() @@ -259,7 +277,7 @@ export default function DashboardTerminal({ if (!isCurrentStart()) { try { - await pty.disconnect() + await pty.kill() } catch { // The start was superseded or unmounted; best-effort PTY cleanup. } @@ -304,7 +322,7 @@ export default function DashboardTerminal({ }, [ appendOutput, - disconnectTerminal, + closeTerminal, resizeTerminal, resetTerminal, focusTerminal, @@ -445,12 +463,24 @@ export default function DashboardTerminal({ }, [autoStart, launchTarget, queueTerminalCommand, status]) useEffect(() => { + const handlePageHide = () => { + const pty = ptyRef.current + const sandboxId = sandboxRef.current?.sandboxId + + if (!pty || !sandboxId) return + + requestPtyKill({ pid: pty.pid, sandboxId }) + } + + window.addEventListener('pagehide', handlePageHide) + return () => { + window.removeEventListener('pagehide', handlePageHide) startGenerationRef.current += 1 clearAttachRetryTimer() - void disconnectTerminal() + void closeTerminal() } - }, [clearAttachRetryTimer, disconnectTerminal]) + }, [clearAttachRetryTimer, closeTerminal, requestPtyKill]) return ( <> From 320df060144451dab71d5ae74b13687dbf309652 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 27 May 2026 19:30:25 -0700 Subject: [PATCH 25/28] Batch terminal input writes --- src/features/dashboard/terminal/constants.ts | 1 + .../dashboard/terminal/dashboard-terminal.tsx | 59 ++++++++++++++++--- 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/src/features/dashboard/terminal/constants.ts b/src/features/dashboard/terminal/constants.ts index 1855f68d5..739d1ff4d 100644 --- a/src/features/dashboard/terminal/constants.ts +++ b/src/features/dashboard/terminal/constants.ts @@ -10,3 +10,4 @@ export const TERMINAL_ATTACH_ATTEMPT_TIMEOUT_MS = 15_000 export const TERMINAL_ATTACH_MAX_RETRIES = 3 export const TERMINAL_ATTACH_RETRY_BASE_DELAY_MS = 1500 export const TERMINAL_ATTACH_RETRY_MAX_DELAY_MS = 5000 +export const TERMINAL_INPUT_FLUSH_DELAY_MS = 16 diff --git a/src/features/dashboard/terminal/dashboard-terminal.tsx b/src/features/dashboard/terminal/dashboard-terminal.tsx index 6f8dec8b1..28ebde7aa 100644 --- a/src/features/dashboard/terminal/dashboard-terminal.tsx +++ b/src/features/dashboard/terminal/dashboard-terminal.tsx @@ -10,6 +10,7 @@ import { TERMINAL_ATTACH_RETRY_BASE_DELAY_MS, TERMINAL_ATTACH_RETRY_MAX_DELAY_MS, TERMINAL_AUTOSTART_DEBOUNCE_MS, + TERMINAL_INPUT_FLUSH_DELAY_MS, } from './constants' import DashboardTerminalCommandDialog from './dashboard-terminal-command-dialog' import { openTerminalSandbox } from './sandbox-session' @@ -54,6 +55,8 @@ export default function DashboardTerminal({ const sandboxRef = useRef(null) const ptyRef = useRef(null) const pidRef = useRef(undefined) + const pendingInputRef = useRef([]) + const inputFlushTimerRef = useRef(null) const pendingCommandsRef = useRef([]) const inputQueueRef = useRef(Promise.resolve()) const isStartingRef = useRef(false) @@ -95,19 +98,59 @@ export default function DashboardTerminal({ [teamId] ) + const clearPendingInput = useCallback(() => { + if (inputFlushTimerRef.current) { + window.clearTimeout(inputFlushTimerRef.current) + inputFlushTimerRef.current = null + } + pendingInputRef.current = [] + }, []) + + const flushInputToPty = useCallback((terminalPid = pidRef.current) => { + inputFlushTimerRef.current = null + + 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 + inputQueueRef.current = inputQueueRef.current + .catch(() => undefined) + .then(() => sandbox.pty.sendInput(terminalPid, data)) + }, []) + 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) + }, TERMINAL_INPUT_FLUSH_DELAY_MS) }, - [] + [flushInputToPty] ) const resizePty = useCallback((size: { cols: number; rows: number }) => { @@ -130,6 +173,7 @@ export default function DashboardTerminal({ const closeTerminal = useCallback(async () => { clearAttachRetryTimer() + clearPendingInput() const pty = ptyRef.current const sandboxId = sandboxRef.current?.sandboxId @@ -147,7 +191,7 @@ export default function DashboardTerminal({ } catch { // Best-effort cleanup. The sandbox is intentionally left alive. } - }, [clearAttachRetryTimer, requestPtyKill]) + }, [clearAttachRetryTimer, clearPendingInput, requestPtyKill]) const runCommand = useCallback( (command: string, terminalPid?: number) => { @@ -478,9 +522,10 @@ export default function DashboardTerminal({ window.removeEventListener('pagehide', handlePageHide) startGenerationRef.current += 1 clearAttachRetryTimer() + clearPendingInput() void closeTerminal() } - }, [clearAttachRetryTimer, closeTerminal, requestPtyKill]) + }, [clearAttachRetryTimer, clearPendingInput, closeTerminal, requestPtyKill]) return ( <> From 3f4a7f72c75db74f498e8c64e11ba6de1c276c96 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 27 May 2026 20:14:08 -0700 Subject: [PATCH 26/28] Use terminal copy for terminal empty state --- .../dashboard/sandbox/inspect/not-found.tsx | 46 +++++++++++++++---- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/src/features/dashboard/sandbox/inspect/not-found.tsx b/src/features/dashboard/sandbox/inspect/not-found.tsx index 58a5e6f9e..0a6d320ab 100644 --- a/src/features/dashboard/sandbox/inspect/not-found.tsx +++ b/src/features/dashboard/sandbox/inspect/not-found.tsx @@ -120,16 +120,20 @@ export default function SandboxInspectNotFound({ const isPaused = sandboxInfo?.state === 'paused' const resourceName = resource === 'terminal' ? 'terminal' : 'filesystem' + const isFilesystem = resource === 'filesystem' - const description = isRunning - ? 'This directory appears to be empty or does not exist. You can reset to the default state, navigate to root, or refresh to try again.' - : isPaused - ? `Resume this sandbox to access the ${resourceName}.` - : `It seems like the sandbox is not connected anymore. We cannot access the ${resourceName} at this time.` + const description = + isRunning && isFilesystem + ? 'This directory appears to be empty or does not exist. You can reset to the default state, navigate to root, or refresh to try again.' + : isRunning + ? 'The terminal is unavailable right now. Refresh to try again.' + : isPaused + ? `Resume this sandbox to access the ${resourceName}.` + : `It seems like the sandbox is not connected anymore. We cannot access the ${resourceName} at this time.` let actions: ReactNode - if (isRunning) { + if (isRunning && isFilesystem) { actions = ( <>
@@ -171,6 +175,26 @@ export default function SandboxInspectNotFound({ ) + } else if (isRunning) { + actions = ( + + ) } else if (isPaused) { actions = (
- + /> ) - } else if (isRunning) { - actions = ( - + /> ) - } else if (isPaused) { - actions = ( + } + + if (isPaused) { + return ( ) - } else { - actions = ( - - ) } return ( - + + ) +} + +function SandboxInspectRefreshButton({ + isResetPending, + onRefresh, +}: { + isResetPending: boolean + onRefresh: () => void +}) { + return ( + ) } diff --git a/src/features/dashboard/sandbox/terminal/view.tsx b/src/features/dashboard/sandbox/terminal/view.tsx index 59d49484b..90f95a156 100644 --- a/src/features/dashboard/sandbox/terminal/view.tsx +++ b/src/features/dashboard/sandbox/terminal/view.tsx @@ -1,6 +1,6 @@ 'use client' -import { type ReactNode, useCallback, useMemo, useState } from 'react' +import { type ReactNode, useCallback, useMemo } from 'react' import { useDashboard } from '@/features/dashboard/context' import LoadingLayout from '@/features/dashboard/loading-layout' import DashboardTerminal from '@/features/dashboard/terminal/dashboard-terminal' @@ -10,34 +10,28 @@ import SandboxInspectNotFound from '../inspect/not-found' export default function SandboxTerminalView() { const { team } = useDashboard() const { + getSandbox, sandboxInfo, isSandboxInfoLoading, isSandboxNotFound, refetchSandboxInfo, } = useSandboxContext() - const [resumeRequestedSandboxId, setResumeRequestedSandboxId] = useState< - string | null - >(null) - + const sandboxTemplateId = sandboxInfo?.templateID const launchTarget = useMemo( () => - sandboxInfo + sandboxTemplateId ? { - sandboxId: sandboxInfo.sandboxID, - template: sandboxInfo.templateID, + template: sandboxTemplateId, } : undefined, - [sandboxInfo] + [sandboxTemplateId] ) const refetchSandbox = useCallback(() => { void refetchSandboxInfo() }, [refetchSandboxInfo]) - const handleSandboxAttachFailed = useCallback(() => { - setResumeRequestedSandboxId(null) - void refetchSandboxInfo() - }, [refetchSandboxInfo]) + const handleSandboxAttachFailed = refetchSandbox if (isSandboxInfoLoading && !sandboxInfo) { return @@ -51,19 +45,10 @@ export default function SandboxTerminalView() { ) } - const shouldOpenTerminal = - sandboxInfo.state === 'running' || - resumeRequestedSandboxId === sandboxInfo.sandboxID - - if (!shouldOpenTerminal) { + if (sandboxInfo.state !== 'running') { return ( - - setResumeRequestedSandboxId(sandboxInfo.sandboxID) - } - /> + ) } @@ -72,11 +57,13 @@ export default function SandboxTerminalView() {
) diff --git a/src/features/dashboard/terminal/dashboard-terminal.tsx b/src/features/dashboard/terminal/dashboard-terminal.tsx index 28ebde7aa..d30c53cdf 100644 --- a/src/features/dashboard/terminal/dashboard-terminal.tsx +++ b/src/features/dashboard/terminal/dashboard-terminal.tsx @@ -23,26 +23,31 @@ import type { PendingTerminalLaunch, StartTerminalOptions, TerminalLaunchTarget, + TerminalSandboxResolver, TerminalStatus, } from './types' import { useTerminalInstance } from './use-terminal-instance' interface DashboardTerminalProps { autoStart?: boolean + 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, + getSandbox, launchTarget, onSandboxAttached, onSandboxAttachFailed, sandboxScoped = false, teamId, + teamSlug, }: DashboardTerminalProps) { const [status, setStatus] = useState('idle') const [activeSandboxId, setActiveSandboxId] = useState() @@ -88,14 +93,22 @@ export default function DashboardTerminal({ const requestPtyKill = useCallback( ({ pid, sandboxId }: { pid: number; sandboxId: string }) => { - void fetch('/api/sandbox/terminal/pty/kill', { + void fetch('/api/trpc/sandbox.killTerminalPty?batch=1', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ pid, sandboxId, teamId }), + body: JSON.stringify({ + 0: { + json: { + pid, + sandboxId, + teamSlug, + }, + }, + }), keepalive: true, }) }, - [teamId] + [teamSlug] ) const clearPendingInput = useCallback(() => { @@ -256,7 +269,6 @@ export default function DashboardTerminal({ await closeTerminal() sandboxRef.current = null pidRef.current = undefined - inputQueueRef.current = Promise.resolve() resetTerminal() setStatus('starting') setActiveSandboxId(requestedSandboxId) @@ -264,17 +276,25 @@ export default function DashboardTerminal({ appendOutput('Opening terminal...\r\n') const openSandboxAndPty = async () => { - const { sandbox } = await openTerminalSandbox({ - forceNewSandbox: options.forceNewSandbox, - onStatus: appendOutput, - requestTimeoutMs: requestedSandboxId - ? TERMINAL_ATTACH_ATTEMPT_TIMEOUT_MS - : undefined, - shouldStoreSession: !sandboxScoped, - sandboxId: requestedSandboxId, - teamId, - template: nextTemplate, - }) + 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 null @@ -295,7 +315,7 @@ export default function DashboardTerminal({ return { pty, sandbox } } - const canRetryAttach = Boolean(requestedSandboxId) + const canRetryAttach = Boolean(requestedSandboxId || getSandbox) try { const result = await attachTerminalWithRetry({ @@ -370,6 +390,7 @@ export default function DashboardTerminal({ resizeTerminal, resetTerminal, focusTerminal, + getSandbox, runCommand, sandboxScoped, teamId, @@ -475,11 +496,12 @@ export default function DashboardTerminal({ ? 'Reconnect terminal' : 'Start new terminal sandbox' const restartDisabled = - status === 'starting' || (sandboxScoped && !reconnectSandboxId) + status === 'starting' || + (sandboxScoped && !reconnectSandboxId && !getSandbox) const restartTerminal = useCallback(() => { if (sandboxScoped) { - if (!reconnectSandboxId) return + if (!reconnectSandboxId && !getSandbox) return void startTerminal({ target: reconnectTarget, @@ -488,7 +510,13 @@ export default function DashboardTerminal({ } void startTerminal({ forceNewSandbox: true }) - }, [reconnectTarget, reconnectSandboxId, sandboxScoped, startTerminal]) + }, [ + getSandbox, + reconnectTarget, + reconnectSandboxId, + sandboxScoped, + startTerminal, + ]) useEffect(() => { if (!autoStart || status !== 'idle' || isStartingRef.current) return diff --git a/src/features/dashboard/terminal/types.ts b/src/features/dashboard/terminal/types.ts index 8802682d1..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 = { @@ -20,3 +22,5 @@ export type TerminalLaunchTarget = { sandboxId?: string template?: string } + +export type TerminalSandboxResolver = () => Promise From 8d9f1dd854a1ba4c107964cb54b7fc3a4c8310e0 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 27 May 2026 22:22:23 -0700 Subject: [PATCH 28/28] Batch terminal input and split panel header --- src/features/dashboard/terminal/constants.ts | 1 - .../dashboard/terminal/dashboard-terminal.tsx | 29 +++++++-- .../dashboard/terminal/terminal-panel.tsx | 62 +++++++++++++------ 3 files changed, 67 insertions(+), 25 deletions(-) diff --git a/src/features/dashboard/terminal/constants.ts b/src/features/dashboard/terminal/constants.ts index 739d1ff4d..1855f68d5 100644 --- a/src/features/dashboard/terminal/constants.ts +++ b/src/features/dashboard/terminal/constants.ts @@ -10,4 +10,3 @@ export const TERMINAL_ATTACH_ATTEMPT_TIMEOUT_MS = 15_000 export const TERMINAL_ATTACH_MAX_RETRIES = 3 export const TERMINAL_ATTACH_RETRY_BASE_DELAY_MS = 1500 export const TERMINAL_ATTACH_RETRY_MAX_DELAY_MS = 5000 -export const TERMINAL_INPUT_FLUSH_DELAY_MS = 16 diff --git a/src/features/dashboard/terminal/dashboard-terminal.tsx b/src/features/dashboard/terminal/dashboard-terminal.tsx index d30c53cdf..3f2b2207c 100644 --- a/src/features/dashboard/terminal/dashboard-terminal.tsx +++ b/src/features/dashboard/terminal/dashboard-terminal.tsx @@ -10,7 +10,6 @@ import { TERMINAL_ATTACH_RETRY_BASE_DELAY_MS, TERMINAL_ATTACH_RETRY_MAX_DELAY_MS, TERMINAL_AUTOSTART_DEBOUNCE_MS, - TERMINAL_INPUT_FLUSH_DELAY_MS, } from './constants' import DashboardTerminalCommandDialog from './dashboard-terminal-command-dialog' import { openTerminalSandbox } from './sandbox-session' @@ -28,6 +27,8 @@ import type { } from './types' import { useTerminalInstance } from './use-terminal-instance' +const FLUSH_INPUT_INTERVAL_MS = 10 + interface DashboardTerminalProps { autoStart?: boolean getSandbox?: TerminalSandboxResolver @@ -62,8 +63,9 @@ export default function DashboardTerminal({ const pidRef = useRef(undefined) const pendingInputRef = useRef([]) const inputFlushTimerRef = useRef(null) + const inputFlushInFlightRef = useRef(false) + const inputGenerationRef = useRef(0) const pendingCommandsRef = useRef([]) - const inputQueueRef = useRef(Promise.resolve()) const isStartingRef = useRef(false) const retryResolveRef = useRef<(() => void) | null>(null) const retryTimerRef = useRef(null) @@ -116,12 +118,16 @@ export default function DashboardTerminal({ window.clearTimeout(inputFlushTimerRef.current) inputFlushTimerRef.current = null } + inputGenerationRef.current += 1 + inputFlushInFlightRef.current = false pendingInputRef.current = [] }, []) const flushInputToPty = useCallback((terminalPid = pidRef.current) => { inputFlushTimerRef.current = null + if (inputFlushInFlightRef.current) return + if (!sandboxRef.current || !terminalPid) { pendingInputRef.current = [] return @@ -143,9 +149,21 @@ export default function DashboardTerminal({ } const sandbox = sandboxRef.current - inputQueueRef.current = inputQueueRef.current + const inputGeneration = inputGenerationRef.current + inputFlushInFlightRef.current = true + + void sandbox.pty + .sendInput(terminalPid, data) .catch(() => undefined) - .then(() => sandbox.pty.sendInput(terminalPid, data)) + .finally(() => { + if (inputGenerationRef.current !== inputGeneration) return + + inputFlushInFlightRef.current = false + + if (pidRef.current === terminalPid && pendingInputRef.current.length) { + flushInputToPty(terminalPid) + } + }) }, []) const sendInputToPty = useCallback( @@ -161,7 +179,7 @@ export default function DashboardTerminal({ inputFlushTimerRef.current = window.setTimeout(() => { flushInputToPty(terminalPid) - }, TERMINAL_INPUT_FLUSH_DELAY_MS) + }, FLUSH_INPUT_INTERVAL_MS) }, [flushInputToPty] ) @@ -192,7 +210,6 @@ export default function DashboardTerminal({ const sandboxId = sandboxRef.current?.sandboxId ptyRef.current = null pidRef.current = undefined - inputQueueRef.current = Promise.resolve() if (!pty) return if (sandboxId) { diff --git a/src/features/dashboard/terminal/terminal-panel.tsx b/src/features/dashboard/terminal/terminal-panel.tsx index cc808f550..5e3050a2e 100644 --- a/src/features/dashboard/terminal/terminal-panel.tsx +++ b/src/features/dashboard/terminal/terminal-panel.tsx @@ -28,7 +28,50 @@ export default function TerminalPanel({ onCopyTerminalText, onRestartTerminal, }: TerminalPanelProps) { - const header = ( + return ( + + } + > +
+ + ) +} + +function TerminalPanelHeader({ + sandboxId, + template, + restartDisabled, + restartLabel, + onCopyTerminalText, + onRestartTerminal, +}: Pick< + TerminalPanelProps, + | 'sandboxId' + | 'template' + | 'restartDisabled' + | 'restartLabel' + | 'onCopyTerminalText' + | 'onRestartTerminal' +>) { + return (
@@ -73,21 +116,4 @@ export default function TerminalPanel({
) - - return ( - -
- - ) }