Skip to content

Commit 6351db0

Browse files
committed
refactor(cli): improve elapsed time tracking with imperative hooks
- Transform useElapsedTime to imperative API with start/stop methods - Add useElapsedTimeFrom for declarative backward compatibility - Simplify StatusIndicator props from isProcessing+showThinking to isActive - Replace mainAgentStreamStartTime state with mainAgentTimer tracker - Update useSendMessage to use timer.start/stop methods - Add tests for new timer interface
1 parent e19a77e commit 6351db0

File tree

6 files changed

+124
-47
lines changed

6 files changed

+124
-47
lines changed

cli/src/chat.tsx

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { SLASH_COMMANDS } from './data/slash-commands'
2727
import { useAgentValidation } from './hooks/use-agent-validation'
2828
import { useAuthQuery, useLogoutMutation } from './hooks/use-auth-query'
2929
import { useClipboard } from './hooks/use-clipboard'
30+
import { useElapsedTime } from './hooks/use-elapsed-time'
3031
import { useInputHistory } from './hooks/use-input-history'
3132
import { useKeyboardHandlers } from './hooks/use-keyboard-handlers'
3233
import { useLogo } from './hooks/use-logo'
@@ -470,9 +471,8 @@ export const App = ({
470471

471472
const { clipboardMessage } = useClipboard()
472473

473-
// Track main agent streaming start time for elapsed time display
474-
const [mainAgentStreamStartTime, setMainAgentStreamStartTime] =
475-
useState<number | null>(null)
474+
// Track main agent streaming elapsed time
475+
const mainAgentTimer = useElapsedTime()
476476

477477

478478
const agentRefsMap = useRef<Map<string, any>>(new Map())
@@ -829,7 +829,7 @@ export const App = ({
829829
abortControllerRef,
830830
agentId,
831831
onBeforeMessageSend: validateAgents,
832-
setMainAgentStreamStartTime,
832+
mainAgentTimer,
833833
scrollToLatest,
834834
availableWidth: separatorWidth,
835835
})
@@ -852,13 +852,12 @@ export const App = ({
852852
return undefined
853853
}, [initialPrompt, agentMode])
854854

855-
// Show thinking indicator even after waiting ends if we're still streaming
856-
const showThinking = isStreaming && !isWaitingForResponse
855+
// Status is active when waiting for response or streaming
856+
const isStatusActive = isWaitingForResponse || isStreaming
857857
const hasStatus = useHasStatus(
858-
isWaitingForResponse,
858+
isStatusActive,
859859
clipboardMessage,
860-
showThinking,
861-
mainAgentStreamStartTime,
860+
mainAgentTimer.startTime,
862861
)
863862

864863
const handleSubmit = useCallback(() => {
@@ -989,7 +988,7 @@ export const App = ({
989988
collapsedAgents,
990989
streamingAgents,
991990
isWaitingForResponse,
992-
streamStartTime: mainAgentStreamStartTime,
991+
streamStartTime: mainAgentTimer.startTime,
993992
setCollapsedAgents,
994993
setFocusedAgentId,
995994
registerAgentRef,
@@ -1014,11 +1013,10 @@ export const App = ({
10141013

10151014
const statusIndicatorNode = (
10161015
<StatusIndicator
1017-
isProcessing={isWaitingForResponse}
10181016
theme={theme}
10191017
clipboardMessage={clipboardMessage}
1020-
showThinking={showThinking}
1021-
streamStartTime={mainAgentStreamStartTime}
1018+
isActive={isStatusActive}
1019+
streamStartTime={mainAgentTimer.startTime}
10221020
/>
10231021
)
10241022

cli/src/components/message-block.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import React, { type ReactNode } from 'react'
44
import { pluralize } from '@codebuff/common/util/string'
55

66
import { BranchItem } from './branch-item'
7-
import { useElapsedTime } from '../hooks/use-elapsed-time'
7+
import { useElapsedTimeFrom } from '../hooks/use-elapsed-time'
88
import { getToolDisplayInfo } from '../utils/codebuff-client'
99
import {
1010
renderMarkdown,
@@ -70,7 +70,7 @@ export const MessageBlock = ({
7070
registerAgentRef,
7171
}: MessageBlockProps): ReactNode => {
7272
// Calculate elapsed time for streaming AI messages
73-
const elapsedSeconds = useElapsedTime(
73+
const elapsedSeconds = useElapsedTimeFrom(
7474
isAi && isLoading && !isComplete ? streamStartTime : null,
7575
)
7676
const computeBranchChar = (indentLevel: number, isLastBranch: boolean) =>

cli/src/components/status-indicator.tsx

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React, { useEffect, useState } from 'react'
22

33
import { ShimmerText } from './shimmer-text'
4-
import { useElapsedTime } from '../hooks/use-elapsed-time'
4+
import { useElapsedTimeFrom } from '../hooks/use-elapsed-time'
55
import { getCodebuffClient } from '../utils/codebuff-client'
66
import { logger } from '../utils/logger'
77

@@ -37,26 +37,25 @@ const useConnectionStatus = () => {
3737
}
3838

3939
export const StatusIndicator = ({
40-
isProcessing,
4140
theme,
4241
clipboardMessage,
43-
showThinking = false,
42+
isActive = false,
4443
streamStartTime,
4544
}: {
46-
isProcessing: boolean
4745
theme: ChatTheme
4846
clipboardMessage?: string | null
49-
showThinking?: boolean
47+
isActive?: boolean
5048
streamStartTime?: number | null
5149
}) => {
5250
const isConnected = useConnectionStatus()
53-
const elapsedSeconds = useElapsedTime(streamStartTime)
51+
// Use declarative hook to isolate re-renders to this component
52+
const elapsedSeconds = useElapsedTimeFrom(streamStartTime)
5453

5554
if (clipboardMessage) {
5655
return <span fg={theme.statusAccent}>{clipboardMessage}</span>
5756
}
5857

59-
const hasStatus = isConnected === false || isProcessing || showThinking
58+
const hasStatus = isConnected === false || isActive
6059

6160
if (!hasStatus) {
6261
return null
@@ -66,9 +65,9 @@ export const StatusIndicator = ({
6665
return <ShimmerText text="connecting..." />
6766
}
6867

69-
if (isProcessing || showThinking) {
70-
// If we have a stream start time and elapsed > 0, show elapsed time
71-
if (streamStartTime && elapsedSeconds > 0) {
68+
if (isActive) {
69+
// If we have elapsed time > 0, show it
70+
if (elapsedSeconds > 0) {
7271
return (
7372
<span fg={theme.statusSecondary}>
7473
{elapsedSeconds}s
@@ -90,17 +89,15 @@ export const StatusIndicator = ({
9089
}
9190

9291
export const useHasStatus = (
93-
isProcessing: boolean,
92+
isActive: boolean,
9493
clipboardMessage?: string | null,
95-
showThinking?: boolean,
9694
streamStartTime?: number | null,
9795
): boolean => {
9896
const isConnected = useConnectionStatus()
9997
return (
10098
isConnected === false ||
101-
isProcessing ||
99+
isActive ||
102100
!!clipboardMessage ||
103-
!!showThinking ||
104101
!!streamStartTime
105102
)
106103
}

cli/src/hooks/__tests__/use-send-message-timer.test.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,12 @@ describe('useSendMessage timer', () => {
102102
setIsStreaming: mockSetIsStreaming,
103103
setCanProcessQueue: mockSetCanProcessQueue,
104104
abortControllerRef,
105-
setMainAgentStreamStartTime: mockSetMainAgentStreamStartTime,
105+
mainAgentTimer: {
106+
start: mockSetMainAgentStreamStartTime.bind(null, Date.now()),
107+
stop: mockSetMainAgentStreamStartTime.bind(null, null),
108+
elapsedSeconds: 0,
109+
startTime: null,
110+
},
106111
scrollToLatest: mockScrollToLatest,
107112
availableWidth: 80,
108113
}),
@@ -160,7 +165,12 @@ describe('useSendMessage timer', () => {
160165
setIsStreaming: mockSetIsStreaming,
161166
setCanProcessQueue: mockSetCanProcessQueue,
162167
abortControllerRef,
163-
setMainAgentStreamStartTime: mockSetMainAgentStreamStartTime,
168+
mainAgentTimer: {
169+
start: mockSetMainAgentStreamStartTime.bind(null, Date.now()),
170+
stop: mockSetMainAgentStreamStartTime.bind(null, null),
171+
elapsedSeconds: 0,
172+
startTime: null,
173+
},
164174
scrollToLatest: mockScrollToLatest,
165175
availableWidth: 80,
166176
}),
@@ -210,7 +220,12 @@ describe('useSendMessage timer', () => {
210220
setIsStreaming: mockSetIsStreaming,
211221
setCanProcessQueue: mockSetCanProcessQueue,
212222
abortControllerRef,
213-
setMainAgentStreamStartTime: mockSetMainAgentStreamStartTime,
223+
mainAgentTimer: {
224+
start: mockSetMainAgentStreamStartTime.bind(null, Date.now()),
225+
stop: mockSetMainAgentStreamStartTime.bind(null, null),
226+
elapsedSeconds: 0,
227+
startTime: null,
228+
},
214229
scrollToLatest: mockScrollToLatest,
215230
availableWidth: 80,
216231
}),

cli/src/hooks/use-elapsed-time.ts

Lines changed: 73 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,60 @@
1-
import { useEffect, useState } from 'react'
1+
import { useCallback, useEffect, useRef, useState } from 'react'
2+
3+
export interface ElapsedTimeTracker {
4+
/**
5+
* Start tracking elapsed time from now
6+
*/
7+
start: () => void
8+
/**
9+
* Stop tracking and reset to 0
10+
*/
11+
stop: () => void
12+
/**
13+
* Get the current elapsed seconds
14+
*/
15+
elapsedSeconds: number
16+
/**
17+
* Get the start time timestamp (null if not started)
18+
*/
19+
startTime: number | null
20+
}
221

322
/**
4-
* Hook to track elapsed time from a start timestamp
5-
* Updates every second while the start time is set
23+
* Hook to track elapsed time with manual start/stop control
24+
* Updates every second while active
25+
*
26+
* @returns ElapsedTimeTracker - Object with start/stop methods and current elapsed time
27+
*
28+
* @example
29+
* // Imperative API - for components that control timing
30+
* const timer = useElapsedTime()
31+
* timer.start() // Start timing
32+
* timer.stop() // Stop and reset
633
*
7-
* @param startTime - Timestamp in milliseconds (Date.now()) when timing started, or null/undefined to reset
8-
* @returns elapsedSeconds - Number of seconds elapsed since startTime, or 0 if no startTime
34+
* // Can also pass timer to useSendMessage
35+
* useSendMessage({ mainAgentTimer: timer, ... })
36+
*
37+
* @example
38+
* // Declarative API - for components that just display
39+
* const timer = useElapsedTime()
40+
* useEffect(() => {
41+
* if (streamStartTime) timer.start()
42+
* else timer.stop()
43+
* }, [streamStartTime])
944
*/
10-
export const useElapsedTime = (startTime: number | null | undefined): number => {
45+
export const useElapsedTime = (): ElapsedTimeTracker => {
46+
const [startTime, setStartTime] = useState<number | null>(null)
1147
const [elapsedSeconds, setElapsedSeconds] = useState<number>(0)
1248

49+
const start = useCallback(() => {
50+
setStartTime(Date.now())
51+
}, [])
52+
53+
const stop = useCallback(() => {
54+
setStartTime(null)
55+
setElapsedSeconds(0)
56+
}, [])
57+
1358
useEffect(() => {
1459
if (!startTime) {
1560
setElapsedSeconds(0)
@@ -30,5 +75,26 @@ export const useElapsedTime = (startTime: number | null | undefined): number =>
3075
return () => clearInterval(interval)
3176
}, [startTime])
3277

33-
return elapsedSeconds
78+
return { start, stop, elapsedSeconds, startTime }
79+
}
80+
81+
/**
82+
* Declarative hook that tracks elapsed time when a start time is provided
83+
* Useful for components that don't control the timing themselves
84+
*
85+
* @param externalStartTime - Timestamp when timing should start, or null to stop
86+
* @returns elapsedSeconds - Number of seconds elapsed since startTime
87+
*/
88+
export const useElapsedTimeFrom = (externalStartTime: number | null | undefined): number => {
89+
const timer = useElapsedTime()
90+
91+
useEffect(() => {
92+
if (externalStartTime) {
93+
timer.start()
94+
} else {
95+
timer.stop()
96+
}
97+
}, [externalStartTime, timer])
98+
99+
return timer.elapsedSeconds
34100
}

cli/src/hooks/use-send-message.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { createValidationErrorBlocks } from '../utils/create-validation-error-bl
1111
import type { ChatMessage, ContentBlock } from '../chat'
1212
import type { AgentDefinition, ToolName } from '@codebuff/sdk'
1313
import type { SetStateAction } from 'react'
14+
import type { ElapsedTimeTracker } from './use-elapsed-time'
1415

1516
const hiddenToolNames = new Set<ToolName | 'spawn_agent_inline'>([
1617
'spawn_agent_inline',
@@ -100,7 +101,7 @@ interface UseSendMessageOptions {
100101
abortControllerRef: React.MutableRefObject<AbortController | null>
101102
agentId?: string
102103
onBeforeMessageSend?: () => Promise<{ success: boolean; errors: Array<{ id: string; message: string }> }>
103-
setMainAgentStreamStartTime: (time: number | null) => void
104+
mainAgentTimer: ElapsedTimeTracker
104105
scrollToLatest: () => void
105106
availableWidth?: number
106107
}
@@ -124,7 +125,7 @@ export const useSendMessage = ({
124125
abortControllerRef,
125126
agentId,
126127
onBeforeMessageSend,
127-
setMainAgentStreamStartTime,
128+
mainAgentTimer,
128129
scrollToLatest,
129130
availableWidth = 80,
130131
}: UseSendMessageOptions) => {
@@ -256,6 +257,9 @@ export const useSendMessage = ({
256257
const { agentMode } = params
257258
const timestamp = formatTimestamp()
258259

260+
// Start timer immediately when message is sent
261+
mainAgentTimer.start()
262+
259263
// Add user message to UI first
260264
const userMessage: ChatMessage = {
261265
id: `user-${Date.now()}`,
@@ -597,8 +601,6 @@ export const useSendMessage = ({
597601
if (!hasReceivedContent) {
598602
hasReceivedContent = true
599603
setIsWaitingForResponse(false)
600-
// Main agent started streaming - set timer
601-
setMainAgentStreamStartTime(Date.now())
602604
}
603605

604606
const previous = rootStreamBufferRef.current ?? ''
@@ -637,7 +639,6 @@ export const useSendMessage = ({
637639
if (!hasReceivedContent && !event.agentId) {
638640
hasReceivedContent = true
639641
setIsWaitingForResponse(false)
640-
setMainAgentStreamStartTime(Date.now())
641642
} else if (!hasReceivedContent) {
642643
hasReceivedContent = true
643644
setIsWaitingForResponse(false)
@@ -1309,7 +1310,7 @@ export const useSendMessage = ({
13091310
setCanProcessQueue(true)
13101311
updateChainInProgress(false)
13111312
setIsWaitingForResponse(false)
1312-
setMainAgentStreamStartTime(null)
1313+
mainAgentTimer.stop()
13131314

13141315
if ((result as any)?.credits !== undefined) {
13151316
actualCredits = (result as any).credits
@@ -1344,7 +1345,7 @@ export const useSendMessage = ({
13441345
setCanProcessQueue(true)
13451346
updateChainInProgress(false)
13461347
setIsWaitingForResponse(false)
1347-
setMainAgentStreamStartTime(null)
1348+
mainAgentTimer.stop()
13481349

13491350
if (isAborted) {
13501351
applyMessageUpdate((prev) =>
@@ -1421,7 +1422,7 @@ export const useSendMessage = ({
14211422
addActiveSubagent,
14221423
removeActiveSubagent,
14231424
onBeforeMessageSend,
1424-
setMainAgentStreamStartTime,
1425+
mainAgentTimer,
14251426
scrollToLatest,
14261427
availableWidth,
14271428
],

0 commit comments

Comments
 (0)