fix(desktop): keep active turn duration live

Fixes #813 and #853.

Live streaming, tool, retry, fallback, and thinking events now restore the elapsed timer if the local timer handle was lost while the turn is still active.

Completed turns now show a lightweight duration system message when no queued follow-up prompt needs to stay directly after the assistant response.

Tested: cd desktop && bun run test -- --run src/stores/chatStore.test.ts src/components/chat/StreamingIndicator.test.tsx src/components/chat/MessageList.test.tsx

Tested: cd desktop && bun run test -- --run src/components/chat/ChatInput.test.tsx -t 'sends a queued prompt as the next tail message'

Tested: bun run check:desktop

Confidence: high

Scope-risk: narrow
This commit is contained in:
程序员阿江(Relakkes) 2026-06-22 23:32:08 +08:00
parent 10f4768c18
commit cd081116cf
2 changed files with 120 additions and 20 deletions

View File

@ -3137,6 +3137,66 @@ describe('chatStore history mapping', () => {
vi.useRealTimers()
})
it('resumes the elapsed timer when streaming continues after the timer was lost', () => {
vi.useFakeTimers()
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession({
chatState: 'streaming',
elapsedSeconds: 3,
elapsedTimer: null,
}),
},
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'content_delta',
text: 'still running',
})
vi.advanceTimersByTime(2100)
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.elapsedSeconds).toBe(5)
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'status',
state: 'idle',
})
vi.runOnlyPendingTimers()
vi.useRealTimers()
})
it('adds a completed turn duration after a running response finishes', () => {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession({
chatState: 'streaming',
elapsedSeconds: 65,
streamingText: 'Finished answer',
elapsedTimer: null,
}),
},
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'message_complete',
usage: { input_tokens: 12, output_tokens: 34 },
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
{
type: 'assistant_text',
content: 'Finished answer',
},
{
type: 'system',
content: 'Completed in 1m 5s',
},
])
})
it('tracks API retry status until the request finishes', () => {
useChatStore.setState({
sessions: {

View File

@ -495,6 +495,31 @@ function appendAssistantTextMessage(
]
}
function formatTurnDuration(seconds: number): string {
const totalSeconds = Math.max(1, Math.round(seconds))
if (totalSeconds < 60) return `${totalSeconds}s`
const minutes = Math.floor(totalSeconds / 60)
const remainingSeconds = totalSeconds % 60
return `${minutes}m ${remainingSeconds}s`
}
function appendCompletedTurnDurationMessage(
messages: UIMessage[],
elapsedSeconds: number,
timestamp: number,
): UIMessage[] {
if (!Number.isFinite(elapsedSeconds) || elapsedSeconds <= 0) return messages
return [
...messages,
{
id: nextId(),
type: 'system',
content: `Completed in ${formatTurnDuration(elapsedSeconds)}`,
timestamp,
},
]
}
function extractCompactSummaryContent(content: unknown): string | null {
if (typeof content !== 'string') return null
const trimmed = content.trim()
@ -1509,6 +1534,24 @@ export const useChatStore = create<ChatStore>((set, get) => ({
const update = (updater: (session: PerSessionState) => Partial<PerSessionState>) => {
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, updater) }))
}
const ensureElapsedTimer = () => {
const session = get().sessions[sessionId]
if (!session || session.elapsedTimer) return
const timer = setInterval(() => {
set((st) => ({
sessions: updateSessionIn(st.sessions, sessionId, (sess) => ({
elapsedSeconds: sess.elapsedSeconds + 1,
})),
}))
}, 1000)
update(() => ({ elapsedTimer: timer }))
}
const clearElapsedTimer = () => {
const session = get().sessions[sessionId]
if (!session?.elapsedTimer) return
clearInterval(session.elapsedTimer)
update(() => ({ elapsedTimer: null }))
}
switch (msg.type) {
case 'connected':
@ -1556,25 +1599,9 @@ export const useChatStore = create<ChatStore>((set, get) => ({
} : pendingText !== session.streamingText ? { streamingText: pendingText } : {}),
}
})
if (msg.state !== 'idle') {
const session = get().sessions[sessionId]
if (session && !session.elapsedTimer) {
const timer = setInterval(() => {
set((st) => ({
sessions: updateSessionIn(st.sessions, sessionId, (sess) => ({
elapsedSeconds: sess.elapsedSeconds + 1,
})),
}))
}, 1000)
update(() => ({ elapsedTimer: timer }))
}
}
if (msg.state !== 'idle') ensureElapsedTimer()
if (msg.state === 'idle') {
const session = get().sessions[sessionId]
if (session?.elapsedTimer) {
clearInterval(session.elapsedTimer)
update(() => ({ elapsedTimer: null }))
}
clearElapsedTimer()
}
// Sync tab status
useTabStore.getState().updateTabStatus(sessionId, msg.state === 'idle' ? 'idle' : 'running')
@ -1640,6 +1667,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
streamingFallback: null,
}))
}
ensureElapsedTimer()
break
}
@ -1661,6 +1689,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
activeThinkingId: null,
statusVerb: '',
}))
ensureElapsedTimer()
useTabStore.getState().updateTabStatus(sessionId, 'running')
break
}
@ -1678,13 +1707,16 @@ export const useChatStore = create<ChatStore>((set, get) => ({
activeThinkingId: null,
statusVerb: '',
}))
ensureElapsedTimer()
useTabStore.getState().updateTabStatus(sessionId, 'running')
break
}
case 'content_delta':
let receivedLiveDelta = false
if (msg.text !== undefined) {
if (!get().sessions[sessionId]) break
receivedLiveDelta = true
appendPendingDelta(sessionId, msg.text)
if (!flushTimerBySession.has(sessionId)) {
const timer = setTimeout(() => {
@ -1700,6 +1732,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
}
}
if (msg.toolInput !== undefined) {
receivedLiveDelta = true
appendPendingToolInputDelta(sessionId, msg.toolInput)
if (!toolInputFlushTimerBySession.has(sessionId)) {
const timer = setTimeout(() => {
@ -1735,6 +1768,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
toolInputFlushTimerBySession.set(sessionId, timer)
}
}
if (receivedLiveDelta && get().sessions[sessionId]?.chatState !== 'idle') ensureElapsedTimer()
break
case 'thinking':
@ -1764,6 +1798,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
streamingResponseChars: s.streamingResponseChars + msg.text.length,
}
})
ensureElapsedTimer()
break
case 'tool_use_complete': {
@ -1900,11 +1935,13 @@ export const useChatStore = create<ChatStore>((set, get) => ({
case 'message_complete': {
const session = get().sessions[sessionId]
if (!session) break
const completedAt = Date.now()
const wasAgentRunning = session.chatState !== 'idle'
const hasQueuedUserMessages = (session.queuedUserMessages?.length ?? 0) > 0
const text = `${session.streamingText}${consumePendingDelta(sessionId)}`
let completionMessages = session.messages
if (text.trim()) {
completionMessages = appendAssistantTextMessage(session.messages, text, Date.now())
completionMessages = appendAssistantTextMessage(session.messages, text, completedAt)
update(() => ({
messages: completionMessages,
streamingText: '',
@ -1913,7 +1950,10 @@ export const useChatStore = create<ChatStore>((set, get) => ({
update(() => ({ streamingText: text }))
}
const appendedCompletionMessage = completionMessages !== session.messages
const finalMessages = markPendingToolUseMessagesStopped(completionMessages)
const stoppedMessages = markPendingToolUseMessagesStopped(completionMessages)
const finalMessages = wasAgentRunning && !hasQueuedUserMessages
? appendCompletedTurnDurationMessage(stoppedMessages, session.elapsedSeconds, completedAt)
: stoppedMessages
if (session.elapsedTimer) clearInterval(session.elapsedTimer)
update(() => ({
messages: finalMessages,