mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-08-01 16:43:37 +08:00
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:
parent
10f4768c18
commit
cd081116cf
@ -3137,6 +3137,66 @@ describe('chatStore history mapping', () => {
|
|||||||
vi.useRealTimers()
|
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', () => {
|
it('tracks API retry status until the request finishes', () => {
|
||||||
useChatStore.setState({
|
useChatStore.setState({
|
||||||
sessions: {
|
sessions: {
|
||||||
|
|||||||
@ -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 {
|
function extractCompactSummaryContent(content: unknown): string | null {
|
||||||
if (typeof content !== 'string') return null
|
if (typeof content !== 'string') return null
|
||||||
const trimmed = content.trim()
|
const trimmed = content.trim()
|
||||||
@ -1509,6 +1534,24 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
const update = (updater: (session: PerSessionState) => Partial<PerSessionState>) => {
|
const update = (updater: (session: PerSessionState) => Partial<PerSessionState>) => {
|
||||||
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, updater) }))
|
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) {
|
switch (msg.type) {
|
||||||
case 'connected':
|
case 'connected':
|
||||||
@ -1556,25 +1599,9 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
} : pendingText !== session.streamingText ? { streamingText: pendingText } : {}),
|
} : pendingText !== session.streamingText ? { streamingText: pendingText } : {}),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if (msg.state !== 'idle') {
|
if (msg.state !== 'idle') ensureElapsedTimer()
|
||||||
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') {
|
if (msg.state === 'idle') {
|
||||||
const session = get().sessions[sessionId]
|
clearElapsedTimer()
|
||||||
if (session?.elapsedTimer) {
|
|
||||||
clearInterval(session.elapsedTimer)
|
|
||||||
update(() => ({ elapsedTimer: null }))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Sync tab status
|
// Sync tab status
|
||||||
useTabStore.getState().updateTabStatus(sessionId, msg.state === 'idle' ? 'idle' : 'running')
|
useTabStore.getState().updateTabStatus(sessionId, msg.state === 'idle' ? 'idle' : 'running')
|
||||||
@ -1640,6 +1667,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
streamingFallback: null,
|
streamingFallback: null,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
ensureElapsedTimer()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1661,6 +1689,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
activeThinkingId: null,
|
activeThinkingId: null,
|
||||||
statusVerb: '',
|
statusVerb: '',
|
||||||
}))
|
}))
|
||||||
|
ensureElapsedTimer()
|
||||||
useTabStore.getState().updateTabStatus(sessionId, 'running')
|
useTabStore.getState().updateTabStatus(sessionId, 'running')
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@ -1678,13 +1707,16 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
activeThinkingId: null,
|
activeThinkingId: null,
|
||||||
statusVerb: '',
|
statusVerb: '',
|
||||||
}))
|
}))
|
||||||
|
ensureElapsedTimer()
|
||||||
useTabStore.getState().updateTabStatus(sessionId, 'running')
|
useTabStore.getState().updateTabStatus(sessionId, 'running')
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'content_delta':
|
case 'content_delta':
|
||||||
|
let receivedLiveDelta = false
|
||||||
if (msg.text !== undefined) {
|
if (msg.text !== undefined) {
|
||||||
if (!get().sessions[sessionId]) break
|
if (!get().sessions[sessionId]) break
|
||||||
|
receivedLiveDelta = true
|
||||||
appendPendingDelta(sessionId, msg.text)
|
appendPendingDelta(sessionId, msg.text)
|
||||||
if (!flushTimerBySession.has(sessionId)) {
|
if (!flushTimerBySession.has(sessionId)) {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@ -1700,6 +1732,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (msg.toolInput !== undefined) {
|
if (msg.toolInput !== undefined) {
|
||||||
|
receivedLiveDelta = true
|
||||||
appendPendingToolInputDelta(sessionId, msg.toolInput)
|
appendPendingToolInputDelta(sessionId, msg.toolInput)
|
||||||
if (!toolInputFlushTimerBySession.has(sessionId)) {
|
if (!toolInputFlushTimerBySession.has(sessionId)) {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@ -1735,6 +1768,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
toolInputFlushTimerBySession.set(sessionId, timer)
|
toolInputFlushTimerBySession.set(sessionId, timer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (receivedLiveDelta && get().sessions[sessionId]?.chatState !== 'idle') ensureElapsedTimer()
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'thinking':
|
case 'thinking':
|
||||||
@ -1764,6 +1798,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
streamingResponseChars: s.streamingResponseChars + msg.text.length,
|
streamingResponseChars: s.streamingResponseChars + msg.text.length,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
ensureElapsedTimer()
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'tool_use_complete': {
|
case 'tool_use_complete': {
|
||||||
@ -1900,11 +1935,13 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
case 'message_complete': {
|
case 'message_complete': {
|
||||||
const session = get().sessions[sessionId]
|
const session = get().sessions[sessionId]
|
||||||
if (!session) break
|
if (!session) break
|
||||||
|
const completedAt = Date.now()
|
||||||
const wasAgentRunning = session.chatState !== 'idle'
|
const wasAgentRunning = session.chatState !== 'idle'
|
||||||
|
const hasQueuedUserMessages = (session.queuedUserMessages?.length ?? 0) > 0
|
||||||
const text = `${session.streamingText}${consumePendingDelta(sessionId)}`
|
const text = `${session.streamingText}${consumePendingDelta(sessionId)}`
|
||||||
let completionMessages = session.messages
|
let completionMessages = session.messages
|
||||||
if (text.trim()) {
|
if (text.trim()) {
|
||||||
completionMessages = appendAssistantTextMessage(session.messages, text, Date.now())
|
completionMessages = appendAssistantTextMessage(session.messages, text, completedAt)
|
||||||
update(() => ({
|
update(() => ({
|
||||||
messages: completionMessages,
|
messages: completionMessages,
|
||||||
streamingText: '',
|
streamingText: '',
|
||||||
@ -1913,7 +1950,10 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
update(() => ({ streamingText: text }))
|
update(() => ({ streamingText: text }))
|
||||||
}
|
}
|
||||||
const appendedCompletionMessage = completionMessages !== session.messages
|
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)
|
if (session.elapsedTimer) clearInterval(session.elapsedTimer)
|
||||||
update(() => ({
|
update(() => ({
|
||||||
messages: finalMessages,
|
messages: finalMessages,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user