From 7b183434ce0990282460999f34d0257866bd09e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=83=E6=B0=94?= Date: Sun, 20 Sep 2026 21:20:17 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E5=AF=B9=E8=AF=9D=EF=BC=9A=E6=80=9D?= =?UTF-8?q?=E8=80=83=E8=BF=87=E7=A8=8B=E4=B8=8E=E5=B7=A5=E5=85=B7=E5=88=86?= =?UTF-8?q?=E5=BC=80=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/chat-process.test.ts | 99 +++++++++++++++++++++++- src/lib/chat-process.ts | 80 +++++++++++++++++++ src/pages/chat/ChatMessageBubble.test.ts | 59 ++++++++++++++ src/pages/chat/ChatMessageBubble.tsx | 50 ++++++++++-- src/pages/chat/ChatProcessPanel.test.ts | 27 +++++++ src/pages/chat/ChatProcessPanel.tsx | 71 ++++++++++++++--- src/pages/chat/ChatTranscript.test.ts | 52 +++++++++++++ src/pages/chat/chat-format.test.ts | 3 + 8 files changed, 425 insertions(+), 16 deletions(-) diff --git a/src/lib/chat-process.test.ts b/src/lib/chat-process.test.ts index 65dea0c6..6c16259f 100644 --- a/src/lib/chat-process.test.ts +++ b/src/lib/chat-process.test.ts @@ -10,13 +10,17 @@ import { hasInspectableProcess, hasProcessDetails, isProtocolProcessStep, + latestThinkingStep, mergeThinkingText, mergeToolResult, phaseFromMessageStatus, processKey, processPhaseLabel, reduceProcessEvent, + showBubbleThinkingBar, stepSummary, + thinkingElapsedMs, + timelineHasToolRow, timelineProcessSteps, toolActionTarget, toolActionTone, @@ -622,6 +626,97 @@ describe('chat-process reduceProcessEvent', () => { expect(map['1:grok']?.steps[1]).toMatchObject({ type: 'tool', status: 'end', result: 'ok' }); }); + it('stamps thinking start and freezes duration when the episode ends', () => { + let map: ProcessMap = reduceProcessEvent( + {}, + { type: 'agentStarted', turn: 1, agent: 'grok', command: 'x' }, + 1000, + ); + map = reduceProcessEvent( + map, + { + type: 'agentProcess', + turn: 1, + agent: 'grok', + step: { type: 'thinking', text: 'Hel', done: false }, + }, + 2000, + ); + expect(map['1:grok']?.thinkingStartedAt).toBe(2000); + expect(map['1:grok']?.thinkingDurationMs).toBeUndefined(); + expect(thinkingElapsedMs(map['1:grok'], 3500)).toBe(1500); + expect(latestThinkingStep(map['1:grok']?.steps)).toMatchObject({ done: false }); + expect(showBubbleThinkingBar(map['1:grok']?.steps, false)).toBe(true); + expect(showBubbleThinkingBar(map['1:grok']?.steps, true)).toBe(false); + + map = reduceProcessEvent( + map, + { + type: 'agentProcess', + turn: 1, + agent: 'grok', + step: { type: 'thinking', text: 'lo', done: false }, + }, + 2800, + ); + expect(map['1:grok']?.thinkingStartedAt).toBe(2000); + + map = reduceProcessEvent( + map, + { + type: 'agentProcess', + turn: 1, + agent: 'grok', + step: { type: 'tool', id: 't1', name: 'Read', status: 'start' }, + }, + 5200, + ); + expect(map['1:grok']?.thinkingDurationMs).toBe(3200); + expect(thinkingElapsedMs(map['1:grok'], 9000)).toBe(3200); + expect(timelineHasToolRow(map['1:grok']?.steps)).toBe(true); + }); + + it('starts a new thinking timer after a tool', () => { + let map: ProcessMap = reduceProcessEvent( + {}, + { type: 'agentStarted', turn: 1, agent: 'grok', command: 'x' }, + 1, + ); + map = reduceProcessEvent( + map, + { + type: 'agentProcess', + turn: 1, + agent: 'grok', + step: { type: 'thinking', text: 'first', done: false }, + }, + 100, + ); + map = reduceProcessEvent( + map, + { + type: 'agentProcess', + turn: 1, + agent: 'grok', + step: { type: 'tool', id: 't1', name: 'Read', status: 'start' }, + }, + 400, + ); + map = reduceProcessEvent( + map, + { + type: 'agentProcess', + turn: 1, + agent: 'grok', + step: { type: 'thinking', text: 'second', done: false }, + }, + 900, + ); + expect(map['1:grok']?.thinkingStartedAt).toBe(900); + expect(map['1:grok']?.thinkingDurationMs).toBeUndefined(); + expect(thinkingElapsedMs(map['1:grok'], 1400)).toBe(500); + }); + it('agentFinished marks leftover thinking done', () => { let map: ProcessMap = reduceProcessEvent( {}, @@ -646,9 +741,11 @@ describe('chat-process reduceProcessEvent', () => { agent: 'grok', message: finishedMsg({ status: 'ok', content: 'done', agentId: 'grok' }), }, - 3, + 5002, ); expect(map['1:grok']?.steps[0]).toMatchObject({ type: 'thinking', done: true }); + expect(map['1:grok']?.thinkingStartedAt).toBe(2); + expect(map['1:grok']?.thinkingDurationMs).toBe(5000); }); it('finished finalizes still-active process views for the turn', () => { diff --git a/src/lib/chat-process.ts b/src/lib/chat-process.ts index 41af2d83..91e29bb1 100644 --- a/src/lib/chat-process.ts +++ b/src/lib/chat-process.ts @@ -16,6 +16,8 @@ export type ProcessPhase = | 'cancelled' | 'timeout'; +export type ThinkingStep = Extract; + export type AgentProcessView = { turn: number; agent: AgentKey; @@ -26,6 +28,10 @@ export type AgentProcessView = { /** Structured steps (tool / thinking / status / raw / usage). Cap in reducer. */ steps: ProcessStep[]; updatedAt: number; + /** Wall-clock when the current/last thinking episode started. */ + thinkingStartedAt?: number; + /** Frozen duration for the last thinking episode once it finishes. */ + thinkingDurationMs?: number; }; export type ProcessMap = Record; @@ -344,6 +350,36 @@ export function timelineProcessSteps(steps: ProcessStep[]): ProcessStep[] { return out; } +export function isThinkingStep(step: ProcessStep): step is ThinkingStep { + return step.type === 'thinking'; +} + +export function latestThinkingStep(steps: ProcessStep[] | undefined): ThinkingStep | undefined { + return lastMatching(steps ?? [], isThinkingStep); +} + +/** Live timer, or the frozen duration after thinking ends. */ +export function thinkingElapsedMs(view: AgentProcessView | undefined, now: number): number { + if (!view?.thinkingStartedAt) return 0; + if (view.thinkingDurationMs != null) return Math.max(0, view.thinkingDurationMs); + return Math.max(0, now - view.thinkingStartedAt); +} + +/** Main-column chrome: thinking exists and the assistant body has not arrived. */ +export function showBubbleThinkingBar( + steps: ProcessStep[] | undefined, + hasContent: boolean, +): boolean { + if (hasContent) return false; + return latestThinkingStep(steps) != null; +} + +export function timelineHasToolRow(steps: ProcessStep[] | undefined): boolean { + return timelineProcessSteps(steps ?? []).some( + (step) => step.type === 'tool' || step.type === 'error' || step.type === 'raw', + ); +} + function lastMatching(items: T[], pred: (item: T) => boolean): T | undefined { for (let i = items.length - 1; i >= 0; i -= 1) { if (pred(items[i])) return items[i]; @@ -549,6 +585,45 @@ function markLastThinkingDone(steps: ProcessStep[]): ProcessStep[] { return steps; } +function freezeThinkingDuration( + view: Pick, + now: number, +): Pick { + if (view.thinkingStartedAt == null || view.thinkingDurationMs != null) { + return { + thinkingStartedAt: view.thinkingStartedAt, + thinkingDurationMs: view.thinkingDurationMs, + }; + } + return { + thinkingStartedAt: view.thinkingStartedAt, + thinkingDurationMs: Math.max(0, now - view.thinkingStartedAt), + }; +} + +function stampThinkingTiming( + prev: AgentProcessView, + step: ProcessStep, + now: number, +): Pick { + if (step.type === 'thinking') { + const last = prev.steps[prev.steps.length - 1]; + const mergeIntoOpen = last?.type === 'thinking' && !last.done; + const startedAt = mergeIntoOpen ? (prev.thinkingStartedAt ?? now) : now; + if (step.done) { + return { thinkingStartedAt: startedAt, thinkingDurationMs: Math.max(0, now - startedAt) }; + } + return { thinkingStartedAt: startedAt, thinkingDurationMs: undefined }; + } + if (prev.thinkingStartedAt != null && prev.thinkingDurationMs == null) { + return freezeThinkingDuration(prev, now); + } + return { + thinkingStartedAt: prev.thinkingStartedAt, + thinkingDurationMs: prev.thinkingDurationMs, + }; +} + /** * Codex `item.updated` reasoning is a full snapshot; Grok/Pi/Claude thinking * chunks are deltas. If the new text already contains the previous text as a @@ -686,6 +761,8 @@ export function reduceProcessEvent(map: ProcessMap, ev: ChatEvent, now = Date.no stdout: prev?.stdout ?? '', stderr: prev?.stderr ?? '', steps: prev?.steps ?? [], + thinkingStartedAt: prev?.thinkingStartedAt, + thinkingDurationMs: prev?.thinkingDurationMs, updatedAt: now, }, }; @@ -728,6 +805,7 @@ export function reduceProcessEvent(map: ProcessMap, ev: ChatEvent, now = Date.no ...prev, phase: prev.phase === 'queued' || prev.phase === 'starting' ? 'running' : prev.phase, steps: pushStep(prev.steps, ev.step), + ...stampThinkingTiming(prev, ev.step, now), updatedAt: now, }, }; @@ -744,6 +822,7 @@ export function reduceProcessEvent(map: ProcessMap, ev: ChatEvent, now = Date.no phase: phaseFromMessageStatus(ev.message.status), stdout: content || prev.stdout, steps: markLastThinkingDone(prev.steps), + ...freezeThinkingDuration(prev, now), updatedAt: now, }, }; @@ -765,6 +844,7 @@ export function reduceProcessEvent(map: ProcessMap, ev: ChatEvent, now = Date.no // 生产取消时 ok=true;缺省 cancelled 当 false,兼容旧事件 phase: ev.cancelled ? 'cancelled' : ev.ok ? 'ok' : 'failed', steps: markLastThinkingDone(view.steps), + ...freezeThinkingDuration(view, now), updatedAt: now, }; changed = true; diff --git a/src/pages/chat/ChatMessageBubble.test.ts b/src/pages/chat/ChatMessageBubble.test.ts index c5442f9a..cd629ff6 100644 --- a/src/pages/chat/ChatMessageBubble.test.ts +++ b/src/pages/chat/ChatMessageBubble.test.ts @@ -91,6 +91,65 @@ describe('ChatMessageBubble streaming feel', () => { expect(html).not.toContain('已停止'); }); + it('shows a clickable thinking bar instead of three dots when thinking has no body yet', () => { + const process: AgentProcessView = { + turn: 1, + agent: 'codex', + phase: 'running', + stdout: '', + stderr: '', + steps: [{ type: 'thinking', text: 'secret plan that must not enter the bubble', done: false }], + updatedAt: 1, + thinkingStartedAt: Date.now() - 3200, + }; + const html = renderToStaticMarkup( + createElement(TooltipProvider, null, createElement(ChatMessageBubble, { + message: agentMessage(''), + process, + isLastTurn: true, + multiAgent: false, + retryDisabled: false, + onRetry: () => undefined, + onOpenProcess: () => undefined, + })), + ); + expect(html).toContain('data-help="chat-thinking-bar"'); + expect(html).toContain('思考中'); + expect(html).toContain('▸'); + expect(html).not.toContain('正在想'); + expect(html).not.toContain('secret plan that must not enter the bubble'); + expect(html).not.toContain('data-help="chat-process-chip"'); + }); + + it('shows 思考了 after thinking ends and before the reply body', () => { + const process: AgentProcessView = { + turn: 1, + agent: 'codex', + phase: 'running', + stdout: '', + stderr: '', + steps: [{ type: 'thinking', text: 'done thinking body', done: true }], + updatedAt: 1, + thinkingStartedAt: 1, + thinkingDurationMs: 3200, + }; + const html = renderToStaticMarkup( + createElement(TooltipProvider, null, createElement(ChatMessageBubble, { + message: agentMessage(''), + process, + isLastTurn: true, + multiAgent: false, + retryDisabled: false, + onRetry: () => undefined, + onOpenProcess: () => undefined, + })), + ); + expect(html).toContain('data-help="chat-thinking-bar"'); + expect(html).toContain('思考了 3.2s'); + expect(html).not.toContain('正在写'); + expect(html).not.toContain('done thinking body'); + }); + it('opens process details from a one-line chip', () => { const process: AgentProcessView = { turn: 1, diff --git a/src/pages/chat/ChatMessageBubble.tsx b/src/pages/chat/ChatMessageBubble.tsx index e98767aa..d933f835 100644 --- a/src/pages/chat/ChatMessageBubble.tsx +++ b/src/pages/chat/ChatMessageBubble.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from 'react'; import { AgentLogo } from '@/components/shared/AgentLogo'; import { AgentThinking } from '@/components/shared/AgentThinking'; import { CopyTextButton } from '@/components/shared/CopyTextButton'; @@ -10,7 +11,11 @@ import { formatProcessHeadline, formatTurnUsageFooter, hasInspectableProcess, + latestThinkingStep, phaseFromMessageStatus, + showBubbleThinkingBar, + thinkingElapsedMs, + timelineHasToolRow, } from '@/lib/chat-process'; import type { AgentProcessView } from '@/lib/chat-process'; import type { AgentKey, ChatMessage } from '@/lib/types'; @@ -20,6 +25,7 @@ import { localizeChatFailure, looksLikeChatProtocolDump, sanitizeCliChatText, + thinkingChromeLabel, } from './chat-format'; import { messageStatusLabel } from './chat-model'; import { streamingActivity, streamingPlaceholderKey } from './chat-streaming'; @@ -159,20 +165,31 @@ function AgentBubble({ : running ? 'running' : null; + const thinking = latestThinkingStep(process?.steps); + const showThinkingBar = showBubbleThinkingBar(process?.steps, hasContent); const showProcessChip = Boolean(onOpenProcess) && ( running || Boolean(process && hasInspectableProcess(process)) - ); + ) && (!showThinkingBar || timelineHasToolRow(process?.steps)); const processHeadline = showProcessChip ? process && effectivePhase ? formatProcessHeadline(process.steps, effectivePhase, t) : messageStatusLabel(t, resolvedStatus, process, hasContent) ?? t('chat.process.summaryGenerating') : ''; - const statusText = (hideRetry && looksFailed) || showProcessChip + const statusText = (hideRetry && looksFailed) || showProcessChip || showThinkingBar ? null : messageStatusLabel(t, resolvedStatus, process, hasContent); const activity = running ? streamingActivity(process, hasContent) : null; const showRetry = isLastTurn && looksFailed && !hideRetry; const usageText = formatTurnUsageFooter(process?.steps, running, t); + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (!showThinkingBar || thinking?.done) return; + const id = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(id); + }, [showThinkingBar, thinking?.done]); + const thinkingLabel = thinking + ? thinkingChromeLabel(Boolean(thinking.done), thinkingElapsedMs(process, now), t) + : ''; return (
@@ -200,6 +217,29 @@ function AgentBubble({ )}
+ {showThinkingBar && thinkingLabel ? ( + + ) : null} {showProcessChip && processHeadline && onOpenProcess ? (