Skip to content

Commit 0224c7c

Browse files
authored
connect: show queued sends in the chat, and mark a stopped turn's message cancelled (#89)
A message you send is now on screen the moment you send it. A new session's prompt renders as a dim row with the live pulsing mark and "queued" until the worker inserts it as turn 0's inbox message, so the chat is never empty while the sandbox comes up. Mid-session sends read "sending" then "queued", both breathing like a running tool does. The backend inbox has no cancelled state and /stop writes no record, so the chat derives it: a message whose delivering turn later failed never gets an answer, and now reads "cancelled" instead of waiting forever. A wake is also one line rather than two, with the waking line settling in place to "Session awake", and the asleep/awake notices drop their trailing advice.
1 parent e321a85 commit 0224c7c

5 files changed

Lines changed: 173 additions & 59 deletions

File tree

src/lib/output.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ export function friendlyErrorMessage(err: unknown): string {
8080
? 'The server rejected ELLIPSIS_API_TOKEN. Check the token, or unset it and run `agent login`.'
8181
: 'Your login is invalid or has expired. Run `agent login` to re-authenticate.'
8282
}
83+
// A 429 detail is written for a human to act on (which limit was hit, how to
84+
// get it raised), so print it alone — the `METHOD /path failed: 429` prefix
85+
// buries the remedy.
86+
if (err instanceof ApiError && err.status === 429) return err.detail
8387
return (err as Error).message
8488
}
8589

src/ui/ConnectApp.tsx

Lines changed: 113 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -500,21 +500,6 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
500500
return () => clearInterval(t)
501501
}, [working])
502502

503-
// The heartbeat behind every live ⏺ mark: one timer for the whole app, so
504-
// each pulsing glyph breathes in step instead of drifting out of phase. It
505-
// runs only while something is actually in flight — a still ⏺ on a settled
506-
// transcript would be a lie, and an idle interval would wake the render loop
507-
// for nothing. Reset on the way in so a new turn starts bright.
508-
const [pulseOn, setPulseOn] = useState(true)
509-
useEffect(() => {
510-
if (!working) {
511-
setPulseOn(true)
512-
return
513-
}
514-
const t = setInterval(() => setPulseOn((on) => !on), PULSE_MS)
515-
return () => clearInterval(t)
516-
}, [working])
517-
518503
// The tool calls executing right now (an unmatched tool_use in the committed
519504
// transcript — see pendingToolCalls), with a per-burst seconds ticker so a
520505
// long Bash call reads "Running Bash(pytest…)… (34s)" instead of dead air.
@@ -535,8 +520,10 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
535520
// Every in-flight send, oldest pipeline stage last, at the transcript's
536521
// bottom edge: 'accepted' (delivered, awaiting its echo record — full
537522
// colour), 'queued' (the server's pending inbox — dim), 'sending' (the
538-
// POST is in flight — dim). Local chips are multiset-subtracted by text so
539-
// a send never renders twice during the received-record handoff window.
523+
// POST is in flight — dim), 'cancelled' (taken by a turn that died without
524+
// answering it — see deliveredUnechoedSends). Local chips are multiset-
525+
// subtracted by text so a send never renders twice during the
526+
// received-record handoff window.
540527
const inFlightSends = useMemo(() => {
541528
const counts = new Map<string, number>()
542529
for (const m of serverQueued) counts.set(m, (counts.get(m) ?? 0) + 1)
@@ -547,11 +534,55 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
547534
else extras.push(q.text)
548535
}
549536
return [
550-
...acceptedSends.map((m) => ({ key: m.id, text: m.body, state: 'accepted' as const })),
537+
...acceptedSends.map((m) => ({
538+
key: m.id,
539+
text: m.body,
540+
state: m.cancelled ? ('cancelled' as const) : ('accepted' as const),
541+
})),
551542
...serverQueued.map((text, i) => ({ key: `sq${i}`, text, state: 'queued' as const })),
552543
...extras.map((text, i) => ({ key: `lq${i}`, text, state: 'sending' as const })),
553544
]
554545
}, [acceptedSends, serverQueued, queued])
546+
547+
// The session's opening prompt, shown as a queued row while the sandbox comes
548+
// up. A prompt given at creation is NOT an inbox message yet — the worker
549+
// inserts it as turn 0's message once Claude Code is running in the sandbox,
550+
// which can be minutes later — so without this the chat sits empty and the
551+
// message you just sent is nowhere on screen.
552+
//
553+
// It retires on the first message_received record: from there the inbox rows
554+
// (queued → delivered → the echo) are the truth for the same text, so the two
555+
// never both render. That record is also what keeps an OLD session's original
556+
// prompt out of the chat — its turn-0 message_received is in the feed, even
557+
// when --no-records hides the transcript itself.
558+
const pendingPrompt = useMemo(() => {
559+
if (items.length > 0) return null
560+
if (snapshot.records.some((r) => r.record_type === 'message_received')) return null
561+
const prompt = snapshot.session?.prompt
562+
return typeof prompt === 'string' && prompt.trim() ? prompt : null
563+
}, [items.length, snapshot.records, snapshot.session?.prompt])
564+
565+
// Whether a send is waiting on the agent — a queued row breathes while it
566+
// waits, like a running tool does.
567+
const sendsWaiting =
568+
pendingPrompt !== null ||
569+
inFlightSends.some((q) => q.state === 'queued' || q.state === 'sending')
570+
571+
// The heartbeat behind every live ⏺ mark: one timer for the whole app, so
572+
// each pulsing glyph breathes in step instead of drifting out of phase. It
573+
// runs only while something is actually in flight — a still ⏺ on a settled
574+
// transcript would be a lie, and an idle interval would wake the render loop
575+
// for nothing. Reset on the way in so a new turn starts bright.
576+
const [pulseOn, setPulseOn] = useState(true)
577+
const pulsing = working || sendsWaiting
578+
useEffect(() => {
579+
if (!pulsing) {
580+
setPulseOn(true)
581+
return
582+
}
583+
const t = setInterval(() => setPulseOn((on) => !on), PULSE_MS)
584+
return () => clearInterval(t)
585+
}, [pulsing])
555586
const [toolElapsed, setToolElapsed] = useState(0)
556587
const pendingToolKey = pendingTools.length > 0 ? pendingTools[0].key : null
557588
useEffect(() => {
@@ -864,12 +895,30 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
864895
),
865896
)
866897
}
898+
// The session's opening prompt while it is still only a start request: the
899+
// same queued row a mid-session send gets, so the message you sent is on
900+
// screen from the first frame.
901+
if (pendingPrompt) {
902+
out.push(
903+
...pendingMessageRows('prompt', pendingPrompt, cols, {
904+
gutter: LIVE_GLYPH,
905+
dim: true,
906+
right: 'queued',
907+
pulse: true,
908+
}),
909+
)
910+
}
867911
for (const q of inFlightSends.filter((q) => q.state !== 'accepted')) {
912+
const waiting = q.state !== 'cancelled'
868913
out.push(
869914
...pendingMessageRows(q.key, q.text, cols, {
870-
gutter: '◆',
915+
// A waiting send wears the breathing ⏺, the app's one "in flight"
916+
// mark; a cancelled one keeps the ◆ sender glyph — it was a real
917+
// message, it just never got answered.
918+
gutter: waiting ? LIVE_GLYPH : '◆',
871919
dim: true,
872-
right: q.state === 'sending' ? '(sending…)' : '(queued…)',
920+
right: q.state === 'sending' ? 'sending' : q.state === 'queued' ? 'queued' : 'cancelled',
921+
pulse: waiting,
873922
}),
874923
)
875924
}
@@ -885,6 +934,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
885934
expanded,
886935
openedKeys,
887936
inFlightSends,
937+
pendingPrompt,
888938
liveTail,
889939
cols,
890940
])
@@ -1617,17 +1667,23 @@ export function reshapeTranscript(
16171667
minRenderFeedSeq: number,
16181668
): { items: TranscriptItem[] } {
16191669
const items: TranscriptItem[] = []
1670+
// Index of the "Waking the session…" line still awaiting its outcome, so the
1671+
// resumed record can settle it in place instead of adding a second row. The
1672+
// line KEEPS ITS KEY, so settling it doesn't move the scroll anchor or the
1673+
// ↑/↓ walk.
1674+
let wakeAt = -1
16201675
for (const r of records) {
16211676
if (r.feed_seq <= minRenderFeedSeq) continue
16221677
if (r.source === 'lifecycle') {
1678+
if (r.record_type === 'session_resumed' && wakeAt >= 0) {
1679+
items[wakeAt] = { ...items[wakeAt], text: 'Session awake' }
1680+
wakeAt = -1
1681+
continue
1682+
}
16231683
const text = sessionLogText(r)
16241684
if (text) {
1625-
items.push({
1626-
key: `s${r.feed_seq}`,
1627-
kind: 'notice',
1628-
text,
1629-
spaceBefore: true,
1630-
})
1685+
items.push({ key: `s${r.feed_seq}`, kind: 'notice', text, spaceBefore: true })
1686+
wakeAt = text === 'Waking the session…' ? items.length - 1 : -1
16311687
}
16321688
continue
16331689
}
@@ -1652,22 +1708,25 @@ export function reshapeTranscript(
16521708
// The session milestones worth a line in the chat log, and how each reads.
16531709
// Deliberately a SHORT list of state changes a reader would otherwise be left
16541710
// guessing about:
1655-
// - the session parked between turns, and what wakes it
1711+
// - the session parked between turns
16561712
// - it is coming back up (a wake, or an infra retry after a wobble)
1657-
// - it came back and the conversation continues
16581713
// - it was stopped or cancelled
16591714
// Everything else the lifecycle feed carries is startup detail (sandbox phases,
16601715
// setup log chunks, per-phase timings) and belongs to the startup block up top,
16611716
// not the conversation — logging it would bury the chat in provisioning noise.
16621717
//
1718+
// A wake is ONE line, not two: "Waking the session…" is the same event as
1719+
// "Session awake" a few seconds later, so reshapeTranscript settles the waking
1720+
// line in place rather than adding a second row under it.
1721+
//
16631722
// `session_ready`-style milestones are deliberately absent for a FIRST start:
16641723
// the startup block already tells that story in place. A wake is different —
16651724
// it happens long after the block settled, mid-conversation. Pure, for tests.
16661725
export function sessionLogText(record: LifecycleRecordLike): string | null {
16671726
const p = record.payload
16681727
switch (record.record_type) {
16691728
case 'session_idle':
1670-
return 'Session asleep — your next message wakes it'
1729+
return 'Session asleep'
16711730
case 'session_starting': {
16721731
// Only a WAKE is logged: the first start is the startup block's story.
16731732
const wake = typeof p.wake_index === 'number' ? p.wake_index : 0
@@ -1680,7 +1739,7 @@ export function sessionLogText(record: LifecycleRecordLike): string | null {
16801739
? `Retrying · ${p.reason}`
16811740
: 'Retrying after a transient error…'
16821741
case 'session_resumed':
1683-
return 'Session awake — picking up where it left off'
1742+
return 'Session awake'
16841743
case 'session_cancelled': {
16851744
const reason = typeof p.reason === 'string' && p.reason ? ` · ${p.reason}` : ''
16861745
return `Session cancelled${reason}`
@@ -1733,27 +1792,43 @@ export function awaitingAgentPhase(
17331792
// but the agent's echo record can lag by a whole sandbox wake — without this
17341793
// bridge a send flashes and vanishes for the gap. Rendered as full-colour
17351794
// user rows at the transcript's bottom edge (the mid-turn send is part of the
1736-
// running turn, Claude Code-style). Pure, for tests.
1795+
// running turn, Claude Code-style).
1796+
//
1797+
// `cancelled` means the turn that took the message DIED without answering it —
1798+
// the /stop path, where the backend deliberately does not requeue an
1799+
// interrupted turn's messages (the message is consumed, the answer never
1800+
// comes). Rendered "cancelled" rather than left breathing forever, which is the
1801+
// bug this distinction fixes. A message_requeued instead puts the message back
1802+
// in the inbox, so it is queued again, not cancelled. Pure, for tests.
17371803
export function deliveredUnechoedSends(
17381804
records: readonly LifecycleRecordLike[],
1739-
): { id: string; body: string }[] {
1805+
): { id: string; body: string; cancelled: boolean }[] {
17401806
const received = new Map<string, string>()
1741-
const delivered = new Set<string>()
1807+
// Message id -> the turn that consumed it, for the turn_failed correlation.
1808+
const delivered = new Map<string, string>()
1809+
const failedTurns = new Set<string>()
17421810
const echoed = new Set<string>()
17431811
for (const r of records) {
17441812
if (r.session_message_id != null) echoed.add(r.session_message_id)
17451813
if (r.source !== 'lifecycle') continue
1814+
if (r.record_type === 'turn_failed') {
1815+
if (typeof r.payload.turn_id === 'string') failedTurns.add(r.payload.turn_id)
1816+
continue
1817+
}
17461818
const id = typeof r.payload.message_id === 'string' ? r.payload.message_id : null
17471819
if (!id) continue
17481820
if (r.record_type === 'message_received') {
17491821
if (!received.has(id))
17501822
received.set(id, typeof r.payload.body === 'string' ? r.payload.body : '')
1751-
} else if (r.record_type === 'message_delivered') delivered.add(id)
1752-
else if (r.record_type === 'message_requeued') delivered.delete(id)
1823+
} else if (r.record_type === 'message_delivered') {
1824+
delivered.set(id, typeof r.payload.turn_id === 'string' ? r.payload.turn_id : '')
1825+
} else if (r.record_type === 'message_requeued') delivered.delete(id)
17531826
}
1754-
const out: { id: string; body: string }[] = []
1827+
const out: { id: string; body: string; cancelled: boolean }[] = []
17551828
for (const [id, body] of received) {
1756-
if (delivered.has(id) && !echoed.has(id)) out.push({ id, body })
1829+
const turnId = delivered.get(id)
1830+
if (turnId === undefined || echoed.has(id)) continue
1831+
out.push({ id, body, cancelled: failedTurns.has(turnId) })
17571832
}
17581833
return out
17591834
}
@@ -1889,7 +1964,7 @@ export function deriveSandboxState(
18891964
}
18901965
case 'session_idle': {
18911966
seen = true
1892-
headline = 'Session idle — your next message wakes it'
1967+
headline = 'Session asleep'
18931968
done = true
18941969
break
18951970
}

src/ui/transcriptRows.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -367,11 +367,14 @@ export function activityRows(
367367

368368
// An in-flight send, or the streaming assistant response: the same panel a
369369
// committed message sits on, so nothing shifts when the real record lands.
370+
// `pulse` marks the send as still in flight — the same breathing ⏺ a running
371+
// tool wears, so a message the agent hasn't answered yet never reads as settled
372+
// conversation.
370373
export function pendingMessageRows(
371374
key: string,
372375
text: string,
373376
cols: number,
374-
opts: { gutter: string; dim?: boolean; bold?: boolean; right?: string },
377+
opts: { gutter: string; dim?: boolean; bold?: boolean; right?: string; pulse?: boolean },
375378
): TranscriptRow[] {
376379
const width = contentWidth(cols, { panel: true })
377380
const rows: TranscriptRow[] = [spacerRow(key, `${key}:sp`)]
@@ -382,11 +385,12 @@ export function pendingMessageRows(
382385
entryKey: key,
383386
gutter:
384387
i === 0 && opts.gutter
385-
? { text: opts.gutter, color: theme.foreground, dim: opts.dim }
388+
? { text: opts.gutter, color: theme.foreground, dim: opts.dim, pulse: opts.pulse }
386389
: undefined,
387390
spans: [{ text: line, dim: opts.dim, bold: opts.bold }],
388391
right: i === lines.length - 1 && opts.right ? { text: opts.right, dim: true } : undefined,
389392
panel: true,
393+
pulse: i === 0 ? opts.pulse : undefined,
390394
})
391395
}
392396
return rows

test/connect-app.test.ts

Lines changed: 36 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ describe('deriveSandboxState', () => {
251251
],
252252
0,
253253
)
254-
expect(state?.headline).toBe('Session idle — your next message wakes it')
254+
expect(state?.headline).toBe('Session asleep')
255255
expect(state?.done).toBe(true)
256256
})
257257

@@ -345,19 +345,33 @@ describe('awaitingAgentPhase', () => {
345345

346346
describe('deliveredUnechoedSends', () => {
347347
const received = (id: string, body: string) => rec('message_received', { message_id: id, body })
348-
const delivered = (id: string) => rec('message_delivered', { message_id: id })
348+
const delivered = (id: string, turn = 't1') =>
349+
rec('message_delivered', { message_id: id, turn_id: turn })
349350
const requeued = (id: string) => rec('message_requeued', { message_id: id })
351+
const turnFailed = (turn = 't1') => rec('turn_failed', { turn_id: turn, turn_index: 0 })
350352
const echo = (id: string | null) => ({
351353
...rec('user', {}, 'claude_code'),
352354
session_message_id: id,
353355
})
354356

355357
it('bridges the gap between delivery and the user-echo record', () => {
356358
expect(deliveredUnechoedSends([received('m1', 'hi'), delivered('m1')])).toEqual([
357-
{ id: 'm1', body: 'hi' },
359+
{ id: 'm1', body: 'hi', cancelled: false },
358360
])
359361
})
360362

363+
it('marks a send cancelled when the turn that took it died unanswered', () => {
364+
expect(
365+
deliveredUnechoedSends([received('m1', 'hi'), delivered('m1', 't7'), turnFailed('t7')]),
366+
).toEqual([{ id: 'm1', body: 'hi', cancelled: true }])
367+
})
368+
369+
it('leaves a send waiting when a DIFFERENT turn failed', () => {
370+
expect(
371+
deliveredUnechoedSends([received('m1', 'hi'), delivered('m1', 't7'), turnFailed('t8')]),
372+
).toEqual([{ id: 'm1', body: 'hi', cancelled: false }])
373+
})
374+
361375
it('retires the send once its echo record lands', () => {
362376
expect(deliveredUnechoedSends([received('m1', 'hi'), delivered('m1'), echo('m1')])).toEqual([])
363377
})
@@ -379,8 +393,8 @@ describe('deliveredUnechoedSends', () => {
379393
echo(null),
380394
]),
381395
).toEqual([
382-
{ id: 'm1', body: 'first' },
383-
{ id: 'm2', body: 'second' },
396+
{ id: 'm1', body: 'first', cancelled: false },
397+
{ id: 'm2', body: 'second', cancelled: false },
384398
])
385399
})
386400
})
@@ -434,24 +448,27 @@ describe('reshapeTranscript', () => {
434448
expect(items[1].isError).toBe(true)
435449
})
436450

437-
it('logs the session going to sleep and waking, in feed order', () => {
438-
const { items } = reshapeTranscript(
439-
[
440-
assistant('done for now'),
441-
rec('session_idle'),
442-
rec('session_starting', { wake_index: 1 }),
443-
rec('session_resumed'),
444-
assistant('back'),
445-
],
446-
0,
447-
)
448-
expect(items.map((i) => i.text)).toEqual([
451+
it('settles the waking line in place instead of logging the wake twice', () => {
452+
const records = [
453+
assistant('done for now'),
454+
rec('session_idle'),
455+
rec('session_starting', { wake_index: 1 }),
456+
]
457+
const waking = reshapeTranscript(records, 0)
458+
expect(waking.items.map((i) => i.text)).toEqual([
449459
'done for now',
450-
'Session asleep — your next message wakes it',
460+
'Session asleep',
451461
'Waking the session…',
452-
'Session awake — picking up where it left off',
462+
])
463+
const awake = reshapeTranscript([...records, rec('session_resumed'), assistant('back')], 0)
464+
expect(awake.items.map((i) => i.text)).toEqual([
465+
'done for now',
466+
'Session asleep',
467+
'Session awake',
453468
'back',
454469
])
470+
// Same key, so settling the line can't slide the scroll anchor.
471+
expect(awake.items[2].key).toBe(waking.items[2].key)
455472
})
456473

457474
it('leaves startup detail out of the chat — that story is the startup block', () => {

0 commit comments

Comments
 (0)