From aae78a38d9854abe1c6a71202ed3525f54b1694e Mon Sep 17 00:00:00 2001 From: hbrooks Date: Fri, 17 Jul 2026 11:56:45 -0400 Subject: [PATCH] Connect: attach live activity to the transcript, humanize durations Three fixes to the connect UI: - The live "Running Bash(...)" / "Generating" status lines rendered in the footer above the composer; they now render in the transcript, attached to the block they describe. In-flight calls are excluded from the collapsed "Ran N shell commands" fold (it counts only completed calls) and show as a live line hugging the fold until the result lands. - Turn summaries showed raw seconds ("turn complete - 200.7s"); a shared formatDuration renders "3m 21s" style, reused by the live elapsed tickers. - The ephemeral streamed-prose overlay was cleared whenever ANY fresh record landed (lifecycle rows, the user's own relayed message), permanently losing the streamed prefix since later deltas append to the emptied overlay. It now clears only when a committed assistant step or the turn's result supersedes it. --- src/lib/events.ts | 9 +++- src/ui/ConnectApp.tsx | 114 +++++++++++++++++++++++++++--------------- test/events.test.ts | 20 +++++++- 3 files changed, 101 insertions(+), 42 deletions(-) diff --git a/src/lib/events.ts b/src/lib/events.ts index 8649940..2f7eb2d 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -135,6 +135,13 @@ export function summarizeToolInput(name: string, input: unknown): string { return oneLine(JSON.stringify(args), 100) } +// Human-readable duration from seconds, Claude Code style: "42s" under a +// minute, then "3m 21s". Whole seconds only ("200.7s" reads as noise). +export function formatDuration(seconds: number): string { + const s = Math.round(seconds) + return s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s` +} + // Flatten a message's `content` to a block list (a bare string becomes one // text block), so assistant and user turns share one iteration path. function blocksOf(content: unknown): CCContentBlock[] { @@ -174,7 +181,7 @@ export function eventToItems(event: CCEvent, keyBase: string): TranscriptItem[] // A final result event: a dim one-liner capping the turn (cost + duration). if (type === 'result') { const bits: string[] = [] - if (typeof event.duration_ms === 'number') bits.push(`${(event.duration_ms / 1000).toFixed(1)}s`) + if (typeof event.duration_ms === 'number') bits.push(formatDuration(event.duration_ms / 1000)) if (typeof event.total_cost_usd === 'number') bits.push(`$${event.total_cost_usd.toFixed(2)}`) const label = event.is_error ? 'turn ended with an error' : 'turn complete' push({ diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index ade70e3..8f4d410 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -6,6 +6,7 @@ import { clampLines, collapseToolRuns, foldCosts, + formatDuration, isConnectVisibleRecord, pendingToolCalls, recordToItems, @@ -20,8 +21,9 @@ import { VERSION } from '../lib/constants' // The interactive `agent session connect` UI, modelled on Claude Code: a // committed transcript that groups tool calls with their results and spaces -// messages apart, above a live footer with an animated status spinner and a -// composer that echoes what you send. Rendering shape lives in lib/events.ts +// messages apart — live activity (✻ Running/Generating) rendered on the +// transcript block it describes — above a footer with a composer that echoes +// what you send. Rendering shape lives in lib/events.ts // (pure); this component owns the data flow, the composer, and the colours. // // Data flow: the committed transcript comes from the structured records API @@ -251,10 +253,20 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { append( fresh.filter(isConnectVisibleRecord).flatMap((st) => recordToItems(st, `s${st.feed_seq}`)), ) - // A committed step landed — the streamed overlay is now part of the - // transcript (or the turn advanced), so drop the live overlay. - setLiveText('') - setLiveTokens(null) + // Drop the live overlay only when a committed ASSISTANT step (or the + // turn's result) lands — those supersede the streamed prose. Other + // records (lifecycle rows, your own relayed message) arrive mid- + // generation; clearing on them would lose the streamed prefix for + // good, since later deltas append to the now-empty overlay. + const supersedes = fresh.some((st) => { + if (st.source === 'lifecycle') return false + const t = (st.payload as CCEvent).type + return t === 'assistant' || t === 'result' + }) + if (supersedes) { + setLiveText('') + setLiveTokens(null) + } } } catch { // Transient fetch failure — the next wake/poll retries. @@ -558,12 +570,28 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // committed blocks. Collapsed (the default), runs of consecutive tool // activity fold into one "Ran N shell commands" line, Claude-Code-app-style; // ctrl+r restores the full ● call / ⎿ result blocks (and un-clamps long - // bodies). The list is memoized on [items, expanded], so the elapsed-second + // bodies). In-flight calls are excluded from the collapsed fold — they render + // as the live "Running …" line appended right below (see runningTool), so the + // fold only counts what actually ran. The pieces are memoized on + // [items, expanded] (pendingTools derives from items), so the elapsed-second // ticks reuse the same elements and don't re-lay-out the transcript. - const lines = useMemo(() => { - const visible = expanded ? items : collapseToolRuns(items) - return visible.map((item) => ) - }, [items, expanded]) + const { lines, runningHug } = useMemo(() => { + const pendingKeys = new Set(pendingTools.map((t) => t.key)) + const visible = expanded + ? items + : collapseToolRuns(pendingKeys.size ? items.filter((i) => !pendingKeys.has(i.key)) : items) + const last = visible[visible.length - 1] + return { + lines: visible.map((item) => ( + + )), + // Whether the live "Running …" line should hug the block above it: in + // expanded mode the pending ● call itself is the last line; collapsed, + // only when the trailing fold ("Ran N …", key grp:*) is the same burst + // the pending call belongs to — otherwise the line opens its own block. + runningHug: expanded ? pendingKeys.size > 0 : (last?.key.startsWith('grp:') ?? false), + } + }, [items, expanded, pendingTools]) // The persistent footer status line, kept minimal: the current status and // the running spend (cumulative total + the last turn's cost). Session @@ -571,19 +599,22 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { const totalStr = `$${(cost.total ?? 0).toFixed(2)}` const lastStepStr = cost.lastStep != null ? ` (Last step: $${cost.lastStep.toFixed(2)})` : '' const metaLine = `${status} · ${totalStr} total${lastStepStr}` - // Three distinct, factual activity signals — never whimsy: + // Three distinct, factual activity signals — never whimsy. All render IN the + // transcript, on the block they describe, not above the composer: // - `infraActivity`: the sandbox is spawning/waking (scheduled/starting/ // retrying). Shown at the TOP, under the banner, where a startup message - // belongs — there's no conversation yet to sit above a composer. + // belongs — there's no conversation yet. // - `generating`: the model is streaming tokens (delta frames flowing) — - // the ✻ line above the composer, with elapsed + token count. - // - `runningTool`: a committed tool call awaits its result — the same ✻ - // slot names the tool and ticks its own timer (generating wins if both - // somehow read true; the model can't stream past an unresolved call). + // the ✻ line under the streamed prose, with elapsed + token count. + // - `runningTool`: a committed tool call awaits its result — a ✻ line + // attached to the tool burst it belongs to ("Ran 2 shell commands" then + // the live third), naming the tool and ticking its own timer (generating + // wins if both somehow read true; the model can't stream past an + // unresolved call). const infraActivity = statusActivityText(status) const generating = status === 'working' && (liveText !== '' || liveTokens != null) const generatingBits = [ - formatElapsed(elapsed), + formatDuration(elapsed), ...(liveTokens != null ? [`↓ ${formatTokens(liveTokens)} tokens`] : []), ...(inputActive ? ['esc to interrupt'] : []), ].join(' · ') @@ -593,7 +624,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { ? `Running ${pendingTools[0].text}${pendingTools[0].detail ?? ''}` : `Running ${pendingTools.length} tool calls (${[...new Set(pendingTools.map((t) => t.text))].join(', ')})` const runningToolBits = [ - formatElapsed(toolElapsed), + formatDuration(toolElapsed), ...(inputActive ? ['esc to interrupt'] : []), ].join(' · ') @@ -627,7 +658,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { {' '} - {infraActivity}… ({formatElapsed(elapsed)}) + {infraActivity}… ({formatDuration(elapsed)}) )} @@ -635,30 +666,38 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { composer + meta to the bottom edge (flexGrow fills the slack). */} {lines} + {/* The live tool-call status, attached to the burst it belongs to: hugs + the collapsed "Ran N …" fold (or the expanded ● call) above it, and + disappears into the fold's count once the result lands. */} + {runningTool && ( + + + {' '} + + {runningToolLabel}… ({runningToolBits}) + + + + )} {/* The in-progress assistant response, streamed token-by-token from delta - frames; replaced by the committed step when it lands. */} + frames, with its live status hugging beneath; replaced by the + committed step when it lands. */} {liveText && ( {liveText} )} - - - {notice && · {notice}} {generating && ( - - {' '} - Generating… ({generatingBits}) - - )} - {runningTool && ( - - {' '} - - {runningToolLabel}… ({runningToolBits}) + + + {' '} + Generating… ({generatingBits}) - + )} + + + {notice && · {notice}} {/* The composer, framed by a full-width rule above and below (Claude Code-style): the › prompt sits between the two lines. Only the top and bottom borders are drawn, so the rules span the terminal width. */} @@ -689,11 +728,6 @@ function formatTokens(n: number): string { return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n) } -// Elapsed seconds in Claude Code's style: "42s", then "1m 25s" past a minute. -function formatElapsed(s: number): string { - return s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s` -} - // Long bodies collapse to this many lines until ctrl+r expands them. const COLLAPSE_LINES = 6 diff --git a/test/events.test.ts b/test/events.test.ts index 9d75ce0..7096b90 100644 --- a/test/events.test.ts +++ b/test/events.test.ts @@ -5,6 +5,7 @@ import { pendingToolCalls, eventToItems, foldCosts, + formatDuration, LineBuffer, parseEventLine, resultCostUsd, @@ -151,7 +152,12 @@ describe('eventToItems', () => { } const [item] = eventToItems(event, 's5') expect(item.kind).toBe('summary') - expect(item.text).toBe('turn complete · 12.3s · $0.04') + expect(item.text).toBe('turn complete · 12s · $0.04') + }) + + it('formats long turn durations human-readably (minutes, not 200.7s)', () => { + const [item] = eventToItems({ type: 'result', duration_ms: 200700 }, 's5b') + expect(item.text).toBe('turn complete · 3m 21s') }) it('renders nothing for system events (CC emits an init per query — noise)', () => { @@ -164,6 +170,18 @@ describe('eventToItems', () => { }) }) +describe('formatDuration', () => { + it('shows whole seconds under a minute, minutes past it', () => { + expect(formatDuration(42.3)).toBe('42s') + expect(formatDuration(200.7)).toBe('3m 21s') + expect(formatDuration(0)).toBe('0s') + }) + + it('never shows 60s (rounds up into the minute)', () => { + expect(formatDuration(59.7)).toBe('1m 0s') + }) +}) + describe('clampLines', () => { it('leaves short bodies untouched', () => { expect(clampLines('a\nb', 6)).toEqual({ body: 'a\nb', more: 0 })