From cd081116cf315337d4fc065bdc2e161cfa1d0025 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 22 Jun 2026 23:32:08 +0800 Subject: [PATCH] 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 --- desktop/src/stores/chatStore.test.ts | 60 +++++++++++++++++++++ desktop/src/stores/chatStore.ts | 80 +++++++++++++++++++++------- 2 files changed, 120 insertions(+), 20 deletions(-) diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index eacd03ed..cf8671f0 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -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: { diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 0fced879..deffc934 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -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((set, get) => ({ const update = (updater: (session: PerSessionState) => Partial) => { 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((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((set, get) => ({ streamingFallback: null, })) } + ensureElapsedTimer() break } @@ -1661,6 +1689,7 @@ export const useChatStore = create((set, get) => ({ activeThinkingId: null, statusVerb: '', })) + ensureElapsedTimer() useTabStore.getState().updateTabStatus(sessionId, 'running') break } @@ -1678,13 +1707,16 @@ export const useChatStore = create((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((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((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((set, get) => ({ streamingResponseChars: s.streamingResponseChars + msg.text.length, } }) + ensureElapsedTimer() break case 'tool_use_complete': { @@ -1900,11 +1935,13 @@ export const useChatStore = create((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((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,