Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
a21aeaf
Share dashboard panel frame
matthewlouisbrockman May 27, 2026
c954aee
Move xterm styles into terminal component
matthewlouisbrockman May 27, 2026
171563e
Render terminal in dashboard panel frame
matthewlouisbrockman May 27, 2026
dc9d1e9
Use type-only terminal SDK imports
matthewlouisbrockman May 27, 2026
7ec924d
Keep sandbox terminals out of stored sessions
matthewlouisbrockman May 27, 2026
ae2b295
Make terminal restart action caller-controlled
matthewlouisbrockman May 27, 2026
e104b6c
Retry timed-out sandbox terminal attaches
matthewlouisbrockman May 27, 2026
0efbfb4
Debounce terminal autostart
matthewlouisbrockman May 27, 2026
0672776
Reset terminal input queue on disconnect
matthewlouisbrockman May 27, 2026
b6af83c
Show missing terminal sandbox access copy
matthewlouisbrockman May 27, 2026
c4257da
Add terminal tab to sandbox details
matthewlouisbrockman May 27, 2026
5fcd614
Add terminal tab route boundaries
matthewlouisbrockman May 27, 2026
9195326
Extract terminal instance hook
matthewlouisbrockman May 27, 2026
e61ed34
Remove sandbox inspect frame shim
matthewlouisbrockman May 27, 2026
2c9d146
Use dashboard panel frame directly
matthewlouisbrockman May 27, 2026
56ae8bf
Keep shared frame named for sandbox inspect
matthewlouisbrockman May 27, 2026
2b38566
Extract terminal attach retry helper
matthewlouisbrockman May 27, 2026
19faacd
Allow zero-delay terminal attach retries
matthewlouisbrockman May 27, 2026
2165839
Use bounded terminal attach backoff
matthewlouisbrockman May 28, 2026
9d6b490
Pass terminal launch options as a target
matthewlouisbrockman May 28, 2026
3c962d0
Resume paused sandbox inspect views for five minutes
matthewlouisbrockman May 28, 2026
2777f17
Use sandbox context in terminal tab
matthewlouisbrockman May 28, 2026
d6f7003
Reset sandbox terminal resume after attach failure
matthewlouisbrockman May 28, 2026
5ce2360
Kill terminal PTYs on teardown
matthewlouisbrockman May 28, 2026
320df06
Batch terminal input writes
matthewlouisbrockman May 28, 2026
3f4a7f7
Use terminal copy for terminal empty state
matthewlouisbrockman May 28, 2026
e1a2b96
Use sandbox context for terminal lifecycle
matthewlouisbrockman May 28, 2026
8d9f1dd
Batch terminal input and split panel header
matthewlouisbrockman May 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 <DashboardRouteError error={error} reset={reset} />
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from '@/features/dashboard/loading-layout'
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import SandboxTerminalView from '@/features/dashboard/sandbox/terminal/view'

export default function SandboxTerminalPage() {
return <SandboxTerminalView />
}
Comment thread
cursor[bot] marked this conversation as resolved.
9 changes: 0 additions & 9 deletions src/app/dashboard/terminal/layout.tsx

This file was deleted.

19 changes: 15 additions & 4 deletions src/app/dashboard/terminal/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,15 @@ export default async function TerminalPage({
: teamsResult.data.find((candidate) => candidate.id === resolvedTeam?.id)

if (!team) {
return <TerminalUnavailable />
return (
<TerminalUnavailable
message={
terminalSandboxId
? 'Sandbox not found or you do not have access to it.'
: undefined
}
/>
)
}

const templateAvailable = terminalSandboxId
Expand Down Expand Up @@ -107,10 +115,13 @@ export default async function TerminalPage({
<main className="h-dvh min-h-[360px] bg-bg p-3">
<DashboardTerminal
autoStart
initialCommand={command}
initialSandboxId={terminalSandboxId}
initialTemplate={terminalTemplate}
launchTarget={{
command,
sandboxId: terminalSandboxId,
template: terminalTemplate,
}}
teamId={team.id}
teamSlug={team.slug}
/>
</main>
)
Expand Down
2 changes: 2 additions & 0 deletions src/configs/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,

Expand Down
20 changes: 20 additions & 0 deletions src/core/server/api/routers/sandbox.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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)
}),
})
130 changes: 128 additions & 2 deletions src/features/dashboard/sandbox/context.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -28,7 +50,10 @@ interface SandboxContextValue {
isSandboxNotFound: boolean

isSandboxInfoLoading: boolean
isSandboxResumePending: boolean
getSandbox: () => Promise<Sandbox>
refetchSandboxInfo: () => Promise<void>
resumeSandbox: () => Promise<void>
}

const SandboxContext = createContext<SandboxContextValue | null>(null)
Expand Down Expand Up @@ -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<Sandbox | null>(null)
const sandboxPromiseRef = useRef<Promise<Sandbox> | null>(null)
const sandboxConnectionKey = `${team.id}:${sandboxId}`
const sandboxConnectionKeyRef = useRef(sandboxConnectionKey)

const trpc = useTRPC()
const getAlignedRefetchInterval = useAlignedRefetchInterval({
Expand Down Expand Up @@ -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 =
Expand All @@ -138,7 +261,10 @@ export function SandboxProvider({ children }: SandboxProviderProps) {
isRunning,
isSandboxNotFound,
isSandboxInfoLoading: isSandboxInfoPending,
isSandboxResumePending,
getSandbox,
refetchSandboxInfo,
resumeSandbox,
}}
>
{children}
Expand Down
42 changes: 17 additions & 25 deletions src/features/dashboard/sandbox/inspect/context.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
'use client'

import Sandbox from 'e2b'
import { useRouter } from 'next/navigation'
import type { ReactNode } from 'react'
import {
createContext,
Expand All @@ -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'
Expand Down Expand Up @@ -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<FilesystemStore | null>(null)
const sandboxManagerRef = useRef<SandboxManager | null>(null)
const connectGenerationRef = useRef(0)

const router = useRouter()
const { trackInteraction } = useSandboxInspectAnalytics()

// ---------- synchronous store initialisation ----------
Expand All @@ -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
}
Expand Down Expand Up @@ -175,41 +171,35 @@ 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', {
sandbox_id: sandboxInfo?.sandboxID,
team_id: teamId,
root_path: rootPath,
})
}, [sandboxInfo, teamId, rootPath, trackInteraction, router])
}, [getSandbox, sandboxInfo, teamId, rootPath, trackInteraction])

// handle sandbox connection / disconnection
useEffect(() => {
Expand All @@ -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,
Expand Down
Loading
Loading