From f44222f51d5e80786c9b68b74aaded0febd7176e Mon Sep 17 00:00:00 2001 From: hbrooks Date: Mon, 13 Jul 2026 14:05:38 -0400 Subject: [PATCH 1/2] feat(connect): render session/run status surface; stop spinner on waiting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend now streams a customer-facing status surface (session + run axes + a derived word) instead of the raw per-execution status. A connected session used to spin "running" even while the agent was idle at the prompt waiting for input. Consume the new vocabulary: - StreamFrame + the AgentSession type gain session/run (+ surface on the REST session). - isWorkingStatus = [scheduled, starting, working, retrying] — waiting/sleeping stop the spinner, so a finished turn reads "waiting" instead of a misleading spinning "running". - everRunning triggers on working|waiting; the poll backstop and connect's initialStatus read surface.status (fall back to raw) so the socket and poll paths share the derived vocabulary. - statusSystemLine rewritten to the surface words. Companion to the backend PR (ellipsis-dev/ellipsis#5830). --- src/commands/connect.ts | 4 ++-- src/lib/events.ts | 25 ++++++++++++++++--------- src/lib/types.ts | 9 +++++++++ src/lib/ws.ts | 10 ++++++++-- src/ui/ConnectApp.tsx | 36 ++++++++++++++++++++++-------------- 5 files changed, 57 insertions(+), 27 deletions(-) diff --git a/src/commands/connect.ts b/src/commands/connect.ts index ab67fe3..c05d134 100644 --- a/src/commands/connect.ts +++ b/src/commands/connect.ts @@ -106,7 +106,7 @@ export async function runConnect( // A compact header printed once above the live transcript. The live footer // keeps the id/url, status, and spend on screen; this is the scrollback record. - console.log(`${session.id} · ${session.status}`) + console.log(`${session.id} · ${session.surface?.status ?? session.status}`) console.log(url) if (session.agent_config_id) console.log(`config ${session.agent_config_id}`) if (reason) console.log(reason) @@ -139,7 +139,7 @@ export async function runConnect( // Always advance the cursor past existing steps: --no-steps skips // *rendering* history, not re-streaming it live. initialMaxStepIndex, - initialStatus: session.status, + initialStatus: session.surface?.status ?? session.status, sessionUrl: url, initialCost, }), diff --git a/src/lib/events.ts b/src/lib/events.ts index f1b7f4f..3cfd8de 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -281,20 +281,27 @@ export function foldCosts(events: CCEvent[]): { // A dim, Claude-Code-style one-liner for a session lifecycle status, shown in // the transcript when the status changes ("creating sandbox", "spawning agent // process", …). null for statuses that don't warrant their own line. +// Maps the server's derived `status` word (session_surface) to a transcript +// system line. The words are the customer-facing surface, not the raw +// per-execution status. export function statusSystemLine(status: string): string | null { switch (status) { case 'scheduled': - return 'scheduled · waiting for a worker' - case 'creating_sandbox': - return 'creating sandbox' - case 'running': - return 'sandbox ready · spawning agent process' + return 'queued · waiting for a worker' + case 'starting': + return 'starting sandbox' + case 'working': + return 'agent working' + case 'waiting': + return 'waiting for your reply' + case 'sleeping': + return 'sleeping · your next message wakes it' case 'retrying': return 'retrying after a transient error' - case 'completed': - return 'session complete' - case 'error': - return 'session errored' + case 'closed': + return 'conversation closed' + case 'failed': + return 'session failed' case 'cancelled': return 'session cancelled' case 'stopped': diff --git a/src/lib/types.ts b/src/lib/types.ts index 893c26f..b910aa7 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -111,6 +111,15 @@ export interface AgentSession { // the cloud session loop and accepts /messages; null = single-shot. session_key?: string | null session_state?: 'idle' | 'running' | 'closed' | null + // Customer-facing status surface (session_surface.py). `status` is the derived + // single word to display (working/waiting/sleeping/starting/…); `session` + + // `run` are the two raw axes. null for un-keyed (laptop) sessions and on list + // rows that don't populate it. Prefer surface.status over the raw `status`. + surface?: { + session: 'alive' | 'sleeping' | 'closed' | null + run: string | null + status: string | null + } | null cost_tokens: number cost_sandbox_cpu: number cost_sandbox_memory: number diff --git a/src/lib/ws.ts b/src/lib/ws.ts index e554d92..c39748e 100644 --- a/src/lib/ws.ts +++ b/src/lib/ws.ts @@ -4,11 +4,14 @@ import { DEFAULT_WS_BASE, USER_AGENT } from './constants' // The frame protocol spoken over the session WebSocket (server -> client). One // JSON object per message. Mirrors session_stream.py in the backend. -// status: { type, status, ts } +// status: { type, status, session, run, ts } // stdout/stderr: { type, data, seq, ts } // delta: { type, text?, output_tokens? } -// done: { type, status, exit_status } +// done: { type, status, session, run, exit_status } // error: { type, message } +// `status` is the derived single word (working/waiting/sleeping/starting/…); +// `session` (alive/sleeping/closed) and `run` are the two raw axes. All three +// are null for an un-keyed (laptop) session. See session_surface.py. // `seq` is a monotonic per-session cursor; the client resumes from the last seq // it saw via `?after_seq=` so a dropped socket loses nothing. `delta` is the // EPHEMERAL live-streaming frame (partial assistant text + running token count); @@ -18,6 +21,9 @@ export interface StreamFrame { type: 'stdout' | 'stderr' | 'status' | 'delta' | 'done' | 'error' data?: string status?: string + // status/done frames: the two raw session-surface axes (null for laptop). + session?: string | null + run?: string | null seq?: number ts?: string message?: string diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index 8582eee..2e4a22c 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -50,9 +50,12 @@ export interface ConnectAppProps { initialCost: { total: number | null; lastStep: number | null } } -// Statuses in which the agent is actively producing output — drives the spinner. +// Surface statuses in which something is actively happening — drives the +// spinner. `waiting` (turn done, warm, your move) and `sleeping` (parked) are +// deliberately NOT here: the spinner stops and the footer reads the calm state +// instead of a misleading "running". function isWorkingStatus(status: string): boolean { - return ['scheduled', 'creating_sandbox', 'running', 'retrying'].includes(status) + return ['scheduled', 'starting', 'working', 'retrying'].includes(status) } export function ConnectApp(props: ConnectAppProps): React.ReactElement { @@ -211,7 +214,9 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { lastStatus.current = frame.status setStatus(frame.status) setWorking(isWorkingStatus(frame.status)) - if (frame.status === 'running') everRunning.current = true + // Box-up states (working/waiting) mean the session became connectable; + // used to tell a preflight failure from a mid-conversation one. + if (['working', 'waiting'].includes(frame.status)) everRunning.current = true emitStatusLine(frame.status) } if (frame.type === 'error') { @@ -258,7 +263,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { if ( outcome.type === 'done' && !everRunning.current && - ['error', 'cancelled', 'stopped'].includes(outcome.status) + ['failed', 'cancelled', 'stopped'].includes(outcome.status) ) { setNotice(`session ${outcome.status} before it became connectable`) process.exitCode = 1 @@ -287,19 +292,22 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { }) }, [canSend, exit, handleFrame, refreshSteps, sessionId, token, wsBase]) - // A status backstop that doesn't depend on the socket: the lifecycle frames - // (creating_sandbox → running) may arrive before the socket attaches, so poll - // the session too and drive the same transition handling. `lastStatus` - // dedupes against socket frames so a transition is only announced once. + // A status backstop that doesn't depend on the socket: the lifecycle + // transitions may arrive before the socket attaches, so poll the session too + // and drive the same transition handling. Use the derived surface word so this + // matches the stream's `frame.status` exactly (the raw `status` is a different + // vocabulary); fall back to raw for un-keyed sessions. `lastStatus` dedupes + // against socket frames so a transition is only announced once. const pollStatus = useCallback(async (): Promise => { try { const s = await api.getAgentSession(sessionId) - if (s.status !== lastStatus.current) { - lastStatus.current = s.status - setStatus(s.status) - setWorking(isWorkingStatus(s.status)) - if (s.status === 'running') everRunning.current = true - emitStatusLine(s.status) + const word = s.surface?.status ?? s.status + if (word !== lastStatus.current) { + lastStatus.current = word + setStatus(word) + setWorking(isWorkingStatus(word)) + if (['working', 'waiting'].includes(word)) everRunning.current = true + emitStatusLine(word) } } catch { // Transient fetch failure — the next tick retries. From 7e129c56a466ff8bdf9923a8ba49c0a0eeb1cdc0 Mon Sep 17 00:00:00 2001 From: hbrooks Date: Mon, 13 Jul 2026 14:07:43 -0400 Subject: [PATCH 2/2] test(events): update statusSystemLine test to surface vocabulary --- test/events.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/test/events.test.ts b/test/events.test.ts index e5179e9..d37c937 100644 --- a/test/events.test.ts +++ b/test/events.test.ts @@ -196,10 +196,13 @@ describe('foldCosts', () => { }) describe('statusSystemLine', () => { - it('maps lifecycle statuses to Claude-Code-style lines', () => { - expect(statusSystemLine('creating_sandbox')).toBe('creating sandbox') - expect(statusSystemLine('running')).toBe('sandbox ready · spawning agent process') - expect(statusSystemLine('completed')).toBe('session complete') + it('maps surface statuses to Claude-Code-style lines', () => { + expect(statusSystemLine('starting')).toBe('starting sandbox') + expect(statusSystemLine('working')).toBe('agent working') + expect(statusSystemLine('waiting')).toBe('waiting for your reply') + expect(statusSystemLine('sleeping')).toBe('sleeping · your next message wakes it') + expect(statusSystemLine('closed')).toBe('conversation closed') + expect(statusSystemLine('failed')).toBe('session failed') }) it('is null for an unknown status', () => {