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' 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..39179e2db --- /dev/null +++ b/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/terminal/page.tsx @@ -0,0 +1,5 @@ +import SandboxTerminalView from '@/features/dashboard/sandbox/terminal/view' + +export default function SandboxTerminalPage() { + return +} 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/app/dashboard/terminal/page.tsx b/src/app/dashboard/terminal/page.tsx index a5454a625..f7c7b5124 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 @@ -107,10 +115,13 @@ export default async function TerminalPage({
) 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/core/server/api/routers/sandbox.ts b/src/core/server/api/routers/sandbox.ts index 948367c59..bd622310a 100644 --- a/src/core/server/api/routers/sandbox.ts +++ b/src/core/server/api/routers/sandbox.ts @@ -1,5 +1,7 @@ import { millisecondsInDay } from 'date-fns/constants' +import { Sandbox } from 'e2b' import { z } from 'zod' +import { SUPABASE_AUTH_HEADERS } from '@/configs/api' import { deriveSandboxLifecycleFromEvents, mapApiSandboxRecordToModel, @@ -205,4 +207,22 @@ export const sandboxRouter = createTRPCRouter({ }), // MUTATIONS + + killTerminalPty: protectedTeamProcedure + .input( + z.object({ + sandboxId: SandboxIdSchema, + pid: z.number().int().positive(), + }) + ) + .mutation(async ({ ctx, input }) => { + const sandbox = await Sandbox.connect(input.sandboxId, { + domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, + headers: { + ...SUPABASE_AUTH_HEADERS(ctx.session.access_token, ctx.teamId), + }, + }) + + return sandbox.pty.kill(input.pid) + }), }) diff --git a/src/features/dashboard/sandbox/context.tsx b/src/features/dashboard/sandbox/context.tsx index 1ccf8794a..f5f2beb5f 100644 --- a/src/features/dashboard/sandbox/context.tsx +++ b/src/features/dashboard/sandbox/context.tsx @@ -1,19 +1,41 @@ 'use client' import { useQuery } from '@tanstack/react-query' +import Sandbox from 'e2b' +import { useRouter } from 'next/navigation' import type { ReactNode } from 'react' -import { createContext, useCallback, useContext, useMemo } from 'react' +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { SUPABASE_AUTH_HEADERS } from '@/configs/api' import { SANDBOXES_METRICS_POLLING_MS } from '@/configs/intervals' +import { AUTH_URLS } from '@/configs/urls' import type { SandboxDetailsModel, SandboxEventModel, } from '@/core/modules/sandboxes/models' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' +import { supabase } from '@/core/shared/clients/supabase/client' +import { useDashboard } from '@/features/dashboard/context' import { useAlignedRefetchInterval } from '@/lib/hooks/use-aligned-refetch-interval' import { useRouteParams } from '@/lib/hooks/use-route-params' import { isNotFoundError } from '@/lib/utils/trpc-errors' import { useTRPC } from '@/trpc/client' import { SANDBOX_LIFECYCLE_EVENT_KILLED } from './monitoring/utils/constants' +const SANDBOX_RESUME_TIMEOUT_MS = 5 * 60 * 1000 + +interface GetSandboxOptions { + requestTimeoutMs?: number + timeoutMs?: number +} + export interface SandboxLifecycleState { createdAt: string | null pausedAt: string | null @@ -28,7 +50,10 @@ interface SandboxContextValue { isSandboxNotFound: boolean isSandboxInfoLoading: boolean + isSandboxResumePending: boolean + getSandbox: () => Promise refetchSandboxInfo: () => Promise + resumeSandbox: () => Promise } const SandboxContext = createContext(null) @@ -68,8 +93,15 @@ function buildSandboxLifecycle( } export function SandboxProvider({ children }: SandboxProviderProps) { + const router = useRouter() + const { team } = useDashboard() const { teamSlug, sandboxId } = useRouteParams<'/dashboard/[teamSlug]/sandboxes/[sandboxId]'>() + const [isSandboxResumePending, setIsSandboxResumePending] = useState(false) + const sandboxRef = useRef(null) + const sandboxPromiseRef = useRef | null>(null) + const sandboxConnectionKey = `${team.id}:${sandboxId}` + const sandboxConnectionKeyRef = useRef(sandboxConnectionKey) const trpc = useTRPC() const getAlignedRefetchInterval = useAlignedRefetchInterval({ @@ -114,11 +146,102 @@ export function SandboxProvider({ children }: SandboxProviderProps) { ) ) + const sandboxState = sandboxInfoData?.state + const refetchSandboxInfo = useCallback(async () => { await refetch() }, [refetch]) - const sandboxState = sandboxInfoData?.state + useEffect(() => { + sandboxConnectionKeyRef.current = sandboxConnectionKey + sandboxRef.current = null + sandboxPromiseRef.current = null + }, [sandboxConnectionKey]) + + useEffect(() => { + if (sandboxState === 'running') return + + sandboxRef.current = null + sandboxPromiseRef.current = null + }, [sandboxState]) + + const connectSandbox = useCallback( + async (options: GetSandboxOptions = {}) => { + const { data } = await supabase.auth.getSession() + + if (!data.session) { + router.replace(AUTH_URLS.SIGN_IN) + throw new Error('You need to sign in before connecting to sandbox.') + } + + const connectionKey = sandboxConnectionKey + + const sandbox = await Sandbox.connect(sandboxId, { + domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, + timeoutMs: options.timeoutMs, + requestTimeoutMs: options.requestTimeoutMs, + headers: { + ...SUPABASE_AUTH_HEADERS(data.session.access_token, team.id), + }, + }) + + if (sandboxConnectionKeyRef.current !== connectionKey) { + throw new Error('Sandbox connection was superseded.') + } + + return sandbox + }, + [router, sandboxConnectionKey, sandboxId, team.id] + ) + + const getSandbox = useCallback(async () => { + if (sandboxRef.current) { + return sandboxRef.current + } + + if (!sandboxPromiseRef.current) { + sandboxPromiseRef.current = connectSandbox({ + // Keep page-scoped connections from extending sandbox TTL via SDK default connect timeout. + timeoutMs: 1_000, + }) + .then((sandbox) => { + sandboxRef.current = sandbox + return sandbox + }) + .finally(() => { + sandboxPromiseRef.current = null + }) + } + + return sandboxPromiseRef.current + }, [connectSandbox]) + + const resumeSandbox = useCallback(async () => { + setIsSandboxResumePending(true) + try { + sandboxRef.current = null + sandboxPromiseRef.current = null + + const sandbox = await connectSandbox({ + timeoutMs: SANDBOX_RESUME_TIMEOUT_MS, + }) + sandboxRef.current = sandbox + + await refetch() + } catch (error) { + l.error( + { + key: 'sandbox_context:resume_failed', + error: serializeErrorForLog(error), + sandbox_id: sandboxId, + }, + `${error instanceof Error ? error.message : 'Failed to resume sandbox'}` + ) + } finally { + setIsSandboxResumePending(false) + } + }, [connectSandbox, refetch, sandboxId]) + const isRunning = sandboxState === 'running' const isSandboxNotFound = @@ -138,7 +261,10 @@ export function SandboxProvider({ children }: SandboxProviderProps) { isRunning, isSandboxNotFound, isSandboxInfoLoading: isSandboxInfoPending, + isSandboxResumePending, + getSandbox, refetchSandboxInfo, + resumeSandbox, }} > {children} diff --git a/src/features/dashboard/sandbox/inspect/context.tsx b/src/features/dashboard/sandbox/inspect/context.tsx index e15acc0d7..1d228203b 100644 --- a/src/features/dashboard/sandbox/inspect/context.tsx +++ b/src/features/dashboard/sandbox/inspect/context.tsx @@ -1,7 +1,5 @@ 'use client' -import Sandbox from 'e2b' -import { useRouter } from 'next/navigation' import type { ReactNode } from 'react' import { createContext, @@ -11,9 +9,6 @@ import { useMemo, useRef, } from 'react' -import { SUPABASE_AUTH_HEADERS } from '@/configs/api' -import { AUTH_URLS } from '@/configs/urls' -import { supabase } from '@/core/shared/clients/supabase/client' import { useSandboxInspectAnalytics } from '@/lib/hooks/use-analytics' import { getParentPath, normalizePath } from '@/lib/utils/filesystem' import { useDashboard } from '../../context' @@ -43,11 +38,11 @@ export default function SandboxInspectProvider({ const { team } = useDashboard() const teamId = team.id - const { sandboxInfo, isRunning } = useSandboxContext() + const { getSandbox, sandboxInfo, isRunning } = useSandboxContext() const storeRef = useRef(null) const sandboxManagerRef = useRef(null) + const connectGenerationRef = useRef(0) - const router = useRouter() const { trackInteraction } = useSandboxInspectAnalytics() // ---------- synchronous store initialisation ---------- @@ -66,6 +61,7 @@ export default function SandboxInspectProvider({ // stop previous watcher (if any) if (sandboxManagerRef.current) { + connectGenerationRef.current += 1 sandboxManagerRef.current.stopWatching() sandboxManagerRef.current = null } @@ -175,33 +171,27 @@ export default function SandboxInspectProvider({ const connectSandbox = useCallback(async () => { if (!storeRef.current || !sandboxInfo || !teamId) return + const generation = connectGenerationRef.current + 1 + connectGenerationRef.current = generation + const store = storeRef.current + const sandboxId = sandboxInfo.sandboxID // (re)create the sandbox-manager when sandbox / team / root changes if (sandboxManagerRef.current) { sandboxManagerRef.current.stopWatching() } - const { data } = await supabase.auth.getSession() + const sandbox = await getSandbox() - if (!data || !data.session) { - router.replace(AUTH_URLS.SIGN_IN) + if ( + connectGenerationRef.current !== generation || + storeRef.current !== store || + sandboxInfo.sandboxID !== sandboxId + ) { return } - const sandbox = await Sandbox.connect(sandboxInfo.sandboxID, { - domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, - // Keep inspect connections from extending sandbox TTL via SDK default connect timeout. - timeoutMs: 1_000, - headers: { - ...SUPABASE_AUTH_HEADERS(data.session.access_token, teamId), - }, - }) - - sandboxManagerRef.current = new SandboxManager( - storeRef.current, - sandbox, - rootPath - ) + sandboxManagerRef.current = new SandboxManager(store, sandbox, rootPath) await sandboxManagerRef.current.loadDirectory(rootPath) trackInteraction('started_watching', { @@ -209,7 +199,7 @@ export default function SandboxInspectProvider({ team_id: teamId, root_path: rootPath, }) - }, [sandboxInfo, teamId, rootPath, trackInteraction, router]) + }, [getSandbox, sandboxInfo, teamId, rootPath, trackInteraction]) // handle sandbox connection / disconnection useEffect(() => { @@ -220,7 +210,9 @@ export default function SandboxInspectProvider({ return } + connectGenerationRef.current += 1 sandboxManagerRef.current?.stopWatching() + sandboxManagerRef.current = null trackInteraction('stopped_watching', { sandbox_id: sandboxInfo?.sandboxID, diff --git a/src/features/dashboard/sandbox/inspect/filesystem.tsx b/src/features/dashboard/sandbox/inspect/filesystem.tsx index 9ec6fee7b..19b170135 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 { 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/not-found.tsx b/src/features/dashboard/sandbox/inspect/not-found.tsx index 05648b47d..6fc93f1d4 100644 --- a/src/features/dashboard/sandbox/inspect/not-found.tsx +++ b/src/features/dashboard/sandbox/inspect/not-found.tsx @@ -4,7 +4,6 @@ import { useParams, useRouter } from 'next/navigation' import { useCallback, useEffect, useState, useTransition } from 'react' import { PROTECTED_URLS } from '@/configs/urls' import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' -import { useSandboxInspectAnalytics } from '@/lib/hooks/use-analytics' import { cn } from '@/lib/utils' import { Button } from '@/ui/primitives/button' import { @@ -12,21 +11,27 @@ import { ArrowUpIcon, HomeIcon, RefreshIcon, + RunningIcon, } from '@/ui/primitives/icons' import { useSandboxContext } from '../context' import SandboxInspectEmptyFrame from './empty' -export default function SandboxInspectNotFound() { +interface SandboxInspectNotFoundProps { + resource?: 'filesystem' | 'terminal' +} + +export default function SandboxInspectNotFound({ + resource = 'filesystem', +}: SandboxInspectNotFoundProps) { const router = useRouter() - const { isRunning } = useSandboxContext() + const { isRunning, sandboxInfo } = useSandboxContext() const { teamSlug } = useParams() const [pendingPath, setPendingPath] = useState(undefined) 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 +47,7 @@ export default function SandboxInspectNotFound() { `${error instanceof Error ? error.message : 'Failed to save root path'}` ) } - } + }, []) const setRootPath = useCallback( (newPath: string) => { @@ -52,7 +57,7 @@ export default function SandboxInspectNotFound() { router.refresh() }) }, - [router, startTransition] + [router, save] ) useEffect(() => { @@ -61,66 +66,160 @@ export default function SandboxInspectNotFound() { } }, [isPending]) - 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.' - - const actions = isRunning ? ( - <> -
- - -
- + + + + resetTransition(async () => { + router.refresh() + }) + } + /> + + ) + } + + if (isRunning) { + return ( + resetTransition(async () => { router.refresh() }) } + /> + ) + } + + if (isPaused) { + return ( + - - ) : ( + ) + } + + return ( ) +} +function SandboxInspectRefreshButton({ + isResetPending, + onRefresh, +}: { + isResetPending: boolean + onRefresh: () => void +}) { return ( - + ) } diff --git a/src/features/dashboard/sandbox/inspect/viewer.tsx b/src/features/dashboard/sandbox/inspect/viewer.tsx index 559af2774..729cb44ea 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 { 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' 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..90f95a156 --- /dev/null +++ b/src/features/dashboard/sandbox/terminal/view.tsx @@ -0,0 +1,80 @@ +'use client' + +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' +import { useSandboxContext } from '../context' +import SandboxInspectNotFound from '../inspect/not-found' + +export default function SandboxTerminalView() { + const { team } = useDashboard() + const { + getSandbox, + sandboxInfo, + isSandboxInfoLoading, + isSandboxNotFound, + refetchSandboxInfo, + } = useSandboxContext() + const sandboxTemplateId = sandboxInfo?.templateID + const launchTarget = useMemo( + () => + sandboxTemplateId + ? { + template: sandboxTemplateId, + } + : undefined, + [sandboxTemplateId] + ) + + const refetchSandbox = useCallback(() => { + void refetchSandboxInfo() + }, [refetchSandboxInfo]) + + const handleSandboxAttachFailed = refetchSandbox + + if (isSandboxInfoLoading && !sandboxInfo) { + return + } + + if (isSandboxNotFound || !sandboxInfo) { + return ( + + + + ) + } + + if (sandboxInfo.state !== 'running') { + return ( + + + + ) + } + + return ( +
+ +
+ ) +} + +function SandboxTerminalEmptyState({ children }: { children: ReactNode }) { + return ( +
+
+ {children} +
+
+ ) +} diff --git a/src/features/dashboard/shared/index.ts b/src/features/dashboard/shared/index.ts index 9722c392c..1640efc39 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 { SandboxInspectFrame } from './panel-frame' export { UserAvatar } from './user-avatar' diff --git a/src/features/dashboard/sandbox/inspect/frame.tsx b/src/features/dashboard/shared/panel-frame.tsx similarity index 94% rename from src/features/dashboard/sandbox/inspect/frame.tsx rename to src/features/dashboard/shared/panel-frame.tsx index b6b560fdb..6102e23a5 100644 --- a/src/features/dashboard/sandbox/inspect/frame.tsx +++ b/src/features/dashboard/shared/panel-frame.tsx @@ -12,7 +12,7 @@ type SandboxInspectFrameProps = React.ComponentProps & { } } -export default function SandboxInspectFrame({ +export function SandboxInspectFrame({ className, classNames, children, diff --git a/src/features/dashboard/terminal/attach-terminal.ts b/src/features/dashboard/terminal/attach-terminal.ts new file mode 100644 index 000000000..26f3bfcea --- /dev/null +++ b/src/features/dashboard/terminal/attach-terminal.ts @@ -0,0 +1,62 @@ +interface AttachTerminalWithRetryOptions { + canRetry: boolean + isCurrent: () => boolean + isRetryableError: (error: unknown) => boolean + maxRetries: number + onRetry: (delayMs: number) => void + open: () => Promise + retryBaseDelayMs: number + retryMaxDelayMs: number + waitForRetry: (delayMs: number) => Promise +} + +export async function attachTerminalWithRetry({ + canRetry, + isCurrent, + isRetryableError, + maxRetries, + onRetry, + open, + retryBaseDelayMs, + retryMaxDelayMs, + waitForRetry, +}: AttachTerminalWithRetryOptions) { + for (let attempt = 0; attempt <= maxRetries; attempt += 1) { + try { + return await open() + } catch (error) { + if ( + !canRetry || + attempt >= maxRetries || + !isCurrent() || + !isRetryableError(error) + ) { + throw error + } + + 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 514d3ac8c..1855f68d5 100644 --- a/src/features/dashboard/terminal/constants.ts +++ b/src/features/dashboard/terminal/constants.ts @@ -5,3 +5,8 @@ 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_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-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 3269dc675..3f2b2207c 100644 --- a/src/features/dashboard/terminal/dashboard-terminal.tsx +++ b/src/features/dashboard/terminal/dashboard-terminal.tsx @@ -1,14 +1,15 @@ 'use client' -import { Terminal as XTerm } from '@xterm/xterm' -import type Sandbox from 'e2b' -import type { CommandHandle } from 'e2b' +import { type CommandHandle, type Sandbox, TimeoutError } from 'e2b' import { useCallback, useEffect, useRef, useState } from 'react' +import { attachTerminalWithRetry } from './attach-terminal' import { - DEFAULT_COLS, DEFAULT_CWD, - DEFAULT_ROWS, - MAX_TERMINAL_TRANSCRIPT_CHARS, + TERMINAL_ATTACH_ATTEMPT_TIMEOUT_MS, + TERMINAL_ATTACH_MAX_RETRIES, + TERMINAL_ATTACH_RETRY_BASE_DELAY_MS, + TERMINAL_ATTACH_RETRY_MAX_DELAY_MS, + TERMINAL_AUTOSTART_DEBOUNCE_MS, } from './constants' import DashboardTerminalCommandDialog from './dashboard-terminal-command-dialog' import { openTerminalSandbox } from './sandbox-session' @@ -17,41 +18,42 @@ import { resolveTerminalTemplateOverride, } from './template' import TerminalPanel from './terminal-panel' -import { calculateTerminalSize } from './terminal-size' import type { PendingTerminalLaunch, StartTerminalOptions, + TerminalLaunchTarget, + TerminalSandboxResolver, TerminalStatus, } from './types' +import { useTerminalInstance } from './use-terminal-instance' -const INITIAL_TERMINAL_TEXT = - 'Open a terminal to start a persistent E2B sandbox.\r\n' -const TERMINAL_THEME = { - background: '#000000', - cursor: '#ffffff', - foreground: '#ffffff', - selectionBackground: '#ffffff40', -} +const FLUSH_INPUT_INTERVAL_MS = 10 interface DashboardTerminalProps { autoStart?: boolean - initialCommand?: string - initialSandboxId?: string - initialTemplate?: string + getSandbox?: TerminalSandboxResolver + launchTarget?: TerminalLaunchTarget + onSandboxAttached?: (sandboxId: string) => void + onSandboxAttachFailed?: (target: TerminalLaunchTarget | undefined) => void + sandboxScoped?: boolean teamId: string + teamSlug: string } export default function DashboardTerminal({ autoStart = false, - initialCommand = '', - initialSandboxId, - initialTemplate, + getSandbox, + launchTarget, + onSandboxAttached, + onSandboxAttachFailed, + sandboxScoped = false, teamId, + teamSlug, }: DashboardTerminalProps) { const [status, setStatus] = useState('idle') const [activeSandboxId, setActiveSandboxId] = useState() const [template, setTemplate] = useState( - normalizeTerminalTemplate(initialTemplate) ?? 'base' + normalizeTerminalTemplate(launchTarget?.template) ?? 'base' ) const [pendingLaunch, setPendingLaunch] = useState(null) @@ -59,73 +61,168 @@ export default function DashboardTerminal({ const sandboxRef = useRef(null) const ptyRef = useRef(null) const pidRef = useRef(undefined) - const xtermRef = useRef(null) - const terminalContainerRef = useRef(null) - const terminalTranscriptRef = useRef(INITIAL_TERMINAL_TEXT) - const terminalSizeRef = useRef({ cols: DEFAULT_COLS, rows: DEFAULT_ROWS }) - const decoderRef = useRef(new TextDecoder()) + const pendingInputRef = useRef([]) + const inputFlushTimerRef = useRef(null) + const inputFlushInFlightRef = useRef(false) + const inputGenerationRef = useRef(0) const pendingCommandsRef = useRef([]) - const inputQueueRef = useRef(Promise.resolve()) - const didAutoStartRef = useRef(false) const isStartingRef = useRef(false) + const retryResolveRef = useRef<(() => void) | null>(null) + const retryTimerRef = useRef(null) const startGenerationRef = useRef(0) - const resizeTerminal = useCallback(() => { - const nextSize = calculateTerminalSize( - terminalContainerRef.current, - xtermRef.current - ) - terminalSizeRef.current = nextSize - xtermRef.current?.resize(nextSize.cols, nextSize.rows) + const clearAttachRetryTimer = useCallback(() => { + if (!retryTimerRef.current) return - if (sandboxRef.current && pidRef.current) { - void sandboxRef.current.pty.resize(pidRef.current, nextSize) - } - - return nextSize + window.clearTimeout(retryTimerRef.current) + retryTimerRef.current = null + retryResolveRef.current?.() + retryResolveRef.current = null }, []) - const appendOutput = useCallback((chunk: string | Uint8Array) => { - const text = - typeof chunk === 'string' - ? chunk - : decoderRef.current.decode(chunk, { stream: true }) - - terminalTranscriptRef.current = ( - terminalTranscriptRef.current + text - ).slice(-MAX_TERMINAL_TRANSCRIPT_CHARS) - xtermRef.current?.write(chunk, () => { - xtermRef.current?.scrollToBottom() - }) + const waitForAttachRetry = useCallback( + (delayMs: number) => + new Promise((resolve) => { + retryResolveRef.current = resolve + retryTimerRef.current = window.setTimeout(() => { + retryTimerRef.current = null + retryResolveRef.current = null + resolve() + }, delayMs) + }), + [] + ) + + const requestPtyKill = useCallback( + ({ pid, sandboxId }: { pid: number; sandboxId: string }) => { + void fetch('/api/trpc/sandbox.killTerminalPty?batch=1', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + 0: { + json: { + pid, + sandboxId, + teamSlug, + }, + }, + }), + keepalive: true, + }) + }, + [teamSlug] + ) + + const clearPendingInput = useCallback(() => { + if (inputFlushTimerRef.current) { + window.clearTimeout(inputFlushTimerRef.current) + inputFlushTimerRef.current = null + } + inputGenerationRef.current += 1 + inputFlushInFlightRef.current = false + pendingInputRef.current = [] }, []) - const disconnectTerminal = useCallback(async () => { - const pty = ptyRef.current - ptyRef.current = null - if (!pty) return + const flushInputToPty = useCallback((terminalPid = pidRef.current) => { + inputFlushTimerRef.current = null - try { - await pty.disconnect() - } catch { - // Best-effort cleanup. The sandbox is intentionally left alive to pause. + if (inputFlushInFlightRef.current) return + + if (!sandboxRef.current || !terminalPid) { + pendingInputRef.current = [] + return + } + + const pendingInput = pendingInputRef.current + pendingInputRef.current = [] + if (!pendingInput.length) return + + const byteLength = pendingInput.reduce( + (total, chunk) => total + chunk.byteLength, + 0 + ) + const data = new Uint8Array(byteLength) + let offset = 0 + for (const chunk of pendingInput) { + data.set(chunk, offset) + offset += chunk.byteLength } + + const sandbox = sandboxRef.current + const inputGeneration = inputGenerationRef.current + inputFlushInFlightRef.current = true + + void sandbox.pty + .sendInput(terminalPid, data) + .catch(() => undefined) + .finally(() => { + if (inputGenerationRef.current !== inputGeneration) return + + inputFlushInFlightRef.current = false + + if (pidRef.current === terminalPid && pendingInputRef.current.length) { + flushInputToPty(terminalPid) + } + }) }, []) const sendInputToPty = useCallback( (value: string | Uint8Array, terminalPid = pidRef.current) => { if (!value || !sandboxRef.current || !terminalPid) return - const sandbox = sandboxRef.current const data = typeof value === 'string' ? new TextEncoder().encode(value) : value - inputQueueRef.current = inputQueueRef.current - .catch(() => undefined) - .then(() => sandbox.pty.sendInput(terminalPid, data)) + pendingInputRef.current.push(data) + + if (inputFlushTimerRef.current) return + + inputFlushTimerRef.current = window.setTimeout(() => { + flushInputToPty(terminalPid) + }, FLUSH_INPUT_INTERVAL_MS) }, - [] + [flushInputToPty] ) + const resizePty = useCallback((size: { cols: number; rows: number }) => { + if (sandboxRef.current && pidRef.current) { + void sandboxRef.current.pty.resize(pidRef.current, size) + } + }, []) + + const { + appendOutput, + copyTerminalText, + focusTerminal, + resetTerminal, + resizeTerminal, + terminalContainerRef, + } = useTerminalInstance({ + onInput: sendInputToPty, + onResize: resizePty, + }) + + const closeTerminal = useCallback(async () => { + clearAttachRetryTimer() + clearPendingInput() + + const pty = ptyRef.current + const sandboxId = sandboxRef.current?.sandboxId + ptyRef.current = null + pidRef.current = undefined + if (!pty) return + + if (sandboxId) { + requestPtyKill({ pid: pty.pid, sandboxId }) + } + + try { + await pty.kill() + } catch { + // Best-effort cleanup. The sandbox is intentionally left alive. + } + }, [clearAttachRetryTimer, clearPendingInput, requestPtyKill]) + const runCommand = useCallback( (command: string, terminalPid?: number) => { const normalizedCommand = command.trim() @@ -147,7 +244,7 @@ export default function DashboardTerminal({ const url = new URL(window.location.href) let changed = false - if (url.searchParams.get('sandboxId') !== sandboxId) { + if (!sandboxScoped && url.searchParams.get('sandboxId') !== sandboxId) { url.searchParams.set('sandboxId', sandboxId) changed = true } @@ -161,14 +258,15 @@ export default function DashboardTerminal({ window.history.replaceState(window.history.state, '', url) } }, - [] + [sandboxScoped] ) const startTerminal = useCallback( async (options: StartTerminalOptions = {}) => { if (isStartingRef.current) return + const target = options.target const nextTemplate = resolveTerminalTemplateOverride( - options.template, + target?.template, template ) @@ -178,71 +276,109 @@ export default function DashboardTerminal({ return } + const requestedSandboxId = target?.sandboxId isStartingRef.current = true const startGeneration = startGenerationRef.current + 1 startGenerationRef.current = startGeneration const isCurrentStart = () => startGenerationRef.current === startGeneration - await disconnectTerminal() + await closeTerminal() sandboxRef.current = null pidRef.current = undefined - decoderRef.current = new TextDecoder() - inputQueueRef.current = Promise.resolve() - terminalTranscriptRef.current = '' - xtermRef.current?.reset() + resetTerminal() setStatus('starting') - setActiveSandboxId(options.sandboxId) + setActiveSandboxId(requestedSandboxId) setTemplate(nextTemplate) appendOutput('Opening terminal...\r\n') - try { - const { sandbox } = await openTerminalSandbox({ - forceNewSandbox: options.forceNewSandbox, - onStatus: appendOutput, - sandboxId: options.sandboxId, - teamId, - template: nextTemplate, - }) + const openSandboxAndPty = async () => { + let sandbox: Sandbox + + if (getSandbox) { + appendOutput('Connecting to sandbox...\r\n') + sandbox = await getSandbox() + } else { + const terminalSandbox = await openTerminalSandbox({ + forceNewSandbox: options.forceNewSandbox, + onStatus: appendOutput, + requestTimeoutMs: requestedSandboxId + ? TERMINAL_ATTACH_ATTEMPT_TIMEOUT_MS + : undefined, + shouldStoreSession: !sandboxScoped, + sandboxId: requestedSandboxId, + teamId, + template: nextTemplate, + }) + sandbox = terminalSandbox.sandbox + } - if (!isCurrentStart()) return + if (!isCurrentStart()) return null - sandboxRef.current = sandbox - setActiveSandboxId(sandbox.sandboxId) - updateTerminalUrl({ - // Keep ?command= until the confirmed command has an attached sandbox. - clearCommand: pendingCommandsRef.current.length > 0, - sandboxId: sandbox.sandboxId, - }) appendOutput(`Sandbox ${sandbox.sandboxId} is running.\r\n`) - appendOutput('Opening PTY...\r\n') const terminalSize = resizeTerminal() const pty = await sandbox.pty.create({ cols: terminalSize.cols, rows: terminalSize.rows, timeoutMs: 0, + requestTimeoutMs: TERMINAL_ATTACH_ATTEMPT_TIMEOUT_MS, cwd: DEFAULT_CWD, onData: (data) => { appendOutput(data) }, }) + return { pty, sandbox } + } + + const canRetryAttach = Boolean(requestedSandboxId || getSandbox) + + try { + const result = await attachTerminalWithRetry({ + canRetry: canRetryAttach, + isCurrent: isCurrentStart, + isRetryableError: (error) => error instanceof TimeoutError, + maxRetries: TERMINAL_ATTACH_MAX_RETRIES, + onRetry: (retryDelay) => { + appendOutput( + `Terminal attach timed out. Retrying in ${Math.round( + retryDelay / 1000 + )}s...\r\n` + ) + }, + open: openSandboxAndPty, + retryBaseDelayMs: TERMINAL_ATTACH_RETRY_BASE_DELAY_MS, + retryMaxDelayMs: TERMINAL_ATTACH_RETRY_MAX_DELAY_MS, + waitForRetry: waitForAttachRetry, + }) + + if (!result) return + const { sandbox, pty } = result + if (!isCurrentStart()) { try { - await pty.disconnect() + await pty.kill() } catch { // The start was superseded or unmounted; best-effort PTY cleanup. } return } + sandboxRef.current = sandbox + setActiveSandboxId(sandbox.sandboxId) + updateTerminalUrl({ + // Keep ?command= until the confirmed command has an attached sandbox. + clearCommand: pendingCommandsRef.current.length > 0, + sandboxId: sandbox.sandboxId, + }) ptyRef.current = pty pidRef.current = pty.pid resizeTerminal() setStatus('ready') appendOutput(`PTY ${pty.pid} attached.\r\n`) - xtermRef.current?.focus() + focusTerminal() + onSandboxAttached?.(sandbox.sandboxId) const pendingCommands = pendingCommandsRef.current pendingCommandsRef.current = [] @@ -253,6 +389,7 @@ export default function DashboardTerminal({ if (!isCurrentStart()) return setStatus('error') + onSandboxAttachFailed?.(target) appendOutput( `\r\nFailed to start terminal: ${ error instanceof Error ? error.message : 'Unknown error' @@ -260,26 +397,32 @@ export default function DashboardTerminal({ ) } finally { if (isCurrentStart()) { - // Only the latest start owns the shared starting flag. isStartingRef.current = false } } }, [ appendOutput, - disconnectTerminal, + closeTerminal, resizeTerminal, + resetTerminal, + focusTerminal, + getSandbox, runCommand, + sandboxScoped, teamId, template, + onSandboxAttached, + onSandboxAttachFailed, updateTerminalUrl, + waitForAttachRetry, ] ) const queueTerminalCommand = useCallback( (command: string, options: StartTerminalOptions = {}) => { const nextTemplate = resolveTerminalTemplateOverride( - options.template, + options.target?.template, template ) @@ -294,8 +437,10 @@ export default function DashboardTerminal({ // sending anything into the PTY. setPendingLaunch({ command: command.trim(), - sandboxId: options.sandboxId, - template: nextTemplate, + target: { + ...options.target, + template: nextTemplate, + }, }) return } @@ -303,7 +448,10 @@ export default function DashboardTerminal({ if (status === 'idle' || status === 'error' || options.forceNewSandbox) { void startTerminal({ ...options, - template: nextTemplate, + target: { + ...options.target, + template: nextTemplate, + }, }) } }, @@ -313,11 +461,9 @@ export default function DashboardTerminal({ const confirmPendingLaunch = useCallback(() => { if (!pendingLaunch) return - const { - command, - sandboxId: launchSandboxId, - template: launchTemplate, - } = pendingLaunch + const { command, target: launchTarget } = pendingLaunch + const launchTemplate = launchTarget?.template ?? 'base' + const launchSandboxId = launchTarget?.sandboxId if ( status === 'ready' && @@ -327,7 +473,10 @@ export default function DashboardTerminal({ setPendingLaunch(null) runCommand(command) if (activeSandboxId) { - updateTerminalUrl({ clearCommand: true, sandboxId: activeSandboxId }) + updateTerminalUrl({ + clearCommand: true, + sandboxId: activeSandboxId, + }) } return } @@ -340,8 +489,7 @@ export default function DashboardTerminal({ pendingCommandsRef.current = [command] void startTerminal({ forceNewSandbox: !launchSandboxId && template !== launchTemplate, - sandboxId: launchSandboxId, - template: launchTemplate, + target: launchTarget, }) }, [ activeSandboxId, @@ -353,120 +501,88 @@ export default function DashboardTerminal({ updateTerminalUrl, ]) - const copyTerminalText = async () => { - const value = - xtermRef.current?.getSelection() || terminalTranscriptRef.current - if (!value) return - - try { - await navigator.clipboard.writeText(value) - } catch { - appendOutput('\r\nCould not copy terminal output to clipboard.\r\n') - } finally { - xtermRef.current?.focus() + const reconnectTarget = sandboxScoped + ? launchTarget + : activeSandboxId + ? { sandboxId: activeSandboxId, template } + : undefined + const reconnectSandboxId = sandboxScoped + ? launchTarget?.sandboxId + : activeSandboxId + const restartLabel = sandboxScoped + ? 'Reconnect terminal' + : 'Start new terminal sandbox' + const restartDisabled = + status === 'starting' || + (sandboxScoped && !reconnectSandboxId && !getSandbox) + + const restartTerminal = useCallback(() => { + if (sandboxScoped) { + if (!reconnectSandboxId && !getSandbox) return + + void startTerminal({ + target: reconnectTarget, + }) + return } - } - - useEffect(() => { - if (!autoStart || didAutoStartRef.current) return - didAutoStartRef.current = true - queueTerminalCommand(initialCommand, { - sandboxId: initialSandboxId, - template: initialTemplate, - }) + void startTerminal({ forceNewSandbox: true }) }, [ - autoStart, - initialCommand, - initialSandboxId, - initialTemplate, - queueTerminalCommand, + getSandbox, + reconnectTarget, + reconnectSandboxId, + sandboxScoped, + startTerminal, ]) useEffect(() => { - return () => { - startGenerationRef.current += 1 - void disconnectTerminal() - } - }, [disconnectTerminal]) - - useEffect(() => { - const container = terminalContainerRef.current - if (!container) return - - const terminal = new XTerm({ - cols: terminalSizeRef.current.cols, - rows: terminalSizeRef.current.rows, - cursorBlink: true, - cursorStyle: 'block', - fontFamily: - 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', - fontSize: 13, - lineHeight: 1.54, - scrollback: 10_000, - theme: TERMINAL_THEME, - }) + if (!autoStart || status !== 'idle' || isStartingRef.current) return - xtermRef.current = terminal - terminal.open(container) - terminal.write(terminalTranscriptRef.current) - const dataSubscription = terminal.onData((data) => { - sendInputToPty(data) - }) + const autoStartTimer = window.setTimeout(() => { + if (isStartingRef.current || ptyRef.current) return - requestAnimationFrame(() => { - resizeTerminal() - terminal.focus() - }) - const resizeTimer = window.setTimeout(() => { - resizeTerminal() - }, 100) + queueTerminalCommand(launchTarget?.command ?? '', { + target: launchTarget, + }) + }, TERMINAL_AUTOSTART_DEBOUNCE_MS) return () => { - window.clearTimeout(resizeTimer) - dataSubscription.dispose() - terminal.dispose() - if (xtermRef.current === terminal) { - xtermRef.current = null - } + window.clearTimeout(autoStartTimer) } - }, [resizeTerminal, sendInputToPty]) + }, [autoStart, launchTarget, queueTerminalCommand, status]) useEffect(() => { - const container = terminalContainerRef.current - const resizeObserver = - container && typeof ResizeObserver !== 'undefined' - ? new ResizeObserver(() => { - resizeTerminal() - }) - : null + const handlePageHide = () => { + const pty = ptyRef.current + const sandboxId = sandboxRef.current?.sandboxId - if (container) { - resizeObserver?.observe(container) - } + if (!pty || !sandboxId) return - const handleWindowResize = () => { - resizeTerminal() + requestPtyKill({ pid: pty.pid, sandboxId }) } - window.addEventListener('resize', handleWindowResize) + window.addEventListener('pagehide', handlePageHide) return () => { - resizeObserver?.disconnect() - window.removeEventListener('resize', handleWindowResize) + window.removeEventListener('pagehide', handlePageHide) + startGenerationRef.current += 1 + clearAttachRetryTimer() + clearPendingInput() + void closeTerminal() } - }, [resizeTerminal]) + }, [clearAttachRetryTimer, clearPendingInput, closeTerminal, requestPtyKill]) return ( <> xtermRef.current?.focus()} + onFocusTerminal={focusTerminal} onCopyTerminalText={() => void copyTerminalText()} - onStartTerminal={(options) => void startTerminal(options)} + onRestartTerminal={restartTerminal} /> void + requestTimeoutMs?: number + shouldStoreSession?: boolean sandboxId?: string teamId: string template: string @@ -19,6 +21,8 @@ interface OpenTerminalSandboxOptions { export async function openTerminalSandbox({ forceNewSandbox = false, onStatus, + requestTimeoutMs, + shouldStoreSession, sandboxId, teamId, template, @@ -34,7 +38,9 @@ export async function openTerminalSandbox({ if (sandboxId) { onStatus(`Connecting to terminal sandbox ${sandboxId}...\r\n`) - const sandbox = await connectTerminalSandbox(sandboxId, headers) + const sandbox = await connectTerminalSandbox(sandboxId, headers, { + requestTimeoutMs, + }) return { sandbox, @@ -55,7 +61,8 @@ export async function openTerminalSandbox({ try { sandbox = await connectTerminalSandbox( storedTerminalSession.sandboxId, - headers + headers, + { requestTimeoutMs } ) } catch { clearStoredTerminalSession(userId) @@ -68,10 +75,12 @@ export async function openTerminalSandbox({ sandbox = await createTerminalSandbox({ headers, template, userId }) } - writeStoredTerminalSession(userId, { - sandboxId: sandbox.sandboxId, - template, - }) + if (shouldStoreSession ?? true) { + writeStoredTerminalSession(userId, { + sandboxId: sandbox.sandboxId, + template, + }) + } return { sandbox, @@ -80,11 +89,13 @@ export async function openTerminalSandbox({ function connectTerminalSandbox( sandboxId: string, - headers: Record + headers: Record, + options: { requestTimeoutMs?: number } = {} ) { return Sandbox.connect(sandboxId, { domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, timeoutMs: TERMINAL_SANDBOX_TIMEOUT_MS, + requestTimeoutMs: options.requestTimeoutMs, headers: { ...headers, }, diff --git a/src/features/dashboard/terminal/terminal-panel.tsx b/src/features/dashboard/terminal/terminal-panel.tsx index 0236076dc..5e3050a2e 100644 --- a/src/features/dashboard/terminal/terminal-panel.tsx +++ b/src/features/dashboard/terminal/terminal-panel.tsx @@ -1,75 +1,49 @@ import type { RefObject } from 'react' +import { SandboxInspectFrame as DashboardPanelFrame } from '@/features/dashboard/shared' import { IconButton } from '@/ui/primitives/icon-button' import { CopyIcon, RefreshIcon, TerminalCustomIcon, } from '@/ui/primitives/icons' -import type { StartTerminalOptions, TerminalStatus } from './types' interface TerminalPanelProps { sandboxId?: string - template: string - status: TerminalStatus + template?: string + restartDisabled: boolean + restartLabel: string terminalContainerRef: RefObject onFocusTerminal: () => void onCopyTerminalText: () => void - onStartTerminal: (options?: StartTerminalOptions) => void + onRestartTerminal: () => void } export default function TerminalPanel({ sandboxId, template, - status, + restartDisabled, + restartLabel, terminalContainerRef, onFocusTerminal, onCopyTerminalText, - onStartTerminal, + onRestartTerminal, }: TerminalPanelProps) { return ( -
-
-
- - - Terminal - - - {template} - - {sandboxId ? ( - - {sandboxId} - - ) : null} -
- -
- event.preventDefault()} - onClick={onCopyTerminalText} - > - - - onStartTerminal({ forceNewSandbox: true })} - > - - -
-
- + + } + >
-
+ + ) +} + +function TerminalPanelHeader({ + sandboxId, + template, + restartDisabled, + restartLabel, + onCopyTerminalText, + onRestartTerminal, +}: Pick< + TerminalPanelProps, + | 'sandboxId' + | 'template' + | 'restartDisabled' + | 'restartLabel' + | 'onCopyTerminalText' + | 'onRestartTerminal' +>) { + return ( +
+
+ + + Terminal + + {template ? ( + + {template} + + ) : null} + {sandboxId ? ( + + {sandboxId} + + ) : null} +
+ +
+ event.preventDefault()} + onClick={onCopyTerminalText} + > + + + + + +
+
) } diff --git a/src/features/dashboard/terminal/types.ts b/src/features/dashboard/terminal/types.ts index 4dd6b42df..e1ca2422d 100644 --- a/src/features/dashboard/terminal/types.ts +++ b/src/features/dashboard/terminal/types.ts @@ -1,3 +1,5 @@ +import type { Sandbox } from 'e2b' + export type TerminalStatus = 'idle' | 'starting' | 'ready' | 'error' export type StoredTerminalSession = { @@ -7,12 +9,18 @@ export type StoredTerminalSession = { export type StartTerminalOptions = { forceNewSandbox?: boolean - sandboxId?: string - template?: string + target?: TerminalLaunchTarget } export type PendingTerminalLaunch = { command: string + target?: TerminalLaunchTarget +} + +export type TerminalLaunchTarget = { + command?: string sandboxId?: string - template: string + template?: string } + +export type TerminalSandboxResolver = () => Promise diff --git a/src/features/dashboard/terminal/use-terminal-instance.ts b/src/features/dashboard/terminal/use-terminal-instance.ts new file mode 100644 index 000000000..95b70196b --- /dev/null +++ b/src/features/dashboard/terminal/use-terminal-instance.ts @@ -0,0 +1,158 @@ +import '@xterm/xterm/css/xterm.css' +import { Terminal as XTerm } from '@xterm/xterm' +import { useCallback, useEffect, useRef } from 'react' +import { + DEFAULT_COLS, + DEFAULT_ROWS, + MAX_TERMINAL_TRANSCRIPT_CHARS, +} from './constants' +import { calculateTerminalSize } from './terminal-size' + +const INITIAL_TERMINAL_TEXT = + 'Open a terminal to start a persistent E2B sandbox.\r\n' +const TERMINAL_THEME = { + background: '#000000', + cursor: '#ffffff', + foreground: '#ffffff', + selectionBackground: '#ffffff40', +} + +interface UseTerminalInstanceOptions { + onInput: (data: string) => void + onResize: (size: { cols: number; rows: number }) => void +} + +export function useTerminalInstance({ + onInput, + onResize, +}: UseTerminalInstanceOptions) { + const xtermRef = useRef(null) + const terminalContainerRef = useRef(null) + const terminalTranscriptRef = useRef(INITIAL_TERMINAL_TEXT) + const terminalSizeRef = useRef({ cols: DEFAULT_COLS, rows: DEFAULT_ROWS }) + const decoderRef = useRef(new TextDecoder()) + + const resizeTerminal = useCallback(() => { + const nextSize = calculateTerminalSize( + terminalContainerRef.current, + xtermRef.current + ) + terminalSizeRef.current = nextSize + xtermRef.current?.resize(nextSize.cols, nextSize.rows) + onResize(nextSize) + + return nextSize + }, [onResize]) + + const appendOutput = useCallback((chunk: string | Uint8Array) => { + const text = + typeof chunk === 'string' + ? chunk + : decoderRef.current.decode(chunk, { stream: true }) + + terminalTranscriptRef.current = ( + terminalTranscriptRef.current + text + ).slice(-MAX_TERMINAL_TRANSCRIPT_CHARS) + xtermRef.current?.write(chunk, () => { + xtermRef.current?.scrollToBottom() + }) + }, []) + + const resetTerminal = useCallback(() => { + decoderRef.current = new TextDecoder() + terminalTranscriptRef.current = '' + xtermRef.current?.reset() + }, []) + + const focusTerminal = useCallback(() => { + xtermRef.current?.focus() + }, []) + + const copyTerminalText = useCallback(async () => { + const value = + xtermRef.current?.getSelection() || terminalTranscriptRef.current + if (!value) return + + try { + await navigator.clipboard.writeText(value) + } catch { + appendOutput('\r\nCould not copy terminal output to clipboard.\r\n') + } finally { + focusTerminal() + } + }, [appendOutput, focusTerminal]) + + useEffect(() => { + const container = terminalContainerRef.current + if (!container) return + + const terminal = new XTerm({ + cols: terminalSizeRef.current.cols, + rows: terminalSizeRef.current.rows, + cursorBlink: true, + cursorStyle: 'block', + fontFamily: + 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', + fontSize: 13, + lineHeight: 1.54, + scrollback: 10_000, + theme: TERMINAL_THEME, + }) + + xtermRef.current = terminal + terminal.open(container) + terminal.write(terminalTranscriptRef.current) + const dataSubscription = terminal.onData(onInput) + + requestAnimationFrame(() => { + resizeTerminal() + terminal.focus() + }) + const resizeTimer = window.setTimeout(() => { + resizeTerminal() + }, 100) + + return () => { + window.clearTimeout(resizeTimer) + dataSubscription.dispose() + terminal.dispose() + if (xtermRef.current === terminal) { + xtermRef.current = null + } + } + }, [onInput, resizeTerminal]) + + useEffect(() => { + const container = terminalContainerRef.current + const resizeObserver = + container && typeof ResizeObserver !== 'undefined' + ? new ResizeObserver(() => { + resizeTerminal() + }) + : null + + if (container) { + resizeObserver?.observe(container) + } + + const handleWindowResize = () => { + resizeTerminal() + } + + window.addEventListener('resize', handleWindowResize) + + return () => { + resizeObserver?.disconnect() + window.removeEventListener('resize', handleWindowResize) + } + }, [resizeTerminal]) + + return { + appendOutput, + copyTerminalText, + focusTerminal, + resetTerminal, + resizeTerminal, + terminalContainerRef, + } +} diff --git a/tests/unit/dashboard-terminal.test.ts b/tests/unit/dashboard-terminal.test.ts index 54675e595..a229962cc 100644 --- a/tests/unit/dashboard-terminal.test.ts +++ b/tests/unit/dashboard-terminal.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { SUPABASE_TEAM_HEADER, SUPABASE_TOKEN_HEADER } from '@/configs/api' +import { attachTerminalWithRetry } from '@/features/dashboard/terminal/attach-terminal' import { TERMINAL_SESSION_STORAGE_PREFIX } from '@/features/dashboard/terminal/constants' import { openTerminalSandbox } from '@/features/dashboard/terminal/sandbox-session' import { @@ -211,6 +212,142 @@ describe('dashboard terminal helpers', () => { }) }) + describe('attachTerminalWithRetry', () => { + const retryOptions = { + maxRetries: 2, + retryBaseDelayMs: 100, + retryMaxDelayMs: 500, + } + + it('returns the first successful attach without retrying', async () => { + const attachResult = { pty: 'pty', sandbox: 'sandbox' } + const waitForRetry = vi.fn() + const onRetry = vi.fn() + + await expect( + attachTerminalWithRetry({ + canRetry: true, + isCurrent: () => true, + isRetryableError: () => true, + ...retryOptions, + onRetry, + open: vi.fn().mockResolvedValue(attachResult), + waitForRetry, + }) + ).resolves.toBe(attachResult) + + expect(onRetry).not.toHaveBeenCalled() + expect(waitForRetry).not.toHaveBeenCalled() + }) + + it('retries retryable attach failures with exponential backoff', async () => { + const attachResult = { pty: 'pty', sandbox: 'sandbox' } + const retryableError = new Error('timeout') + const open = vi + .fn() + .mockRejectedValueOnce(retryableError) + .mockRejectedValueOnce(retryableError) + .mockResolvedValueOnce(attachResult) + const waitForRetry = vi.fn().mockResolvedValue(undefined) + const onRetry = vi.fn() + + await expect( + attachTerminalWithRetry({ + canRetry: true, + isCurrent: () => true, + isRetryableError: (error) => error === retryableError, + ...retryOptions, + onRetry, + open, + waitForRetry, + }) + ).resolves.toBe(attachResult) + + expect(open).toHaveBeenCalledTimes(3) + expect(onRetry).toHaveBeenNthCalledWith(1, 100) + expect(onRetry).toHaveBeenNthCalledWith(2, 200) + expect(waitForRetry).toHaveBeenNthCalledWith(1, 100) + expect(waitForRetry).toHaveBeenNthCalledWith(2, 200) + }) + + it('caps exponential backoff at the configured max delay', async () => { + const attachResult = { pty: 'pty', sandbox: 'sandbox' } + const retryableError = new Error('timeout') + const open = vi + .fn() + .mockRejectedValueOnce(retryableError) + .mockRejectedValueOnce(retryableError) + .mockRejectedValueOnce(retryableError) + .mockResolvedValueOnce(attachResult) + const waitForRetry = vi.fn().mockResolvedValue(undefined) + const onRetry = vi.fn() + + await expect( + attachTerminalWithRetry({ + canRetry: true, + isCurrent: () => true, + isRetryableError: (error) => error === retryableError, + maxRetries: 3, + onRetry, + open, + retryBaseDelayMs: 100, + retryMaxDelayMs: 150, + waitForRetry, + }) + ).resolves.toBe(attachResult) + + expect(open).toHaveBeenCalledTimes(4) + expect(onRetry).toHaveBeenNthCalledWith(1, 100) + expect(onRetry).toHaveBeenNthCalledWith(2, 150) + expect(onRetry).toHaveBeenNthCalledWith(3, 150) + expect(waitForRetry).toHaveBeenNthCalledWith(1, 100) + expect(waitForRetry).toHaveBeenNthCalledWith(2, 150) + expect(waitForRetry).toHaveBeenNthCalledWith(3, 150) + }) + + it('does not retry non-retryable attach failures', async () => { + const error = new Error('permission denied') + const waitForRetry = vi.fn() + + await expect( + attachTerminalWithRetry({ + canRetry: true, + isCurrent: () => true, + isRetryableError: () => false, + ...retryOptions, + onRetry: vi.fn(), + open: vi.fn().mockRejectedValue(error), + waitForRetry, + }) + ).rejects.toBe(error) + + expect(waitForRetry).not.toHaveBeenCalled() + }) + + it('stops retrying when the caller is no longer current', async () => { + let isCurrent = true + const open = vi.fn().mockRejectedValue(new Error('timeout')) + const waitForRetry = vi.fn().mockImplementation(async () => { + isCurrent = false + }) + + await expect( + attachTerminalWithRetry({ + canRetry: true, + isCurrent: () => isCurrent, + isRetryableError: () => true, + ...retryOptions, + onRetry: vi.fn(), + open, + waitForRetry, + }) + ).resolves.toBeNull() + + expect(open).toHaveBeenCalledTimes(1) + expect(waitForRetry).toHaveBeenCalledWith(100) + }) + }) + describe('openTerminalSandbox', () => { it('connects to an explicit sandbox without writing a stored session', async () => { const statuses: string[] = []