@@ -3,6 +3,28 @@ import type { ServerNotification } from '@/bindings';
33import type { ThreadStatus , CommandExecutionStatus , Thread , ThreadTokenUsage } from '@/bindings/v2' ;
44import { codexService } from '@/services/codexService' ;
55
6+ /**
7+ * Per-thread turn timing, tracked as the single source of truth for
8+ * "how long is this turn taking / how long did it take".
9+ *
10+ * Sourced directly from turn/started + turn/completed + error notifications,
11+ * independent of thread/status/changed. thread/status/changed only carries
12+ * active/idle/error flags with no timestamps, and can race with turn events
13+ * (e.g. on error/interrupt), so deriving elapsed time by scanning the raw
14+ * events array for turn/started vs turn/completed produced stale/incorrect
15+ * timers when the two streams disagreed. Keeping timing here, updated
16+ * directly by the turn lifecycle events, removes that race entirely.
17+ */
18+ export interface TurnTiming {
19+ turnId : string ;
20+ /** ms since epoch, from turn/started's turn.startedAt (server clock). */
21+ startedAtMs : number ;
22+ /** Set once the turn completes/fails/is interrupted (turn/completed). */
23+ durationMs : number | null ;
24+ /** Last known turn status; 'inProgress' while active. */
25+ status : 'inProgress' | 'completed' | 'interrupted' | 'failed' ;
26+ }
27+
628type DeltaMethod = 'item/agentMessage/delta' ;
729
830type DeltaEvent = Extract < ServerNotification , { method : DeltaMethod } > ;
@@ -61,8 +83,12 @@ interface CodexStore {
6183 events : Record < string , ServerNotification [ ] > ; // Events per thread
6284 /** Per-thread status derived from thread/status/changed (authoritative) and turn events (fallback) */
6385 threadStatusMap : Record < string , ThreadStatus > ;
86+ /** Per-thread timing of the most recent turn, see TurnTiming for why this is separate from threadStatusMap. */
87+ turnTimingMap : Record < string , TurnTiming > ;
6488 /** Command status map: itemId -> CommandExecutionStatus */
6589 commandStatusMap : Record < string , CommandExecutionStatus > ;
90+ /** Command duration map: itemId -> durationMs, populated on item/completed for commandExecution. */
91+ commandDurationMap : Record < string , number | null > ;
6692 activeThreadIds : string [ ] ; // Track resumed/active threads
6793 inputFocusTrigger : number ; // Increment to trigger focus in InputArea
6894 threadListNextCursor : string | null ;
@@ -100,7 +126,9 @@ export const useCodexStore = create<CodexStore>((set, get) => ({
100126 hasAccount : null ,
101127 events : { } ,
102128 threadStatusMap : { } ,
129+ turnTimingMap : { } ,
103130 commandStatusMap : { } ,
131+ commandDurationMap : { } ,
104132 activeThreadIds : [ ] ,
105133 inputFocusTrigger : 0 ,
106134 threadListNextCursor : null ,
@@ -169,8 +197,57 @@ export const useCodexStore = create<CodexStore>((set, get) => ({
169197 threadStatusMap = { ...threadStatusMap , [ threadId ] : event . params . status } ;
170198 }
171199
200+ // Update turn timing map: single source of truth for turn elapsed/duration,
201+ // independent of thread/status/changed (see TurnTiming doc comment).
202+ let turnTimingMap = state . turnTimingMap ;
203+ if ( event . method === 'turn/started' ) {
204+ const { turn } = event . params ;
205+ turnTimingMap = {
206+ ...turnTimingMap ,
207+ [ threadId ] : {
208+ turnId : turn . id ,
209+ startedAtMs : typeof turn . startedAt === 'number' ? turn . startedAt * 1000 : Date . now ( ) ,
210+ durationMs : null ,
211+ status : 'inProgress' ,
212+ } ,
213+ } ;
214+ } else if ( event . method === 'turn/completed' ) {
215+ const { turn } = event . params ;
216+ const existing = turnTimingMap [ threadId ] ;
217+ // Only update if this completion matches the turn we're tracking (or we have none tracked).
218+ if ( ! existing || existing . turnId === turn . id ) {
219+ turnTimingMap = {
220+ ...turnTimingMap ,
221+ [ threadId ] : {
222+ turnId : turn . id ,
223+ startedAtMs :
224+ existing ?. startedAtMs ??
225+ ( typeof turn . startedAt === 'number' ? turn . startedAt * 1000 : Date . now ( ) ) ,
226+ durationMs : turn . durationMs ,
227+ status : turn . status === 'inProgress' ? 'completed' : turn . status ,
228+ } ,
229+ } ;
230+ }
231+ } else if ( event . method === 'error' ) {
232+ // Standalone error notification: if it targets the turn we're tracking
233+ // and no turn/completed has landed yet, mark it failed so the UI stops
234+ // showing "Working..." even if turn/completed never arrives.
235+ const existing = turnTimingMap [ threadId ] ;
236+ if ( existing && existing . turnId === event . params . turnId && existing . status === 'inProgress' ) {
237+ turnTimingMap = {
238+ ...turnTimingMap ,
239+ [ threadId ] : {
240+ ...existing ,
241+ durationMs : Date . now ( ) - existing . startedAtMs ,
242+ status : 'failed' ,
243+ } ,
244+ } ;
245+ }
246+ }
247+
172248 // Update command status map
173249 let commandStatusMap = state . commandStatusMap ;
250+ let commandDurationMap = state . commandDurationMap ;
174251 if ( event . method === 'item/started' && event . params . item ?. type === 'commandExecution' ) {
175252 commandStatusMap = {
176253 ...commandStatusMap ,
@@ -181,12 +258,18 @@ export const useCodexStore = create<CodexStore>((set, get) => ({
181258 ...commandStatusMap ,
182259 [ event . params . item . id ] : event . params . item . status ,
183260 } ;
261+ commandDurationMap = {
262+ ...commandDurationMap ,
263+ [ event . params . item . id ] : event . params . item . durationMs ,
264+ } ;
184265 }
185266
186267 return {
187268 events : newEvents ,
188269 threadStatusMap,
270+ turnTimingMap,
189271 commandStatusMap,
272+ commandDurationMap,
190273 } ;
191274 } ) ;
192275 } ,
0 commit comments