Skip to content

Commit d9e56fd

Browse files
committed
feat: track and display turn and command execution durations in the Codex thread UI
1 parent 1696c14 commit d9e56fd

8 files changed

Lines changed: 198 additions & 41 deletions

File tree

src/components/agent/codex-agent-card.tsx

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export function CodexAgentCard({
5858
header,
5959
isSelected,
6060
}: CodexAgentCardProps) {
61-
const { events, threadStatusMap, activeThreadIds } = useCodexStore();
61+
const { events, threadStatusMap, turnTimingMap, activeThreadIds } = useCodexStore();
6262
const { pendingApprovals } = useApprovalStore();
6363
const { pendingRequests } = useRequestUserInputStore();
6464
const { setCurrentAgentCardId, updateCard } = useAgentCenterStore();
@@ -82,20 +82,28 @@ export function CodexAgentCard({
8282
const canApplyWorktree =
8383
!!card.worktreePath && !!cwd && !processing && !hasPending && worktreeHasChanges;
8484

85+
// turnTimingMap is the single source of truth for turn timing (see TurnTiming
86+
// in useCodexStore) — driven directly by turn/started + turn/completed + error,
87+
// so it stays correct even when a turn ends via error/interrupt, unlike scanning
88+
// raw events which could disagree with threadStatusMap's active/idle flip.
89+
const turnTiming = turnTimingMap[card.id];
90+
const turnInProgress = turnTiming?.status === 'inProgress';
8591
const [elapsed, setElapsed] = useState(0);
86-
const processingStartRef = useRef<number | null>(null);
87-
const wasProcessingRef = useRef(processing);
8892

89-
if (processing && !wasProcessingRef.current) {
90-
processingStartRef.current = Date.now();
91-
}
92-
wasProcessingRef.current = processing;
93+
useEffect(() => {
94+
if (!turnInProgress || !turnTiming) {
95+
setElapsed(0);
96+
return;
97+
}
98+
99+
const { startedAtMs } = turnTiming;
100+
setElapsed(Date.now() - startedAtMs);
101+
const intervalId = setInterval(() => {
102+
setElapsed(Date.now() - startedAtMs);
103+
}, 200);
93104

94-
if (processing && processingStartRef.current) {
95-
setElapsed(Date.now() - processingStartRef.current);
96-
} else if (!processing) {
97-
setElapsed(0);
98-
}
105+
return () => clearInterval(intervalId);
106+
}, [turnInProgress, turnTiming]);
99107

100108
useEffect(() => {
101109
const el = scrollRef.current;
@@ -125,7 +133,7 @@ export function CodexAgentCard({
125133
}, [card.worktreePath, cwd]);
126134

127135
const handleStop = async () => {
128-
const turnId = getCodexActiveTurnId(threadEvents);
136+
const turnId = getCodexActiveTurnId(turnTiming);
129137
if (turnId) await codexService.turnInterrupt(card.id, turnId);
130138
};
131139

@@ -194,9 +202,9 @@ export function CodexAgentCard({
194202
<div className="flex items-center justify-between px-2 py-1 border-t bg-muted/20 shrink-0">
195203
<div className="flex items-center gap-2">
196204
<span
197-
className={`text-[10px] font-mono tabular-nums ${processing ? 'text-green-500' : 'text-muted-foreground/50'}`}
205+
className={`text-[10px] font-mono tabular-nums ${turnInProgress ? 'text-green-500' : 'text-muted-foreground/50'}`}
198206
>
199-
{processing && fmtElapsed(elapsed)}
207+
{turnInProgress && fmtElapsed(elapsed)}
200208
</span>
201209
{tokens !== null && (
202210
<span className="text-[10px] text-muted-foreground/40">{fmtTokens(tokens)} tok</span>

src/components/agent/utils.ts

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,19 @@
11
// ─── Codex Utilities ──────────────────────────────────────────────────────
22

33
import { ServerNotification } from "@/bindings";
4+
import type { TurnTiming } from "@/components/codex/stores/useCodexStore";
45

56
/**
6-
* Extracts the current active turn ID from events.
7-
* Returns null if no active turn found.
7+
* Extracts the current active turn ID from a TurnTiming record.
8+
* Returns null if there is no in-progress turn.
9+
*
10+
* Prefer this over scanning raw events: turnTimingMap is updated directly by
11+
* turn/started + turn/completed + error, so it can't disagree with itself
12+
* the way independently scanning the events array for those same methods can
13+
* (e.g. when an error notification arrives without a matching turn/completed).
814
*/
9-
export function getCodexActiveTurnId(events: ServerNotification[]): string | null {
10-
for (let i = events.length - 1; i >= 0; i--) {
11-
const e = events[i];
12-
if (e.method === 'turn/started') {
13-
return (e.params as { turn: { id: string } }).turn.id;
14-
}
15-
if (e.method === 'turn/completed' || e.method === 'error') {
16-
return null;
17-
}
18-
}
19-
return null;
15+
export function getCodexActiveTurnId(timing: TurnTiming | undefined): string | null {
16+
return timing?.status === 'inProgress' ? timing.turnId : null;
2017
}
2118

2219
/**
@@ -66,11 +63,12 @@ export function fmtTokens(n: number): string {
6663

6764
/**
6865
* Formats elapsed time into minutes:seconds.
69-
* @param s - Elapsed time in seconds.
66+
* @param ms - Elapsed time in milliseconds.
7067
* @returns Formatted string like "5:30" or "0:30".
7168
*/
72-
export function fmtElapsed(s: number): string {
73-
const m = Math.floor(s / 60);
74-
const sec = s % 60;
69+
export function fmtElapsed(ms: number): string {
70+
const totalSec = Math.max(0, Math.floor(ms / 1000));
71+
const m = Math.floor(totalSec / 60);
72+
const sec = totalSec % 60;
7573
return m > 0 ? `${m}:${String(sec).padStart(2, '0')}` : `0:${String(sec).padStart(2, '0')}`;
7674
}

src/components/codex/CodexThread.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { useRef, useEffect, useState, useMemo, type ReactNode } from 'react';
22
import { useCodexStore } from '@/components/codex/stores';
3-
import { useIsProcessing } from '@/components/codex/hooks';
43
import { ScrollArea } from '@/components/ui/scroll-area';
54
import { renderEvent } from './items';
65
import { ApprovalItem } from './items/ApprovalItem';
@@ -12,6 +11,7 @@ import { codexService } from '@/services/codexService';
1211
import { Button } from '@/components/ui/button';
1312
import type { CommandAction } from '@/bindings/v2';
1413
import type { ServerNotification } from '@/bindings';
14+
import { WorkingIndicator } from './widget/WorkingIndicator';
1515

1616
// Intermediate render item: either a raw event or an aggregated command group.
1717
type RenderItem =
@@ -96,13 +96,13 @@ function deriveRenderItems(events: ServerNotification[]): RenderItem[] {
9696
}
9797

9898
export function CodexThread({ hideComposer = false }: { hideComposer?: boolean } = {}) {
99-
const { currentThreadId, events, hasAccount, activeThreadIds } = useCodexStore();
99+
const { currentThreadId, events, hasAccount, activeThreadIds, turnTimingMap } = useCodexStore();
100100
const isLive = !!currentThreadId && activeThreadIds.includes(currentThreadId);
101-
const isProcessing = useIsProcessing();
102101
const bottomAnchorRef = useRef<HTMLDivElement>(null);
103102

104103
// Get events for the current thread
105104
const currentThreadEvents = currentThreadId ? events[currentThreadId] || [] : [];
105+
const turnTiming = currentThreadId ? turnTimingMap[currentThreadId] : undefined;
106106

107107
// Auto-scroll to bottom when new events arrive
108108
useEffect(() => {
@@ -165,9 +165,7 @@ export function CodexThread({ hideComposer = false }: { hideComposer?: boolean }
165165
))}
166166
<ApprovalItem />
167167
{currentThreadEvents.length === 0 && hasAccount === false && <CodexAuth />}
168-
{isProcessing && (
169-
<div className="text-sm text-muted-foreground animate-pulse">Thinking</div>
170-
)}
168+
<WorkingIndicator turnTiming={turnTiming} />
171169
<RequestUserInputItem currentThreadId={currentThreadId} />
172170
<div ref={bottomAnchorRef} aria-hidden="true" />
173171
</div>

src/components/codex/items/McpToolCallItem.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useState } from "react";
22
import { ThreadItem } from "@/bindings/v2";
33
import { Badge } from "@/components/ui/badge";
44
import { Loader2, X, ChevronDown, ChevronRight } from "lucide-react";
5+
import { fmtElapsed } from "@/components/agent/utils";
56

67
type Props = {
78
item: ThreadItem
@@ -48,6 +49,12 @@ export function McpToolCallItem({ item }: Props) {
4849
{item.mcpAppResourceUri}
4950
</div>
5051
)}
52+
53+
{status !== 'inProgress' && typeof item.durationMs === 'number' && (
54+
<span className="ml-auto text-xs text-neutral-400 font-mono shrink-0">
55+
{fmtElapsed(item.durationMs)}
56+
</span>
57+
)}
5158
</div>
5259

5360
{isExpanded && hasDetails && (

src/components/codex/items/ShellCommand.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
55
import { useCodexStore } from '@/components/codex/stores';
66
import { Badge } from '@/components/ui/badge';
77
import { useTranslation } from 'react-i18next';
8+
import { fmtElapsed } from '@/components/agent/utils';
89

910
interface ShellCommandProps {
1011
command: string;
@@ -47,8 +48,9 @@ export const ShellCommand = ({ command, commandItemId, aggregatedOutput }: Shell
4748
}
4849
};
4950

50-
const { commandStatusMap } = useCodexStore();
51+
const { commandStatusMap, commandDurationMap } = useCodexStore();
5152
const status = commandItemId ? commandStatusMap[commandItemId] : undefined;
53+
const durationMs = commandItemId ? commandDurationMap[commandItemId] : undefined;
5254
const { variant, icon: Icon } = STATUS_STYLE_MAP[status ?? ''] ?? DEFAULT_STYLE;
5355

5456
return (
@@ -114,7 +116,12 @@ export const ShellCommand = ({ command, commandItemId, aggregatedOutput }: Shell
114116
</div>
115117
)}
116118

117-
<div className="flex justify-end p-2">
119+
<div className="flex items-center justify-end gap-2 p-2">
120+
{typeof durationMs === 'number' && (
121+
<span className="text-xs text-muted-foreground/60 font-mono">
122+
{fmtElapsed(durationMs)}
123+
</span>
124+
)}
118125
<Badge
119126
variant={variant}
120127
className="flex items-center gap-1.5 px-2.5 py-1 w-fit"

src/components/codex/stores/useCodexStore.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,28 @@ import type { ServerNotification } from '@/bindings';
33
import type { ThreadStatus, CommandExecutionStatus, Thread, ThreadTokenUsage } from '@/bindings/v2';
44
import { 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+
628
type DeltaMethod = 'item/agentMessage/delta';
729

830
type 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

Comments
 (0)