@@ -73,6 +73,7 @@ import type { StreamRow } from "./stream.js"
7373import { advanceRevealChars , flattenReasoningText , type Thought } from "./thinking.js"
7474import {
7575 agentProgress ,
76+ clockLabel ,
7677 fleetProgress ,
7778 type AgentProgressSession ,
7879} from "./agent-progress.js"
@@ -356,6 +357,12 @@ type BridgeBag = {
356357 now : ( ) => number
357358 /** Transcript row each in-flight call occupies, so its result can resolve it. */
358359 toolRows : Map < string , number >
360+ /**
361+ * When each in-flight ordinary tool call started, so its row can carry a
362+ * live elapsed clock instead of sitting on a static pending mark for the
363+ * length of a slow call — the one case a healthy turn reads as dead.
364+ */
365+ toolCallStartedAt : Map < string , number >
359366 /** Row of the newest in-flight call, for results that carry no call id. */
360367 lastToolRow : number
361368 /**
@@ -564,7 +571,15 @@ function applyToolCall(
564571 } else {
565572 appendStreamRow ( shell , row )
566573 }
567- if ( event . callId !== undefined ) bag . toolRows . set ( event . callId , index )
574+ if ( event . callId !== undefined ) {
575+ bag . toolRows . set ( event . callId , index )
576+ // A diff call's row already carries a "+n/-n" stat — that is the fact
577+ // worth keeping, not an elapsed clock, so only ordinary calls (no stat of
578+ // their own) pick up the live timer.
579+ if ( row . stat === undefined ) {
580+ bag . toolCallStartedAt . set ( event . callId , bag . now ( ) )
581+ }
582+ }
568583 if ( event . callId !== undefined && event . name === TASK_TOOL_NAME ) {
569584 bag . taskCallIds . add ( event . callId )
570585 }
@@ -590,13 +605,20 @@ function applyToolResult(
590605 } )
591606 const tracked =
592607 event . callId !== undefined ? bag . toolRows . get ( event . callId ) : undefined
608+ // The elapsed clock was scaffolding for the wait, not a fact about the
609+ // call — clear it before the merge so it never crowds out the answer's own
610+ // addendum (e.g. "3 lines") the way a diff's own +/- count is allowed to.
611+ const clockOwned =
612+ event . callId !== undefined && bag . toolCallStartedAt . has ( event . callId )
593613 if ( event . callId !== undefined ) {
594614 bag . toolRows . delete ( event . callId )
615+ bag . toolCallStartedAt . delete ( event . callId )
595616 bag . taskCallIds . delete ( event . callId )
596617 }
597618 if ( bag . toolRows . size === 0 ) shell . inFlightTool = null
598619 const index = tracked ?? bag . lastToolRow
599- const call = streamRowAt ( shell , index )
620+ const rawCall = streamRowAt ( shell , index )
621+ const call = clockOwned && rawCall !== undefined ? omitStat ( rawCall ) : rawCall
600622 if ( call === undefined || call . pending !== true ) {
601623 appendStreamRow ( shell , result )
602624 return
@@ -642,6 +664,40 @@ function syncAgentProgress(
642664 }
643665}
644666
667+ /** Drop `stat` entirely rather than set it `undefined` (exactOptionalPropertyTypes). */
668+ function omitStat ( row : StreamRow ) : StreamRow {
669+ const { stat : _stat , ...rest } = row
670+ return rest
671+ }
672+
673+ /**
674+ * Refresh every plain in-flight tool call's row with how long it has been
675+ * running. A `task` dispatch already gets this (and more) from
676+ * `syncAgentProgress`, so those calls are skipped here rather than double
677+ * painted. Without a live clock an ordinary call's row sits on a static
678+ * pending mark for however long the tool takes — indistinguishable from a
679+ * hung turn once that stretches past a few seconds.
680+ */
681+ function syncToolElapsed ( shell : AppShell , bag : BridgeBag , nowMs : number ) : void {
682+ if ( bag . toolCallStartedAt . size === 0 ) return
683+ for ( const [ callId , startedAt ] of bag . toolCallStartedAt ) {
684+ if ( bag . taskCallIds . has ( callId ) ) continue
685+ const index = bag . toolRows . get ( callId )
686+ if ( index === undefined ) {
687+ bag . toolCallStartedAt . delete ( callId )
688+ continue
689+ }
690+ const row = streamRowAt ( shell , index )
691+ if ( row === undefined || row . pending !== true ) {
692+ bag . toolCallStartedAt . delete ( callId )
693+ continue
694+ }
695+ const stat = clockLabel ( nowMs - startedAt )
696+ if ( row . stat === stat ) continue
697+ replaceStreamRowAt ( shell , index , { ...row , stat } )
698+ }
699+ }
700+
645701/**
646702 * Retract everything the failed attempt painted, then forget the row
647703 * bookkeeping that pointed into it — a rolled-back tool call has no row left
@@ -655,6 +711,7 @@ function rollbackAttempt(shell: AppShell, bag: BridgeBag): void {
655711 for ( const [ callId , index ] of [ ...bag . toolRows ] ) {
656712 if ( index >= boundary ) {
657713 bag . toolRows . delete ( callId )
714+ bag . toolCallStartedAt . delete ( callId )
658715 bag . taskCallIds . delete ( callId )
659716 }
660717 }
@@ -802,6 +859,7 @@ export function attachSessionBridge(
802859 quotaFired : false ,
803860 now,
804861 toolRows : new Map ( ) ,
862+ toolCallStartedAt : new Map ( ) ,
805863 lastToolRow : - 1 ,
806864 taskCallIds : new Set ( ) ,
807865 agentSessions : [ ] ,
@@ -869,6 +927,17 @@ export function attachSessionBridge(
869927
870928 const paintPhaseAt = ( nowMs : number , isStalled : boolean ) : void => {
871929 const turn = bag . turn
930+ // The stall notice is a live diagnosis, not a sticky banner: it has to
931+ // set *and* clear on every paint — including handle() — because the
932+ // cadence timer is cancelled the moment the turn settles. If we only
933+ // touched it from tick(), a tool.done → inference.done burst that lands
934+ // before the next tick would leave the banner up forever.
935+ const level = stallLevel ( stallArgsFor ( nowMs ) )
936+ if ( level === "notice" ) {
937+ setStatusFlash ( shell , STALL_NOTICE_MESSAGE )
938+ } else if ( shell . statusFlash === STALL_NOTICE_MESSAGE ) {
939+ setStatusFlash ( shell , null )
940+ }
872941 // The landing mark rides this same re-entry: it animates through the
873942 // draw/fill loop while a turn is live and holds its filled frame otherwise.
874943 paintLanding ( shell , nowMs , turn . isProcessing )
@@ -878,6 +947,7 @@ export function attachSessionBridge(
878947 if ( bag . openRow !== null && bag . openRow . kind === "thinking" ) {
879948 advanceOpenReveal ( shell , bag . openRow , nowMs )
880949 }
950+ syncToolElapsed ( shell , bag , nowMs )
881951 const input = {
882952 isProcessing : turn . isProcessing ,
883953 status : turn . status ,
@@ -1157,13 +1227,6 @@ export function attachSessionBridge(
11571227 return
11581228 }
11591229
1160- // Notice only — the phase still paints below, because a ramp that stops
1161- // moving is the very thing that reads as a hang.
1162- const level = stallLevel ( stallArgs )
1163- if ( level === "notice" ) {
1164- setStatusFlash ( shell , STALL_NOTICE_MESSAGE )
1165- }
1166-
11671230 // Same "is this stalled at all" question `paintPhase` asks above — call
11681231 // the one definition (`isStalledForDisplay`) rather than re-deriving it
11691232 // from `stallLevel`'s result, so the two call sites can never disagree.
0 commit comments