@@ -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.
16661725export 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.
17371803export 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 }
0 commit comments