Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
44 changes: 43 additions & 1 deletion src/lib/steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,21 @@ export function lifecycleText(
switch (recordType) {
case 'sandbox_starting':
return 'Starting sandbox…'
case 'sandbox_setup_output': {
// One chunk of setup-script output ({hook, chunk, lines}): show the
// script's latest line so the record view reads as install progress.
const line = setupOutputLine(payload)
return line ? `${setupOutputHook(payload)} · ${line}` : null
}
case 'sandbox_ready': {
const repos = Array.isArray(payload.repositories)
? (payload.repositories as unknown[]).filter((r): r is string => typeof r === 'string')
: []
return repos.length ? `Sandbox ready · ${repos.join(', ')}` : 'Sandbox ready'
const parts = ['Sandbox ready']
if (repos.length) parts.push(repos.join(', '))
const tier = cacheTierLabel(payload.cache_tier)
if (tier) parts.push(tier)
return parts.join(' · ')
}
case 'session_resumed':
return 'Resumed the conversation'
Expand All @@ -36,6 +46,38 @@ export function lifecycleText(
}
}

// The customer script a sandbox_setup_output chunk came from (image.setup /
// post_start / post_clone).
export function setupOutputHook(payload: Record<string, unknown>): string {
return typeof payload.hook === 'string' ? payload.hook : 'setup'
}

// The last non-empty output line of a sandbox_setup_output chunk — what the
// live "Starting sandbox" sub-line and the record view both show.
export function setupOutputLine(payload: Record<string, unknown>): string | null {
const lines = Array.isArray(payload.lines)
? (payload.lines as unknown[]).filter(
(l): l is string => typeof l === 'string' && l.trim().length > 0,
)
: []
return lines.length ? lines[lines.length - 1].trim() : null
}

// Customer-facing wording for sandbox_ready's cache_tier, explaining why the
// start was fast or slow.
function cacheTierLabel(tier: unknown): string | null {
switch (tier) {
case 'exact':
return 'cached image'
case 'incremental':
return 'incremental build'
case 'full':
return 'full build'
default:
return null
}
}

// A content block of a Claude Code stream event, typed loosely: the CLI only
// extracts display text and names, never interprets the payload.
interface StepContentBlock {
Expand Down
36 changes: 31 additions & 5 deletions src/ui/ConnectApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from '../lib/events'
import { hyperlink } from '../lib/urls'
import { usdNumberFromMillicents } from '../lib/output'
import { oneLine, setupOutputHook, setupOutputLine } from '../lib/steps'
import { VERSION } from '../lib/constants'

// The interactive `agent session connect` UI, modelled on Claude Code: a
Expand Down Expand Up @@ -109,6 +110,10 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
const [status, setStatus] = useState(props.initialStatus)
const [working, setWorking] = useState(isWorkingStatus(props.initialStatus))
const [elapsed, setElapsed] = useState(0)
// The setup script's latest output line while the sandbox spawns (streamed
// sandbox_setup_output lifecycle chunks) — the sub-line under "Starting
// sandbox…" that says WHAT the box is doing. Cleared by sandbox_ready.
const [setupLine, setSetupLine] = useState<string | null>(null)
const [notice, setNotice] = useState<string | null>(props.initialNotice ?? null)
const [input, setInput] = useState('')
// ctrl+r toggles full vs. collapsed tool output across the whole transcript.
Expand Down Expand Up @@ -260,6 +265,16 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
for (const st of fresh) {
maxFeed.current = Math.max(maxFeed.current, st.feed_seq)
applyCost(st.payload as CCEvent)
// Setup-script output chunks drive the "Starting sandbox" sub-line;
// sandbox_ready retires it (the spawn is over).
if (st.source === 'lifecycle') {
if (st.record_type === 'sandbox_setup_output') {
const line = setupOutputLine(st.payload)
if (line) setSetupLine(`${setupOutputHook(st.payload)} · ${line}`)
} else if (st.record_type === 'sandbox_ready') {
setSetupLine(null)
}
}
}
// Lifecycle rows stay off the transcript — the activity line + footer
// carry session state, closing surfaces as the exit notice — except
Expand Down Expand Up @@ -688,12 +703,23 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
{/* Sandbox spawn/wake progress, at the top where startup belongs;
re-renders in place and disappears once the session is live. */}
{infraActivity && (
<Text>
<Text color="cyan">✻</Text>{' '}
<Text dimColor>
{infraActivity}… ({formatDuration(elapsed)})
<Box flexDirection="column">
<Text>
<Text color="cyan">✻</Text>{' '}
<Text dimColor>
{infraActivity}… ({formatDuration(elapsed)})
</Text>
</Text>
</Text>
{/* What the spawn is actually doing: the setup script's latest
output line, streamed from the backend as it runs (a cold
dependency install is most of a slow start). */}
{setupLine && (
<Text>
{' '}
<Text dimColor>⎿ {oneLine(setupLine, 110)}</Text>
</Text>
)}
</Box>
)}
{/* The transcript grows through the middle of the terminal, pinning the
composer + meta to the bottom edge (flexGrow fills the slack). */}
Expand Down
19 changes: 19 additions & 0 deletions test/search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,25 @@ describe('recordText / formatStepLine', () => {
expect(line).toBe(' -2 2026-07-03 12:00 sandbox_ready Sandbox ready')
})

it('renders sandbox_ready cache tier and setup-output chunks', () => {
// sandbox_ready carries the image-cache tier so a slow start explains itself.
const ready = formatStepLine(
record(
{ repositories: ['acme/repo'], cache_tier: 'full' },
{ source: 'lifecycle', record_type: 'sandbox_ready', stream_seq: -2 },
),
)
expect(ready).toContain('Sandbox ready · acme/repo · full build')
// A setup-output chunk reads as the script's latest non-empty line.
const chunk = formatStepLine(
record(
{ hook: 'image.setup', chunk: 3, lines: ['Installing pandas (3.0.3)', ' '] },
{ source: 'lifecycle', record_type: 'sandbox_setup_output', stream_seq: -3 },
),
)
expect(chunk).toContain('image.setup · Installing pandas (3.0.3)')
})

it('truncates long text to about 120 characters', () => {
const line = formatStepLine(record({ message: { content: 'x'.repeat(500) } }))
expect(line.endsWith('...')).toBe(true)
Expand Down