From aded486ac3d68e3afc9f102be0b7ad306a7d5f0a Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 22 Jul 2026 09:13:42 +0800 Subject: [PATCH 1/6] feat(ui): fold turn reasoning and tool calls into collapsible Processing blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group each maximal run of thinking + tool-call timeline entries between two assistant answer texts into one collapsed "Processing" block (#1307). Answer text stays a grouping boundary and always renders in place, so a turn can hold several Processing blocks; the expanded block replays the full timeline (the same 深度思考 disclosures and tool trows, nested one indent in). - materialize: add a `processing` TurnTimelineItem kind and group in a shared finalize pass used by both the settled (buildTurnTimeline) and live-overlay paths, so streaming and replayed history fold identically; projectTurnTools descends into blocks while preserving turn identity. - trow-summary/copy: summarizeProcessing / isProcessingRunning / processingNeedsAttention — reasoning count + tool buckets + red failed count, live current-activity line, waiting_permission force-open (errors stay collapsed), reusing the existing trow summary + disclosure state machine. - chat-turn: ProcessingBlock renders via the shared disclosure/SETTLE_FADE seams, collapsed by default with sticky manual toggle. --- .../main/__tests__/materialize-turns.test.ts | 69 +++++++-- .../main/__tests__/streaming-handoff.test.ts | 11 +- .../__tests__/live-turn-projection.test.ts | 9 +- packages/ui/src/__tests__/materialize.test.ts | 134 +++++++++++++++++- .../src/__tests__/tool-trow-summary.test.ts | 79 ++++++++++- packages/ui/src/chat-turn.tsx | 123 ++++++++++++++-- packages/ui/src/materialize.ts | 92 ++++++++++-- packages/ui/src/tool-activity.tsx | 4 +- packages/ui/src/tool-activity/copy.ts | 6 + packages/ui/src/tool-activity/trow-summary.ts | 79 ++++++++++- 10 files changed, 559 insertions(+), 47 deletions(-) diff --git a/apps/desktop/src/main/__tests__/materialize-turns.test.ts b/apps/desktop/src/main/__tests__/materialize-turns.test.ts index 99d068f1e3..762b0bcdd1 100644 --- a/apps/desktop/src/main/__tests__/materialize-turns.test.ts +++ b/apps/desktop/src/main/__tests__/materialize-turns.test.ts @@ -8,9 +8,21 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; -import { deriveTurnLineageMap, materializeTurns, overlayLiveTurn, type LiveTurnProjection } from '@maka/ui'; +import { deriveTurnLineageMap, materializeTurns, overlayLiveTurn, type LiveTurnProjection, type TurnTimelineItem } from '@maka/ui'; import type { StoredMessage } from '@maka/core'; +// #1307: reasoning + tool groups fold into collapsible `processing` blocks +// between answer texts. These ordering tests care about the interleave, not the +// folding, so they assert against the unfolded timeline; the folding itself is +// covered by packages/ui/src/__tests__/materialize.test.ts and asserted here in +// the dedicated "processing grouping" test below. +function flattenTimeline(timeline: readonly TurnTimelineItem[]): TurnTimelineItem[] { + return timeline.flatMap((item) => (item.kind === 'processing' ? item.children : [item])); +} +function timelineKinds(timeline: readonly TurnTimelineItem[]): string[] { + return flattenTimeline(timeline).map((item) => item.kind); +} + function userMsg(turnId: string, ts: number, text: string, id?: string): StoredMessage { return { type: 'user', id: id ?? `u-${turnId}`, turnId, ts, text }; } @@ -459,7 +471,7 @@ describe('materializeTurns timeline', () => { }, ); - assert.deepEqual(turns[0]?.timeline.map((item) => item.kind), ['thinking', 'tools']); + assert.deepEqual(timelineKinds(turns[0]!.timeline), ['thinking', 'tools']); }); it('appends the current live step after earlier committed steps in thinking -> text -> tools order', () => { @@ -479,8 +491,8 @@ describe('materializeTurns timeline', () => { }, ); - assert.deepEqual(turns[0]?.timeline.map((item) => item.kind), ['text', 'thinking', 'text', 'tools']); - assert.equal((turns[0]?.timeline[2] as { text: string } | undefined)?.text, 'second answer'); + assert.deepEqual(timelineKinds(turns[0]!.timeline), ['text', 'thinking', 'text', 'tools']); + assert.equal((flattenTimeline(turns[0]!.timeline)[2] as { text: string } | undefined)?.text, 'second answer'); }); it('keeps multiple uncommitted live steps in production order', () => { @@ -507,7 +519,7 @@ describe('materializeTurns timeline', () => { }, ); - assert.deepEqual(turns[0]?.timeline.map((item) => item.kind), ['thinking', 'tools', 'thinking', 'text']); + assert.deepEqual(timelineKinds(turns[0]!.timeline), ['thinking', 'tools', 'thinking', 'text']); }); it('interleaves each step: thinking -> text -> that step’s tools', () => { @@ -520,7 +532,7 @@ describe('materializeTurns timeline', () => { toolResultMsg('t1', 105, 'c2'), assistantStep('t1', 106, 'a2', 'step two', 'think two'), ]); - const timeline = turns[0]!.timeline; + const timeline = flattenTimeline(turns[0]!.timeline); assert.deepEqual(timeline.map((i) => i.kind), ['thinking', 'text', 'tools', 'thinking', 'text', 'tools']); assert.equal((timeline[0] as { text: string }).text, 'think one'); assert.equal((timeline[1] as { text: string }).text, 'step one'); @@ -548,7 +560,7 @@ describe('materializeTurns timeline', () => { }, ]); - assert.deepEqual(turns[0]?.timeline.map((item) => item.kind), ['tools', 'thinking', 'text']); + assert.deepEqual(timelineKinds(turns[0]!.timeline), ['tools', 'thinking', 'text']); }); it('renders a pure-tool step’s orphan tools before the next step’s answer', () => { @@ -562,7 +574,7 @@ describe('materializeTurns timeline', () => { toolResultMsg('t1', 102, 'c1'), assistantStep('t1', 103, 'a2', 'summary', 'think'), ]); - const timeline = turns[0]!.timeline; + const timeline = flattenTimeline(turns[0]!.timeline); assert.deepEqual(timeline.map((i) => i.kind), ['tools', 'thinking', 'text']); assert.equal((timeline[0] as { items: { toolUseId: string }[] }).items[0]?.toolUseId, 'c1'); assert.equal((timeline[2] as { text: string }).text, 'summary'); @@ -575,7 +587,7 @@ describe('materializeTurns timeline', () => { toolResultMsg('t1', 102, 'c1'), assistantMsg('t1', 103, 'summary'), ]); - const timeline = turns[0]!.timeline; + const timeline = flattenTimeline(turns[0]!.timeline); assert.deepEqual(timeline.map((i) => i.kind), ['tools', 'text']); assert.equal((timeline[1] as { text: string }).text, 'summary'); }); @@ -585,7 +597,7 @@ describe('materializeTurns timeline', () => { userMsg('t1', 100, 'q'), toolCallStep('t1', 101, 'c1', 'a1'), ]); - const timeline = turns[0]!.timeline; + const timeline = flattenTimeline(turns[0]!.timeline); assert.deepEqual(timeline.map((i) => i.kind), ['tools']); assert.equal((timeline[0] as { items: { status: string }[] }).items[0]?.status, 'interrupted'); }); @@ -600,7 +612,7 @@ describe('materializeTurns timeline', () => { }], }, ); - const timeline = turns[0]!.timeline; + const timeline = flattenTimeline(turns[0]!.timeline); assert.deepEqual(timeline.map((i) => i.kind), ['text', 'tools']); assert.equal((timeline[1] as { items: { toolUseId: string }[] }).items[0]?.toolUseId, 'live-1'); }); @@ -611,7 +623,7 @@ describe('materializeTurns timeline', () => { assistantStep('t1', 101, 'a1', '', 'first'), assistantStep('t1', 102, 'a2', '', 'second'), ]); - const tl1 = thinkingOnly[0]!.timeline; + const tl1 = flattenTimeline(thinkingOnly[0]!.timeline); assert.deepEqual(tl1.map((i) => i.kind), ['thinking']); assert.equal((tl1[0] as { text: string }).text, 'first\n\nsecond'); @@ -622,10 +634,41 @@ describe('materializeTurns timeline', () => { toolCallStep('t1', 103, 'c2', 'a2'), assistantStep('t1', 104, 'a2', ''), ]); - const tl2 = toolsOnly[0]!.timeline; + const tl2 = flattenTimeline(toolsOnly[0]!.timeline); assert.deepEqual(tl2.map((i) => i.kind), ['tools']); assert.equal((tl2[0] as { items: unknown[] }).items.length, 2); }); + + it('folds each maximal reasoning + tool run between answers into a processing block (#1307)', () => { + const turns = materializeTurns([ + userMsg('t1', 100, 'q'), + toolCallStep('t1', 101, 'c1', 'a1'), + toolResultMsg('t1', 102, 'c1'), + assistantStep('t1', 103, 'a1', 'step one', 'think one'), + toolCallStep('t1', 104, 'c2', 'a2'), + toolResultMsg('t1', 105, 'c2'), + assistantStep('t1', 106, 'a2', 'step two', 'think two'), + ]); + const timeline = turns[0]!.timeline; + // Answer text is the only boundary; reasoning/tools around each text fold. + assert.deepEqual(timeline.map((item) => item.kind), [ + 'processing', + 'text', + 'processing', + 'text', + 'processing', + ]); + const first = timeline[0]; + assert.deepEqual( + first?.kind === 'processing' ? first.children.map((child) => child.kind) : [], + ['thinking'], + ); + const middle = timeline[2]; + assert.deepEqual( + middle?.kind === 'processing' ? middle.children.map((child) => child.kind) : [], + ['tools', 'thinking'], + ); + }); }); describe('deriveTurnLineageMap', () => { diff --git a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts index 10138281f9..23649f95b7 100644 --- a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts @@ -74,8 +74,15 @@ describe('single live-turn handoff', () => { }], }); - assert.ok(markup.indexOf('先检查') < markup.indexOf('data-trow="group"')); - assert.match(markup, /最终答案/); + // #1307: reasoning + the tool fold into collapsed "Processing" blocks, and + // the answer text is the grouping boundary — so reasoning folds into a block + // above the answer and the tool into a block below it. Both blocks are + // collapsed (their bodies are not in the static markup); the summary lines + // carry the order: 思考 above the answer, the tool command below it. + assert.equal((markup.match(/data-processing="block"/g) ?? []).length, 2); + assert.ok(markup.indexOf('思考 1 次') >= 0); + assert.ok(markup.indexOf('思考 1 次') < markup.indexOf('最终答案')); + assert.ok(markup.indexOf('最终答案') < markup.indexOf('运行 1 条命令')); assert.equal((markup.match(/data-turn-id=/g) ?? []).length, 1); }); diff --git a/packages/ui/src/__tests__/live-turn-projection.test.ts b/packages/ui/src/__tests__/live-turn-projection.test.ts index ced6d1dbcb..40b0045223 100644 --- a/packages/ui/src/__tests__/live-turn-projection.test.ts +++ b/packages/ui/src/__tests__/live-turn-projection.test.ts @@ -372,7 +372,14 @@ describe('applyLiveTurnEvent', () => { }); const timeline = overlayLiveTurn([], withLateThinking)[0]?.timeline; - assert.deepEqual(timeline?.map((item) => item.kind), ['tools', 'thinking']); + // Folded into one Processing block (#1307); the tool still renders before + // the late reasoning inside the block — the ordering this test guards. + assert.deepEqual(timeline?.map((item) => item.kind), ['processing']); + const block = timeline?.[0]; + assert.deepEqual( + block?.kind === 'processing' ? block.children.map((child) => child.kind) : [], + ['tools', 'thinking'], + ); }); it('drops a terminal projection only after its last live step settles', () => { diff --git a/packages/ui/src/__tests__/materialize.test.ts b/packages/ui/src/__tests__/materialize.test.ts index 6570c010a9..14228e79ab 100644 --- a/packages/ui/src/__tests__/materialize.test.ts +++ b/packages/ui/src/__tests__/materialize.test.ts @@ -1,7 +1,12 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import type { AttachmentRef, StoredMessage } from '@maka/core'; -import { materializeChat } from '../materialize.js'; +import { + materializeChat, + materializeTurns, + overlayLiveTurn, + type TurnTimelineItem, +} from '../materialize.js'; const imageAttachment: AttachmentRef = { kind: 'image', @@ -74,3 +79,130 @@ describe('materializeChat attachments', () => { ); }); }); + +// ── #1307: reasoning + tool calls fold into collapsible Processing blocks ───── + +function userMsg(turnId: string, ts: number, text: string): StoredMessage { + return { type: 'user', id: `u-${turnId}`, turnId, ts, text }; +} + +function assistantStep( + turnId: string, + ts: number, + id: string, + text: string, + thinking?: string, +): StoredMessage { + return { + type: 'assistant', + id, + turnId, + ts, + text, + modelId: 'm', + ...(thinking !== undefined ? { thinking: { text: thinking } } : {}), + } as StoredMessage; +} + +function toolCallStep(turnId: string, ts: number, id: string, stepId: string, toolName = 'Read'): StoredMessage { + return { type: 'tool_call', id, turnId, ts, toolName, args: {}, stepId }; +} + +function toolResult(turnId: string, ts: number, toolUseId: string): StoredMessage { + return { type: 'tool_result', id: `r-${toolUseId}`, turnId, ts, toolUseId, isError: false, content: { kind: 'text', text: 'ok' } }; +} + +function childKinds(item: TurnTimelineItem | undefined): string[] { + return item?.kind === 'processing' ? item.children.map((child) => child.kind) : []; +} + +describe('materializeTurns processing grouping (#1307)', () => { + test('folds a pure-thinking run into one processing block', () => { + const turns = materializeTurns([ + userMsg('t1', 100, 'q'), + assistantStep('t1', 101, 'a1', '', 'reasoning only'), + ]); + const timeline = turns[0]!.timeline; + assert.deepEqual(timeline.map((item) => item.kind), ['processing']); + assert.deepEqual(childKinds(timeline[0]), ['thinking']); + }); + + test('folds a pure-tools run into one processing block', () => { + const turns = materializeTurns([ + userMsg('t1', 100, 'q'), + toolCallStep('t1', 101, 'c1', 'a1'), + toolResult('t1', 102, 'c1'), + assistantStep('t1', 103, 'a1', ''), + ]); + const timeline = turns[0]!.timeline; + assert.deepEqual(timeline.map((item) => item.kind), ['processing']); + assert.deepEqual(childKinds(timeline[0]), ['tools']); + }); + + test('keeps interleaved thinking + tools inside one block, in order', () => { + const turns = materializeTurns([ + userMsg('t1', 100, 'q'), + toolCallStep('t1', 101, 'c1', 'a1'), + toolResult('t1', 102, 'c1'), + assistantStep('t1', 103, 'a1', '', 'think then call'), + ]); + const timeline = turns[0]!.timeline; + assert.deepEqual(timeline.map((item) => item.kind), ['processing']); + // Reasoning renders above the tools it precedes, full timeline preserved. + assert.deepEqual(childKinds(timeline[0]), ['thinking', 'tools']); + }); + + test('answer text is a boundary: two steps yield several processing blocks around the texts', () => { + const turns = materializeTurns([ + userMsg('t1', 100, 'q'), + toolCallStep('t1', 101, 'c1', 'a1'), + toolResult('t1', 102, 'c1'), + assistantStep('t1', 103, 'a1', 'step one', 'think one'), + toolCallStep('t1', 104, 'c2', 'a2'), + toolResult('t1', 105, 'c2'), + assistantStep('t1', 106, 'a2', 'step two', 'think two'), + ]); + const timeline = turns[0]!.timeline; + // thinking, text, tools, thinking, text, tools -> + // processing[thinking], text, processing[tools, thinking], text, processing[tools] + assert.deepEqual(timeline.map((item) => item.kind), [ + 'processing', + 'text', + 'processing', + 'text', + 'processing', + ]); + assert.deepEqual(childKinds(timeline[0]), ['thinking']); + assert.deepEqual(childKinds(timeline[2]), ['tools', 'thinking']); + assert.deepEqual(childKinds(timeline[4]), ['tools']); + assert.equal((timeline[1] as { text: string }).text, 'step one'); + assert.equal((timeline[3] as { text: string }).text, 'step two'); + }); + + test('live overlay path folds a streaming step the same way as settled history', () => { + const timeline = overlayLiveTurn([], { + turnId: 't1', + phase: 'streamed', + steps: [{ + stepId: 'a1', + thinking: { text: '先测试工具', truncated: false, complete: false }, + tools: [{ toolUseId: 'c1', toolName: 'Read', stepId: 'a1', status: 'running', args: {} }], + }], + })[0]?.timeline; + assert.deepEqual(timeline?.map((item) => item.kind), ['processing']); + assert.deepEqual(childKinds(timeline?.[0]), ['thinking', 'tools']); + // The live processing block keeps its answer texts as boundaries: a live + // step with text splits reasoning/tools out of the answer. + const withText = overlayLiveTurn([], { + turnId: 't2', + phase: 'streamed', + steps: [{ + stepId: 'a1', + thinking: { text: 'think', truncated: false, complete: true }, + text: { text: 'answer', truncated: false, complete: false }, + tools: [{ toolUseId: 'c2', toolName: 'Bash', stepId: 'a1', status: 'running', args: {} }], + }], + })[0]?.timeline; + assert.deepEqual(withText?.map((item) => item.kind), ['processing', 'text', 'processing']); + }); +}); diff --git a/packages/ui/src/__tests__/tool-trow-summary.test.ts b/packages/ui/src/__tests__/tool-trow-summary.test.ts index cc1293f5db..ba378d5d9e 100644 --- a/packages/ui/src/__tests__/tool-trow-summary.test.ts +++ b/packages/ui/src/__tests__/tool-trow-summary.test.ts @@ -7,8 +7,13 @@ import { createElement, type ReactNode } from 'react'; import { renderToStaticMarkup as renderReactToStaticMarkup } from 'react-dom/server'; import { LocaleProvider } from '../locale-context.js'; import { ToolTrow } from '../tool-activity.js'; -import { summarizeTrowTools } from '../tool-activity/trow-summary.js'; -import type { ToolActivityItem } from '../materialize.js'; +import { + isProcessingRunning, + processingNeedsAttention, + summarizeProcessing, + summarizeTrowTools, +} from '../tool-activity/trow-summary.js'; +import type { ProcessingTimelineChild, ToolActivityItem } from '../materialize.js'; const toolActivitySource = readFileSync( join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'src', 'tool-activity.tsx'), @@ -101,3 +106,73 @@ describe('tool trow summary aggregation', () => { assert.equal((toolActivitySource.match(/\bSETTLE_FADE\b/g) ?? []).length, 2); }); }); + +function thinking(live?: boolean): ProcessingTimelineChild { + return { kind: 'thinking', text: 'reasoning', messageId: 'a1', ...(live !== undefined ? { live } : {}) }; +} + +function tools(items: ToolActivityItem[]): ProcessingTimelineChild { + return { kind: 'tools', items }; +} + +describe('processing block summary (#1307)', () => { + it('settled summary counts reasoning blocks + tool buckets + failed', () => { + const children = [ + thinking(), + tools([ + { toolUseId: 'r1', toolName: 'Read', activityKind: 'read', status: 'completed', args: {} }, + { toolUseId: 'g1', toolName: 'Grep', activityKind: 'search', status: 'errored', args: {} }, + ]), + thinking(), + ]; + // 思考计数 + 读取/搜索桶 + 标红失败计数,沿用 summarizeTrowTools 的文案与顺序。 + assert.equal(summarizeProcessing(children, {}), '思考 2 次,读取 1 个文件,搜索 1 次,1 个失败'); + }); + + it('a pure-thinking block summarizes as just the reasoning count', () => { + assert.equal(summarizeProcessing([thinking(), thinking()], {}), '思考 2 次'); + }); + + it('keeps the failed count on the live summary while a tool is still running', () => { + const children = [ + tools([ + { toolUseId: 'r1', toolName: 'Read', activityKind: 'read', status: 'errored', args: {} }, + { toolUseId: 'b1', toolName: 'Bash', activityKind: 'command', status: 'running', args: {}, intent: '运行测试' }, + ]), + ]; + // 运行中显示当前活动(最后一个在飞行中的工具意图),带「正在」前缀。 + assert.equal(summarizeProcessing(children, { live: true }), '正在运行测试'); + }); + + it('live summary falls back to the reasoning label when only thinking is streaming', () => { + assert.equal(summarizeProcessing([thinking(true)], { live: true }), '正在深度思考'); + }); + + it('is running while any tool is in flight or reasoning is still streaming', () => { + assert.equal(isProcessingRunning([thinking(true)]), true); + assert.equal(isProcessingRunning([thinking(false)]), false); + assert.equal( + isProcessingRunning([tools([{ toolUseId: 'r1', toolName: 'Read', status: 'running', args: {} }])]), + true, + ); + assert.equal( + isProcessingRunning([tools([{ toolUseId: 'r1', toolName: 'Read', status: 'completed', args: {} }])]), + false, + ); + }); + + it('needs attention (force-open) only for a waiting_permission prompt, not an error', () => { + assert.equal( + processingNeedsAttention([tools([{ toolUseId: 'w1', toolName: 'Write', status: 'waiting_permission', args: {} }])]), + true, + ); + // Errored tools stay collapsed — the summary line carries the failure count. + assert.equal( + processingNeedsAttention([ + thinking(), + tools([{ toolUseId: 'e1', toolName: 'Bash', status: 'errored', args: {} }]), + ]), + false, + ); + }); +}); diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index 754d886724..a222cc0251 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -1,20 +1,21 @@ import { Fragment, memo, useEffect, useRef, useState, type ReactNode } from 'react'; import { Button as BaseButton } from '@base-ui/react/button'; import { useMountedRef } from './use-mounted-ref.js'; -import { AlertOctagon, Ban, Brain, Check, ChevronRight, Copy, GitBranch, Info, Loader2, RefreshCcw, Timer } from './icons.js'; +import { AlertOctagon, Ban, Brain, Check, ChevronRight, Copy, Cpu, GitBranch, Info, Loader2, RefreshCcw, Timer } from './icons.js'; import { type ClipboardCopyPhase, useClipboardCopyFeedback } from './clipboard-feedback.js'; import { Markdown } from './markdown.js'; import { formatAbsoluteTimestamp, formatClockTime, turnAbortMarkerLabel } from './chat-display-helpers.js'; import { prepareSmoothStreamText, useSmoothStreamContent } from './smooth-stream.js'; import { tokenizeFade, useStreamFade, type StreamFade } from './stream-fade.js'; -import { Button as UiButton, DialogContent, DialogRoot } from './ui.js'; +import { Button as UiButton, cn, DialogContent, DialogRoot } from './ui.js'; import type { AttachmentRef } from '@maka/core'; -import type { TurnTimelineItem, TurnViewModel } from './materialize.js'; +import type { ProcessingTimelineChild, TurnTimelineItem, TurnViewModel } from './materialize.js'; import { AttachmentFileCard } from './attachment-file-card.js'; import { Collapsible, CollapsibleTrigger, CollapsiblePanel } from './primitives/collapsible.js'; import { Bubble, Marker, markerVariants, Message, TextShimmer } from './primitives/chat.js'; import { Tooltip, TooltipTrigger, TooltipContent } from './primitives/tooltip.js'; -import { ToolTrow } from './tool-activity.js'; +import { SETTLE_FADE, ToolTrow, useToolDisclosure } from './tool-activity.js'; +import { isProcessingRunning, processingNeedsAttention, summarizeProcessing } from './tool-activity/trow-summary.js'; import { useUiLocale } from './locale-context.js'; import { getConversationCopy } from './conversation-copy.js'; @@ -314,13 +315,7 @@ export const TurnView = memo(function TurnView(props: { // this is the live streaming tail (a thinking-only / textless streaming turn // has an empty committed timeline but must still show its live answer block). const showAssistantMessage = turn.timeline.length > 0 || !!props.liveStreaming; - const hasLiveTimelineContent = turn.timeline.some((item) => - item.kind === 'thinking' - ? item.live === true - : item.kind === 'text' - ? item.live === true - : item.items.some((tool) => tool.status === 'pending' || tool.status === 'running' || tool.status === 'waiting_permission'), - ); + const hasLiveTimelineContent = turn.timeline.some(timelineItemHasLiveContent); return (
tool.status === 'pending' || tool.status === 'running' || tool.status === 'waiting_permission', + ); + case 'processing': + return item.children.some(timelineItemHasLiveContent); + } +} + +/** Render one timeline entry: reasoning disclosure / answer bubble / tool trow / + * folded Processing block. */ function TurnTimelineEntry(props: { - item: TurnTimelineItem; + item: TurnTimelineItem | ProcessingTimelineChild; onStreamingSettled?: (messageId?: string) => void; }) { const { item } = props; @@ -799,6 +819,9 @@ function TurnTimelineEntry(props: { return ; } if (item.kind === 'tools') return ; + if (item.kind === 'processing') { + return ; + } if (item.kind === 'text' && item.live) { return ( ; } +/** + * "Processing" — a folded run of the model's reasoning + tool activity between + * two answer texts (#1307). Collapsed by default (no defaultOpen — same + * disclosure-collapsible-contract as 深度思考 / the tool trow): the summary line + * shows the current activity while running and freezes to a settled roll-up + * (思考 N 次 + tool counts + 「N 个失败」 in destructive) once the turn ends. A + * `waiting_permission` prompt inside forces the block open (trowNeedsAttention); + * an errored tool stays collapsed with its failure count on the summary line. + * The expanded panel replays the full timeline — the SAME 深度思考 disclosures + * and tool trows, just nested one indent in — so nothing is lost, only folded. + */ +function ProcessingBlock(props: { + entries: ProcessingTimelineChild[]; + onStreamingSettled?: (messageId?: string) => void; +}) { + const locale = useUiLocale(); + const { entries } = props; + const running = isProcessingRunning(entries); + const attention = processingNeedsAttention(entries); + // Reuse the tool disclosure state machine: ordinary work summarized, a + // permission prompt opens, an explicit toggle sticks across status changes. + const disclosure = useToolDisclosure({ kind: 'tool', summary: '', needsAttention: attention }); + // #646 settle seam: play the one-shot landing fade only if this block was + // seen running here (not a replayed transcript), matching the tool trow. + const everRunningRef = useRef(false); + if (running) everRunningRef.current = true; + const settled = !running; + const settling = settled && everRunningRef.current; + const hasError = entries.some((entry) => entry.kind === 'tools' && entry.items.some((item) => item.status === 'errored')); + const summary = summarizeProcessing(entries, { live: running, locale }); + return ( + + {/* Same row language as the tool trow / 深度思考: [16px icon] + [label] + + hover/open chevron, one tier — hierarchy carried by color, not size. */} + + + +
+ {entries.map((entry, index) => ( + + ))} +
+
+
+ ); +} + /** * "深度思考" — the unified reasoning disclosure for both live streaming and * committed history (replaces ReasoningPanel + the retired `.maka-turn-thinking` diff --git a/packages/ui/src/materialize.ts b/packages/ui/src/materialize.ts index 3605576f9a..f4cd41ebad 100644 --- a/packages/ui/src/materialize.ts +++ b/packages/ui/src/materialize.ts @@ -222,11 +222,23 @@ function mergeLiveOverPersisted(persisted: ToolActivityItem, live: ToolActivityI * step's wall-clock for hover meta. * - `tools`: one contiguous group of tool activity, rendered as a single * Codex-style trow. Adjacent groups are pre-merged. + * - `processing`: a maximal run of `thinking` + `tools` entries between two + * answer texts, folded into one collapsed "Processing" disclosure (#1307). + * Its `children` preserve the original interleaved order so the expanded + * block is the full timeline; answer `text` stays a grouping boundary and + * always renders in place, so a turn can hold several processing blocks. */ +export type ThinkingTimelineItem = { kind: 'thinking'; text: string; messageId: string; live?: boolean; truncated?: boolean }; +export type TextTimelineItem = { kind: 'text'; text: string; messageId: string; ts?: number; live?: boolean; complete?: boolean; truncated?: boolean }; +export type ToolsTimelineItem = { kind: 'tools'; items: ToolActivityItem[] }; +/** A single entry inside a folded processing block: reasoning or a tool group. */ +export type ProcessingTimelineChild = ThinkingTimelineItem | ToolsTimelineItem; +export type ProcessingTimelineItem = { kind: 'processing'; children: ProcessingTimelineChild[] }; export type TurnTimelineItem = - | { kind: 'thinking'; text: string; messageId: string; live?: boolean; truncated?: boolean } - | { kind: 'text'; text: string; messageId: string; ts?: number; live?: boolean; complete?: boolean; truncated?: boolean } - | { kind: 'tools'; items: ToolActivityItem[] }; + | ThinkingTimelineItem + | TextTimelineItem + | ToolsTimelineItem + | ProcessingTimelineItem; /** * A single conversational turn — typically one user message, the assistant's @@ -319,7 +331,11 @@ export function overlayLiveTurn( } } const timeline: TurnTimelineItem[] = []; - for (const item of current.timeline) { + // Rebuild from a flat timeline: unfold committed processing blocks so the + // settled-tool filter + live-step append operate on raw thinking/text/tools, + // then re-fold once via finalizeTimeline. Keeps grouping a single source of + // truth shared with the settled path (#1307). + for (const item of flattenProcessing(current.timeline)) { if (item.kind !== 'tools') { timeline.push(item); continue; @@ -360,7 +376,7 @@ export function overlayLiveTurn( } } } - const next = { ...current, tools, timeline: mergeAdjacentTimeline(timeline) }; + const next = { ...current, tools, timeline: finalizeTimeline(timeline) }; const overlaid = targetIndex < 0 ? [...turns, next] : turns.map((turn, index) => index === targetIndex ? next : turn); @@ -598,16 +614,33 @@ function projectTurnTools( return projectedTool ? [projectedTool] : []; }); let timelineChanged = false; - const timeline = turn.timeline.flatMap((item): TurnTimelineItem[] => { - if (item.kind !== 'tools') return [item]; - const items = item.items.flatMap((tool) => { + const projectTools = (source: readonly ToolActivityItem[]): { items: ToolActivityItem[]; changed: boolean } => { + const items = source.flatMap((tool) => { const projectedTool = projected.get(tool.toolUseId); return projectedTool ? [projectedTool] : []; }); - if (items.length !== item.items.length || items.some((tool, index) => tool !== item.items[index])) { + const changed = items.length !== source.length || items.some((tool, index) => tool !== source[index]); + return { items, changed }; + }; + const timeline = turn.timeline.flatMap((item): TurnTimelineItem[] => { + if (item.kind === 'tools') { + const { items, changed } = projectTools(item.items); + if (changed) timelineChanged = true; + return items.length > 0 ? [{ kind: 'tools' as const, items }] : []; + } + if (item.kind === 'processing') { + let childChanged = false; + const children = item.children.flatMap((child): ProcessingTimelineChild[] => { + if (child.kind !== 'tools') return [child]; + const { items, changed } = projectTools(child.items); + if (changed) childChanged = true; + return items.length > 0 ? [{ kind: 'tools' as const, items }] : []; + }); + if (!childChanged) return [item]; timelineChanged = true; + return children.length > 0 ? [{ kind: 'processing' as const, children }] : []; } - return items.length > 0 ? [{ kind: 'tools' as const, items }] : []; + return [item]; }); const toolsChanged = nextTools.length !== turn.tools.length || nextTools.some((tool, index) => tool !== turn.tools[index]); @@ -706,7 +739,44 @@ function buildTurnTimeline( } } flushTools(pending); - return mergeAdjacentTimeline(raw); + return finalizeTimeline(raw); +} + +/** + * Shared final pass for both the settled (`buildTurnTimeline`) and live-overlay + * (`overlayLiveTurn`) paths: merge adjacent thinking/tool runs, then fold every + * maximal thinking+tools run between answer texts into one collapsed + * `processing` block (#1307). Answer `text` is the only grouping boundary, so a + * turn can carry several processing blocks and the answer always reads in place. + */ +function finalizeTimeline(items: readonly TurnTimelineItem[]): TurnTimelineItem[] { + return groupProcessing(mergeAdjacentTimeline(items)); +} + +/** Expand any folded processing blocks back into their raw thinking/tools + * children, so the overlay/projection passes can rebuild from a flat timeline + * and re-group once at the end. Text passes through untouched. */ +function flattenProcessing(items: readonly TurnTimelineItem[]): TurnTimelineItem[] { + return items.flatMap((item) => (item.kind === 'processing' ? item.children : [item])); +} + +function groupProcessing(items: readonly TurnTimelineItem[]): TurnTimelineItem[] { + const out: TurnTimelineItem[] = []; + let buffer: ProcessingTimelineChild[] | null = null; + const flush = (): void => { + if (buffer && buffer.length > 0) out.push({ kind: 'processing', children: buffer }); + buffer = null; + }; + for (const item of items) { + if (item.kind === 'thinking' || item.kind === 'tools') { + (buffer ??= []).push(item); + } else { + flush(); + out.push(item); + } + } + flush(); + return out; } function mergeAdjacentTimeline(items: readonly TurnTimelineItem[]): TurnTimelineItem[] { diff --git a/packages/ui/src/tool-activity.tsx b/packages/ui/src/tool-activity.tsx index 8cb7c9afec..4b627f9186 100644 --- a/packages/ui/src/tool-activity.tsx +++ b/packages/ui/src/tool-activity.tsx @@ -166,7 +166,7 @@ function AutomationResultPreview(props: { text: string }) { return ; } -function useToolDisclosure(presentation: ToolActivityPresentation) { +export function useToolDisclosure(presentation: ToolActivityPresentation) { const [disclosure, setDisclosure] = useState(() => createToolDisclosureState(presentation)); useEffect(() => { setDisclosure((current) => syncToolDisclosureState(current, presentation)); @@ -379,7 +379,7 @@ const TROW_KIND_ICON: Record> = { // under reduced-motion / e2e-fixture by the global rules in styles/base.css. // The per-row seam is a light-band stop (no opacity fade) so parallel tools // finishing together don't stack N fades (#tool-jitter). -const SETTLE_FADE = '[animation:maka-stream-fade-in_var(--duration-emphasized)_var(--ease-out-strong)_both]'; +export const SETTLE_FADE = '[animation:maka-stream-fade-in_var(--duration-emphasized)_var(--ease-out-strong)_both]'; /** * Codex-style tool trow (streaming UI rework): one contiguous run of tool diff --git a/packages/ui/src/tool-activity/copy.ts b/packages/ui/src/tool-activity/copy.ts index 4dcf1540d0..02d410e545 100644 --- a/packages/ui/src/tool-activity/copy.ts +++ b/packages/ui/src/tool-activity/copy.ts @@ -51,6 +51,10 @@ export interface ToolActivityCopy { failed: (count: number) => string; join: (clauses: readonly string[]) => string; live: (summary: string) => string; + /** Settled count clause for reasoning blocks inside a processing group. */ + thinking: (count: number) => string; + /** Live current-activity label when the processing group is reasoning. */ + thinkingActivity: string; }; automation: { created: (name: string) => string; @@ -178,6 +182,7 @@ const TOOL_ACTIVITY_COPY = { summary: { kind: { read: (n) => `读取 ${n} 个文件`, search: (n) => `搜索 ${n} 次`, websearch: (n) => `联网搜索 ${n} 次`, webfetch: (n) => `抓取 ${n} 个网页`, edit: (n) => `编辑 ${n} 个文件`, command: (n) => `运行 ${n} 条命令`, explore: (n) => `探索 ${n} 次`, browser: (n) => `浏览器操作 ${n} 次`, tool: (n) => `调用 ${n} 个工具` }, failed: (n) => `${n} 个失败`, join: (clauses) => clauses.join(','), live: (summary) => `正在${summary}`, + thinking: (n) => `思考 ${n} 次`, thinkingActivity: '深度思考', }, automation: { created: (name) => `自动化任务已创建:${name}`, nextFire: (value) => `下次触发:${value}`, deleted: '自动化任务已删除', notFound: '未找到该任务(可能已完成或已删除)', list: (count) => `自动化任务列表 (${count})`, empty: '当前会话暂无自动化任务' }, loadTools: { displayName: '加载工具组', loaded: (namespace) => namespace ? `已加载 ${namespace} 工具组` : '已加载工具组', count: (n) => `新增 ${n} 个可用工具:`, footer: '下一步即可调用' }, @@ -210,6 +215,7 @@ const TOOL_ACTIVITY_COPY = { summary: { kind: { read: (n) => `Read ${n} ${n === 1 ? 'file' : 'files'}`, search: (n) => `Searched ${n} ${n === 1 ? 'time' : 'times'}`, websearch: (n) => `Ran ${n} web ${n === 1 ? 'search' : 'searches'}`, webfetch: (n) => `Fetched ${n} web ${n === 1 ? 'page' : 'pages'}`, edit: (n) => `Edited ${n} ${n === 1 ? 'file' : 'files'}`, command: (n) => `Ran ${n} ${n === 1 ? 'command' : 'commands'}`, explore: (n) => `Explored ${n} ${n === 1 ? 'time' : 'times'}`, browser: (n) => `Performed ${n} browser ${n === 1 ? 'action' : 'actions'}`, tool: (n) => `Called ${n} ${n === 1 ? 'tool' : 'tools'}` }, failed: (n) => `${n} failed`, join: (clauses) => clauses.join(', '), live: (summary) => `Working: ${summary}`, + thinking: (n) => `Thought ${n} ${n === 1 ? 'time' : 'times'}`, thinkingActivity: 'Thinking', }, automation: { created: (name) => `Automation created: ${name}`, nextFire: (value) => `Next run: ${value}`, deleted: 'Automation deleted', notFound: 'Automation not found (it may have completed or been deleted)', list: (count) => `Automations (${count})`, empty: 'No automations in this conversation' }, loadTools: { displayName: 'Load tools', loaded: (namespace) => namespace ? `Loaded ${namespace} tools` : 'Loaded tools', count: (n) => `Added ${n} available ${n === 1 ? 'tool' : 'tools'}:`, footer: 'Ready to use' }, diff --git a/packages/ui/src/tool-activity/trow-summary.ts b/packages/ui/src/tool-activity/trow-summary.ts index d22893b735..eb8e599414 100644 --- a/packages/ui/src/tool-activity/trow-summary.ts +++ b/packages/ui/src/tool-activity/trow-summary.ts @@ -12,8 +12,9 @@ */ import type { ToolActivityKind, UiLocale } from '@maka/core'; -import type { ToolActivityItem } from '../materialize.js'; +import type { ProcessingTimelineChild, ToolActivityItem } from '../materialize.js'; import { getToolActivityCopy } from './copy.js'; +import { formatUserVisibleToolText } from './preview-utils.js'; export type TrowActivityKind = ToolActivityKind; @@ -125,3 +126,79 @@ export function isTrowRunning(items: readonly ToolActivityItem[]): boolean { export function trowNeedsAttention(items: readonly ToolActivityItem[]): boolean { return items.some((item) => item.status === 'waiting_permission'); } + +// ── Processing block (#1307) ──────────────────────────────────────────────── +// A processing block folds a maximal run of reasoning + tool groups between two +// answer texts. Its summary reuses the trow bucket clauses and prepends a +// reasoning-count clause; the failed count stays visible (errored tools remain +// collapsed, so the summary line is the failure signal, matching the trow). + +/** All tool items across the block's tool groups, in order. */ +function processingTools(children: readonly ProcessingTimelineChild[]): ToolActivityItem[] { + return children.flatMap((child) => (child.kind === 'tools' ? child.items : [])); +} + +/** Number of reasoning blocks folded into the processing group. */ +function processingThinkingCount(children: readonly ProcessingTimelineChild[]): number { + return children.reduce((count, child) => (child.kind === 'thinking' ? count + 1 : count), 0); +} + +/** True while any tool is in flight or any reasoning block is still streaming. */ +export function isProcessingRunning(children: readonly ProcessingTimelineChild[]): boolean { + return children.some((child) => + child.kind === 'thinking' ? child.live === true : isTrowRunning(child.items), + ); +} + +/** + * True when the block must force itself open: a permission prompt sits inside. + * Mirrors `trowNeedsAttention` — an errored tool does NOT force-open; the + * settled summary carries the failure count (「N 个失败」 in destructive color). + */ +export function processingNeedsAttention(children: readonly ProcessingTimelineChild[]): boolean { + return children.some((child) => child.kind === 'tools' && trowNeedsAttention(child.items)); +} + +/** + * Summary line for a processing block. Settled: reasoning-count clause + + * per-bucket tool clauses + failed count, joined like the trow. Live + * (`{ live: true }`): the current activity — the running tool's intent (or the + * reasoning label when only thinking is streaming), prefixed with "正在". + */ +export function summarizeProcessing( + children: readonly ProcessingTimelineChild[], + options?: { live?: boolean; locale?: UiLocale }, +): string { + const locale = options?.locale ?? 'zh'; + const copy = getToolActivityCopy(locale).summary; + if (options?.live) return processingLiveSummary(children, locale); + const tools = processingTools(children); + const thinkingCount = processingThinkingCount(children); + const clauses: string[] = []; + if (thinkingCount > 0) clauses.push(copy.thinking(thinkingCount)); + if (tools.length > 0) clauses.push(summarizeTrowTools(tools, { locale })); + return copy.join(clauses); +} + +/** Current-activity line for a running processing block. */ +function processingLiveSummary( + children: readonly ProcessingTimelineChild[], + locale: UiLocale, +): string { + const copy = getToolActivityCopy(locale).summary; + const tools = processingTools(children); + const activeTool = [...tools] + .reverse() + .find( + (tool) => + tool.status === 'running' || tool.status === 'pending' || tool.status === 'waiting_permission', + ); + if (activeTool) { + const label = + formatUserVisibleToolText(activeTool.intent ?? '', locale) + || activeTool.displayName + || activeTool.toolName; + return copy.live(label); + } + return copy.live(copy.thinkingActivity); +} From dbb28186545a0fddf78b3892a164785da55db9f9 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 22 Jul 2026 09:17:44 +0800 Subject: [PATCH 2/6] test(ui): add a Processing story for the folded reasoning + tools block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show a collapsed "Processing" block (#1307): two think-then-call steps with a failing tool fold into one summary (思考 2 次 + tool counts + a red failed count) ahead of the assistant answer. Enumerate it in the chat-surface Storybook contract. --- .../chat-surface-storybook-contract.test.ts | 1 + packages/ui/stories/chat-surface.stories.tsx | 123 ++++++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/apps/desktop/src/main/__tests__/chat-surface-storybook-contract.test.ts b/apps/desktop/src/main/__tests__/chat-surface-storybook-contract.test.ts index 22f5a7b7ae..812eb413ea 100644 --- a/apps/desktop/src/main/__tests__/chat-surface-storybook-contract.test.ts +++ b/apps/desktop/src/main/__tests__/chat-surface-storybook-contract.test.ts @@ -19,6 +19,7 @@ describe('chat surface Storybook contract', () => { 'EmptyChat', 'StreamingResponse', 'WithToolActivity', + 'Processing', 'BranchedConversation', 'ComposerPendingAndDisabled', 'ImportActions', diff --git a/packages/ui/stories/chat-surface.stories.tsx b/packages/ui/stories/chat-surface.stories.tsx index aba7545b0b..b018773d12 100644 --- a/packages/ui/stories/chat-surface.stories.tsx +++ b/packages/ui/stories/chat-surface.stories.tsx @@ -390,6 +390,119 @@ const multiStepConversation: StoredMessage[] = [ }, ]; +// #1307: reasoning + tool calls between two answer texts fold into one +// collapsed "Processing" block. Here a single reasoning/tool phase (two +// think-then-call steps with no answer text between them, one failing tool) +// collapses into one Processing summary — 思考 2 次 + tool counts + a red failed +// count — followed by the assistant's answer text rendered in place. +const processingConversation: StoredMessage[] = [ + user('msg-user-processing', 'turn-processing', 13, '排查 stream-fade 的环边界,跑一下单测确认。'), + { + type: 'tool_call', + id: 'proc-read', + turnId: 'turn-processing', + ts: NOW - 12 * 60_000, + toolName: 'Read', + activityKind: 'read', + displayName: '读取 stream-fade.ts', + intent: '读取淡入环实现,确认窗口滑动与上限', + stepId: 'proc-a1', + args: { file_path: 'packages/ui/src/stream-fade.ts' }, + }, + { + type: 'tool_result', + id: 'proc-read-result', + turnId: 'turn-processing', + ts: NOW - 12 * 60_000 + 700, + toolUseId: 'proc-read', + isError: false, + durationMs: 620, + content: { kind: 'text', text: 'export function updateFadeRing(...) { /* prune + cap */ }' }, + }, + { + type: 'assistant', + id: 'proc-a1', + turnId: 'turn-processing', + ts: NOW - 11 * 60_000, + text: '', + thinking: { text: '先读实现,确认 boundary 取最老存活批次的 start,age 用 now 减去覆盖该 offset 的批次时间。' }, + modelId: 'claude-sonnet-4-5', + }, + { + type: 'tool_call', + id: 'proc-grep', + turnId: 'turn-processing', + ts: NOW - 11 * 60_000 + 400, + toolName: 'Grep', + activityKind: 'search', + displayName: '搜索 updateFadeRing 调用点', + intent: '搜索环更新的调用点,确认边界处理是否一致', + stepId: 'proc-a2', + args: { pattern: 'updateFadeRing' }, + }, + { + type: 'tool_result', + id: 'proc-grep-result', + turnId: 'turn-processing', + ts: NOW - 11 * 60_000 + 900, + toolUseId: 'proc-grep', + isError: false, + durationMs: 480, + content: { kind: 'text', text: 'packages/ui/src/stream-fade.ts:42\npackages/ui/src/chat-turn.tsx:910' }, + }, + { + type: 'tool_call', + id: 'proc-test', + turnId: 'turn-processing', + ts: NOW - 11 * 60_000 + 1_200, + toolName: 'Bash', + activityKind: 'command', + displayName: '运行 stream-fade 单测', + intent: '执行 node --test 跑淡入环单测', + stepId: 'proc-a2', + args: { cmd: 'node --test dist/main/__tests__/stream-fade.test.js' }, + }, + { + type: 'tool_result', + id: 'proc-test-result', + turnId: 'turn-processing', + ts: NOW - 10 * 60_000, + toolUseId: 'proc-test', + isError: true, + durationMs: 1_640, + content: { + kind: 'terminal', + cwd: '/workspace/maka-agent/apps/desktop', + cmd: 'node --test dist/main/__tests__/stream-fade.test.js', + status: 'failed', + exitCode: 1, + output: { + mode: 'pipes', + stdout: 'tests 13\npass 12\nfail 1\n', + stderr: 'not ok 7 - collapses the ring when a batch ages out\n', + stdoutTruncated: false, + stderrTruncated: false, + redacted: false, + }, + }, + }, + { + type: 'assistant', + id: 'proc-a2', + turnId: 'turn-processing', + ts: NOW - 10 * 60_000 + 500, + text: '', + thinking: { text: '调用点只有两处,边界一致;跑测试却挂了一个 age-out 用例,说明剪枝顺序还有问题。' }, + modelId: 'claude-sonnet-4-5', + }, + assistant( + 'proc-a3', + 'turn-processing', + 9, + '有一个 age-out 用例失败,说明批次超窗剪枝在收缩路径上漏了一步。我先补上重置逻辑,再重跑这条单测。上面的 Processing 折叠里保留了完整的思考与工具时间线。', + ), +]; + export const EmptyChat: Story = { render: () => ( ( + + ), +}; + export const BranchedConversation: Story = { render: () => ( Date: Wed, 22 Jul 2026 10:29:24 +0800 Subject: [PATCH 3/6] fix(ui): drop thinking count from Processing summary and keep pure-thinking runs bare MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on #1307: - The collapsed summary line now rolls up tool activity only (「读取 1 个文件, 搜索 1 次,1 个失败」) — folded reasoning stays inside the block but is no longer counted; the settled `summary.thinking` copy is removed (zh/en). The live `thinkingActivity` fallback (「正在深度思考」 when the block's tools are done and thinking still streams) stays. - A run between two answer texts folds into a Processing block only when it contains at least one tools group; a pure-thinking run renders as the bare 深度思考 disclosure (including the live streaming path). groupProcessing applies the rule identically on the settled and live-overlay paths. --- .../main/__tests__/materialize-turns.test.ts | 10 ++----- .../main/__tests__/streaming-handoff.test.ts | 16 +++++----- packages/ui/src/__tests__/materialize.test.ts | 18 ++++++----- .../src/__tests__/tool-trow-summary.test.ts | 20 ++++++------- packages/ui/src/chat-turn.tsx | 10 ++++--- packages/ui/src/materialize.ts | 19 +++++++++--- packages/ui/src/tool-activity/copy.ts | 9 +++--- packages/ui/src/tool-activity/trow-summary.ts | 30 +++++++------------ packages/ui/stories/chat-surface.stories.tsx | 5 ++-- 9 files changed, 70 insertions(+), 67 deletions(-) diff --git a/apps/desktop/src/main/__tests__/materialize-turns.test.ts b/apps/desktop/src/main/__tests__/materialize-turns.test.ts index 762b0bcdd1..a2ee94ae9a 100644 --- a/apps/desktop/src/main/__tests__/materialize-turns.test.ts +++ b/apps/desktop/src/main/__tests__/materialize-turns.test.ts @@ -650,19 +650,15 @@ describe('materializeTurns timeline', () => { assistantStep('t1', 106, 'a2', 'step two', 'think two'), ]); const timeline = turns[0]!.timeline; - // Answer text is the only boundary; reasoning/tools around each text fold. + // Answer text is the only boundary; a run folds only when it contains tool + // activity, so the lone pre-answer reasoning stays a bare thinking entry. assert.deepEqual(timeline.map((item) => item.kind), [ - 'processing', + 'thinking', 'text', 'processing', 'text', 'processing', ]); - const first = timeline[0]; - assert.deepEqual( - first?.kind === 'processing' ? first.children.map((child) => child.kind) : [], - ['thinking'], - ); const middle = timeline[2]; assert.deepEqual( middle?.kind === 'processing' ? middle.children.map((child) => child.kind) : [], diff --git a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts index 23649f95b7..b7f47343a5 100644 --- a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts @@ -74,14 +74,14 @@ describe('single live-turn handoff', () => { }], }); - // #1307: reasoning + the tool fold into collapsed "Processing" blocks, and - // the answer text is the grouping boundary — so reasoning folds into a block - // above the answer and the tool into a block below it. Both blocks are - // collapsed (their bodies are not in the static markup); the summary lines - // carry the order: 思考 above the answer, the tool command below it. - assert.equal((markup.match(/data-processing="block"/g) ?? []).length, 2); - assert.ok(markup.indexOf('思考 1 次') >= 0); - assert.ok(markup.indexOf('思考 1 次') < markup.indexOf('最终答案')); + // #1307: the answer text is the grouping boundary and a pure-thinking run + // stays bare, so the reasoning renders as the 深度思考 disclosure above the + // answer while the tool folds into one collapsed "Processing" block below + // it (its body is not in the static markup; the summary line carries the + // tool roll-up). + assert.equal((markup.match(/data-processing="block"/g) ?? []).length, 1); + assert.ok(markup.indexOf('深度思考') >= 0); + assert.ok(markup.indexOf('深度思考') < markup.indexOf('最终答案')); assert.ok(markup.indexOf('最终答案') < markup.indexOf('运行 1 条命令')); assert.equal((markup.match(/data-turn-id=/g) ?? []).length, 1); }); diff --git a/packages/ui/src/__tests__/materialize.test.ts b/packages/ui/src/__tests__/materialize.test.ts index 14228e79ab..341e5df1c0 100644 --- a/packages/ui/src/__tests__/materialize.test.ts +++ b/packages/ui/src/__tests__/materialize.test.ts @@ -117,14 +117,15 @@ function childKinds(item: TurnTimelineItem | undefined): string[] { } describe('materializeTurns processing grouping (#1307)', () => { - test('folds a pure-thinking run into one processing block', () => { + test('leaves a pure-thinking run bare instead of folding it (review fix)', () => { const turns = materializeTurns([ userMsg('t1', 100, 'q'), assistantStep('t1', 101, 'a1', '', 'reasoning only'), ]); const timeline = turns[0]!.timeline; - assert.deepEqual(timeline.map((item) => item.kind), ['processing']); - assert.deepEqual(childKinds(timeline[0]), ['thinking']); + // No tools in the run → no processing block; the 深度思考 disclosure + // renders the reasoning directly. + assert.deepEqual(timeline.map((item) => item.kind), ['thinking']); }); test('folds a pure-tools run into one processing block', () => { @@ -164,15 +165,15 @@ describe('materializeTurns processing grouping (#1307)', () => { ]); const timeline = turns[0]!.timeline; // thinking, text, tools, thinking, text, tools -> - // processing[thinking], text, processing[tools, thinking], text, processing[tools] + // thinking (pure run stays bare), text, processing[tools, thinking], + // text, processing[tools] assert.deepEqual(timeline.map((item) => item.kind), [ - 'processing', + 'thinking', 'text', 'processing', 'text', 'processing', ]); - assert.deepEqual(childKinds(timeline[0]), ['thinking']); assert.deepEqual(childKinds(timeline[2]), ['tools', 'thinking']); assert.deepEqual(childKinds(timeline[4]), ['tools']); assert.equal((timeline[1] as { text: string }).text, 'step one'); @@ -192,7 +193,8 @@ describe('materializeTurns processing grouping (#1307)', () => { assert.deepEqual(timeline?.map((item) => item.kind), ['processing']); assert.deepEqual(childKinds(timeline?.[0]), ['thinking', 'tools']); // The live processing block keeps its answer texts as boundaries: a live - // step with text splits reasoning/tools out of the answer. + // step with text splits reasoning/tools out of the answer, and a lone + // pre-answer thinking run stays bare (same rule as settled history). const withText = overlayLiveTurn([], { turnId: 't2', phase: 'streamed', @@ -203,6 +205,6 @@ describe('materializeTurns processing grouping (#1307)', () => { tools: [{ toolUseId: 'c2', toolName: 'Bash', stepId: 'a1', status: 'running', args: {} }], }], })[0]?.timeline; - assert.deepEqual(withText?.map((item) => item.kind), ['processing', 'text', 'processing']); + assert.deepEqual(withText?.map((item) => item.kind), ['thinking', 'text', 'processing']); }); }); diff --git a/packages/ui/src/__tests__/tool-trow-summary.test.ts b/packages/ui/src/__tests__/tool-trow-summary.test.ts index ba378d5d9e..9ea27d08cf 100644 --- a/packages/ui/src/__tests__/tool-trow-summary.test.ts +++ b/packages/ui/src/__tests__/tool-trow-summary.test.ts @@ -116,7 +116,7 @@ function tools(items: ToolActivityItem[]): ProcessingTimelineChild { } describe('processing block summary (#1307)', () => { - it('settled summary counts reasoning blocks + tool buckets + failed', () => { + it('settled summary rolls up tool activity only — folded reasoning is not counted', () => { const children = [ thinking(), tools([ @@ -125,15 +125,11 @@ describe('processing block summary (#1307)', () => { ]), thinking(), ]; - // 思考计数 + 读取/搜索桶 + 标红失败计数,沿用 summarizeTrowTools 的文案与顺序。 - assert.equal(summarizeProcessing(children, {}), '思考 2 次,读取 1 个文件,搜索 1 次,1 个失败'); + // 只汇总工具桶 + 标红失败计数(沿用 summarizeTrowTools 文案),不出现「思考 N 次」。 + assert.equal(summarizeProcessing(children, {}), '读取 1 个文件,搜索 1 次,1 个失败'); }); - it('a pure-thinking block summarizes as just the reasoning count', () => { - assert.equal(summarizeProcessing([thinking(), thinking()], {}), '思考 2 次'); - }); - - it('keeps the failed count on the live summary while a tool is still running', () => { + it('shows the running tool intent as the live current activity', () => { const children = [ tools([ { toolUseId: 'r1', toolName: 'Read', activityKind: 'read', status: 'errored', args: {} }, @@ -144,8 +140,12 @@ describe('processing block summary (#1307)', () => { assert.equal(summarizeProcessing(children, { live: true }), '正在运行测试'); }); - it('live summary falls back to the reasoning label when only thinking is streaming', () => { - assert.equal(summarizeProcessing([thinking(true)], { live: true }), '正在深度思考'); + it('live summary falls back to the reasoning label when tools are done and thinking still streams', () => { + const children = [ + tools([{ toolUseId: 'r1', toolName: 'Read', activityKind: 'read', status: 'completed', args: {} }]), + thinking(true), + ]; + assert.equal(summarizeProcessing(children, { live: true }), '正在深度思考'); }); it('is running while any tool is in flight or reasoning is still streaming', () => { diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index a222cc0251..9ad41f3a53 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -837,10 +837,12 @@ function TurnTimelineEntry(props: { /** * "Processing" — a folded run of the model's reasoning + tool activity between - * two answer texts (#1307). Collapsed by default (no defaultOpen — same - * disclosure-collapsible-contract as 深度思考 / the tool trow): the summary line - * shows the current activity while running and freezes to a settled roll-up - * (思考 N 次 + tool counts + 「N 个失败」 in destructive) once the turn ends. A + * two answer texts (#1307; a run folds only when it contains tool activity — a + * pure-thinking run renders as the bare 深度思考 disclosure). Collapsed by + * default (no defaultOpen — same disclosure-collapsible-contract as 深度思考 / + * the tool trow): the summary line shows the current activity while running and + * freezes to the settled tool roll-up (tool counts + 「N 个失败」 in + * destructive; folded reasoning is not counted) once the turn ends. A * `waiting_permission` prompt inside forces the block open (trowNeedsAttention); * an errored tool stays collapsed with its failure count on the summary line. * The expanded panel replays the full timeline — the SAME 深度思考 disclosures diff --git a/packages/ui/src/materialize.ts b/packages/ui/src/materialize.ts index f4cd41ebad..fc0a5dfb34 100644 --- a/packages/ui/src/materialize.ts +++ b/packages/ui/src/materialize.ts @@ -224,9 +224,11 @@ function mergeLiveOverPersisted(persisted: ToolActivityItem, live: ToolActivityI * Codex-style trow. Adjacent groups are pre-merged. * - `processing`: a maximal run of `thinking` + `tools` entries between two * answer texts, folded into one collapsed "Processing" disclosure (#1307). - * Its `children` preserve the original interleaved order so the expanded - * block is the full timeline; answer `text` stays a grouping boundary and - * always renders in place, so a turn can hold several processing blocks. + * A run folds only when it contains at least one tools group — a + * pure-thinking run stays bare. `children` preserve the original interleaved + * order so the expanded block is the full timeline; answer `text` stays a + * grouping boundary and always renders in place, so a turn can hold several + * processing blocks. */ export type ThinkingTimelineItem = { kind: 'thinking'; text: string; messageId: string; live?: boolean; truncated?: boolean }; export type TextTimelineItem = { kind: 'text'; text: string; messageId: string; ts?: number; live?: boolean; complete?: boolean; truncated?: boolean }; @@ -764,7 +766,16 @@ function groupProcessing(items: readonly TurnTimelineItem[]): TurnTimelineItem[] const out: TurnTimelineItem[] = []; let buffer: ProcessingTimelineChild[] | null = null; const flush = (): void => { - if (buffer && buffer.length > 0) out.push({ kind: 'processing', children: buffer }); + if (buffer && buffer.length > 0) { + // A run folds only when it contains tool activity. A pure-thinking run + // stays bare so the existing 深度思考 disclosure renders it directly — + // wrapping a lone reasoning block would just double the fold. + if (buffer.some((child) => child.kind === 'tools')) { + out.push({ kind: 'processing', children: buffer }); + } else { + out.push(...buffer); + } + } buffer = null; }; for (const item of items) { diff --git a/packages/ui/src/tool-activity/copy.ts b/packages/ui/src/tool-activity/copy.ts index 02d410e545..8d4161a74e 100644 --- a/packages/ui/src/tool-activity/copy.ts +++ b/packages/ui/src/tool-activity/copy.ts @@ -51,9 +51,8 @@ export interface ToolActivityCopy { failed: (count: number) => string; join: (clauses: readonly string[]) => string; live: (summary: string) => string; - /** Settled count clause for reasoning blocks inside a processing group. */ - thinking: (count: number) => string; - /** Live current-activity label when the processing group is reasoning. */ + /** Live current-activity label when a processing group's tools are done + * and only reasoning is still streaming. */ thinkingActivity: string; }; automation: { @@ -182,7 +181,7 @@ const TOOL_ACTIVITY_COPY = { summary: { kind: { read: (n) => `读取 ${n} 个文件`, search: (n) => `搜索 ${n} 次`, websearch: (n) => `联网搜索 ${n} 次`, webfetch: (n) => `抓取 ${n} 个网页`, edit: (n) => `编辑 ${n} 个文件`, command: (n) => `运行 ${n} 条命令`, explore: (n) => `探索 ${n} 次`, browser: (n) => `浏览器操作 ${n} 次`, tool: (n) => `调用 ${n} 个工具` }, failed: (n) => `${n} 个失败`, join: (clauses) => clauses.join(','), live: (summary) => `正在${summary}`, - thinking: (n) => `思考 ${n} 次`, thinkingActivity: '深度思考', + thinkingActivity: '深度思考', }, automation: { created: (name) => `自动化任务已创建:${name}`, nextFire: (value) => `下次触发:${value}`, deleted: '自动化任务已删除', notFound: '未找到该任务(可能已完成或已删除)', list: (count) => `自动化任务列表 (${count})`, empty: '当前会话暂无自动化任务' }, loadTools: { displayName: '加载工具组', loaded: (namespace) => namespace ? `已加载 ${namespace} 工具组` : '已加载工具组', count: (n) => `新增 ${n} 个可用工具:`, footer: '下一步即可调用' }, @@ -215,7 +214,7 @@ const TOOL_ACTIVITY_COPY = { summary: { kind: { read: (n) => `Read ${n} ${n === 1 ? 'file' : 'files'}`, search: (n) => `Searched ${n} ${n === 1 ? 'time' : 'times'}`, websearch: (n) => `Ran ${n} web ${n === 1 ? 'search' : 'searches'}`, webfetch: (n) => `Fetched ${n} web ${n === 1 ? 'page' : 'pages'}`, edit: (n) => `Edited ${n} ${n === 1 ? 'file' : 'files'}`, command: (n) => `Ran ${n} ${n === 1 ? 'command' : 'commands'}`, explore: (n) => `Explored ${n} ${n === 1 ? 'time' : 'times'}`, browser: (n) => `Performed ${n} browser ${n === 1 ? 'action' : 'actions'}`, tool: (n) => `Called ${n} ${n === 1 ? 'tool' : 'tools'}` }, failed: (n) => `${n} failed`, join: (clauses) => clauses.join(', '), live: (summary) => `Working: ${summary}`, - thinking: (n) => `Thought ${n} ${n === 1 ? 'time' : 'times'}`, thinkingActivity: 'Thinking', + thinkingActivity: 'Thinking', }, automation: { created: (name) => `Automation created: ${name}`, nextFire: (value) => `Next run: ${value}`, deleted: 'Automation deleted', notFound: 'Automation not found (it may have completed or been deleted)', list: (count) => `Automations (${count})`, empty: 'No automations in this conversation' }, loadTools: { displayName: 'Load tools', loaded: (namespace) => namespace ? `Loaded ${namespace} tools` : 'Loaded tools', count: (n) => `Added ${n} available ${n === 1 ? 'tool' : 'tools'}:`, footer: 'Ready to use' }, diff --git a/packages/ui/src/tool-activity/trow-summary.ts b/packages/ui/src/tool-activity/trow-summary.ts index eb8e599414..b2b935ff05 100644 --- a/packages/ui/src/tool-activity/trow-summary.ts +++ b/packages/ui/src/tool-activity/trow-summary.ts @@ -129,20 +129,17 @@ export function trowNeedsAttention(items: readonly ToolActivityItem[]): boolean // ── Processing block (#1307) ──────────────────────────────────────────────── // A processing block folds a maximal run of reasoning + tool groups between two -// answer texts. Its summary reuses the trow bucket clauses and prepends a -// reasoning-count clause; the failed count stays visible (errored tools remain -// collapsed, so the summary line is the failure signal, matching the trow). +// answer texts (a run folds only when it contains tool activity — see +// groupProcessing). Its summary reuses the trow bucket clauses; folded +// reasoning stays inside the block but is not counted in the summary line. The +// failed count stays visible (errored tools remain collapsed, so the summary +// line is the failure signal, matching the trow). /** All tool items across the block's tool groups, in order. */ function processingTools(children: readonly ProcessingTimelineChild[]): ToolActivityItem[] { return children.flatMap((child) => (child.kind === 'tools' ? child.items : [])); } -/** Number of reasoning blocks folded into the processing group. */ -function processingThinkingCount(children: readonly ProcessingTimelineChild[]): number { - return children.reduce((count, child) => (child.kind === 'thinking' ? count + 1 : count), 0); -} - /** True while any tool is in flight or any reasoning block is still streaming. */ export function isProcessingRunning(children: readonly ProcessingTimelineChild[]): boolean { return children.some((child) => @@ -160,24 +157,19 @@ export function processingNeedsAttention(children: readonly ProcessingTimelineCh } /** - * Summary line for a processing block. Settled: reasoning-count clause + - * per-bucket tool clauses + failed count, joined like the trow. Live - * (`{ live: true }`): the current activity — the running tool's intent (or the - * reasoning label when only thinking is streaming), prefixed with "正在". + * Summary line for a processing block. Settled: the tool-activity roll-up only + * (per-bucket clauses + failed count, exactly the trow summary) — folded + * reasoning is not counted. Live (`{ live: true }`): the current activity — + * the running tool's intent (or the reasoning label when the tools are done + * and only thinking is still streaming), prefixed with "正在". */ export function summarizeProcessing( children: readonly ProcessingTimelineChild[], options?: { live?: boolean; locale?: UiLocale }, ): string { const locale = options?.locale ?? 'zh'; - const copy = getToolActivityCopy(locale).summary; if (options?.live) return processingLiveSummary(children, locale); - const tools = processingTools(children); - const thinkingCount = processingThinkingCount(children); - const clauses: string[] = []; - if (thinkingCount > 0) clauses.push(copy.thinking(thinkingCount)); - if (tools.length > 0) clauses.push(summarizeTrowTools(tools, { locale })); - return copy.join(clauses); + return summarizeTrowTools(processingTools(children), { locale }); } /** Current-activity line for a running processing block. */ diff --git a/packages/ui/stories/chat-surface.stories.tsx b/packages/ui/stories/chat-surface.stories.tsx index b018773d12..44509036d9 100644 --- a/packages/ui/stories/chat-surface.stories.tsx +++ b/packages/ui/stories/chat-surface.stories.tsx @@ -393,8 +393,9 @@ const multiStepConversation: StoredMessage[] = [ // #1307: reasoning + tool calls between two answer texts fold into one // collapsed "Processing" block. Here a single reasoning/tool phase (two // think-then-call steps with no answer text between them, one failing tool) -// collapses into one Processing summary — 思考 2 次 + tool counts + a red failed -// count — followed by the assistant's answer text rendered in place. +// collapses into one Processing summary — the tool roll-up「读取 1 个文件,搜索 +// 1 次,运行 1 条命令,1 个失败」(folded reasoning stays inside the block but is +// not counted) — followed by the assistant's answer text rendered in place. const processingConversation: StoredMessage[] = [ user('msg-user-processing', 'turn-processing', 13, '排查 stream-fade 的环边界,跑一下单测确认。'), { From fe37c22cb6461ad6e9f4a8565172339079b45a95 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 22 Jul 2026 19:03:53 +0800 Subject: [PATCH 4/6] refactor(ui): move the Processing fold from the timeline model to the render layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External review (Codex + Kimi) adjudication on #1307: baking the 'processing' kind into the shared TurnTimelineItem model forced every timeline-rewriting pass (overlayLiveTurn's flatten/refold, projectTurnTools' descent) to maintain the nesting invariant, and projectTurnTools missed it — shell-run folding could strip a block's every tool and strand an illegal thinking-only Processing block with an empty summary (P1). - materialize.ts reverts to main's flat TurnTimelineItem model; the processing kind, its child types, groupProcessing/flattenProcessing/ finalizeTimeline, overlayLiveTurn's flatten→refold, and projectTurnTools' processing branch are all removed. - New pure module timeline-fold.ts derives the folded view at render time: foldTimeline keeps answer text in place as the boundary, folds a maximal thinking+tools run into one block only when it contains a tools group, and gives each block a stable id from the preceding text's messageId ('start' at turn head) — so a block's React key survives its first tool being projected away without remounting or dropping a manual toggle (P2). - TurnView folds via useMemo(foldTimeline); ProcessingBlock consumes fold children and loses its dead onStreamingSettled prop; TurnTimelineEntry, timelineEntryKey, and the live-content check return to their flat forms. - Tests: materialize-turns.test.ts and live-turn-projection.test.ts restore their original stronger assertions (flatten helpers removed); the grouping cases move to timeline-fold.test.ts with a block-id stability case; new P1 regression (shell-run fold leaves a flat thinking-only timeline) and a render-level waiting_permission force-open test. --- .../main/__tests__/materialize-turns.test.ts | 65 ++----- .../main/__tests__/streaming-handoff.test.ts | 10 +- .../__tests__/live-turn-projection.test.ts | 9 +- packages/ui/src/__tests__/materialize.test.ts | 160 ++++++------------ .../src/__tests__/processing-block.test.tsx | 54 ++++++ .../ui/src/__tests__/timeline-fold.test.ts | 95 +++++++++++ .../src/__tests__/tool-trow-summary.test.ts | 7 +- packages/ui/src/chat-turn.tsx | 87 ++++------ packages/ui/src/materialize.ts | 108 ++---------- packages/ui/src/timeline-fold.ts | 64 +++++++ packages/ui/src/tool-activity/trow-summary.ts | 16 +- 11 files changed, 344 insertions(+), 331 deletions(-) create mode 100644 packages/ui/src/__tests__/processing-block.test.tsx create mode 100644 packages/ui/src/__tests__/timeline-fold.test.ts create mode 100644 packages/ui/src/timeline-fold.ts diff --git a/apps/desktop/src/main/__tests__/materialize-turns.test.ts b/apps/desktop/src/main/__tests__/materialize-turns.test.ts index a2ee94ae9a..99d068f1e3 100644 --- a/apps/desktop/src/main/__tests__/materialize-turns.test.ts +++ b/apps/desktop/src/main/__tests__/materialize-turns.test.ts @@ -8,21 +8,9 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; -import { deriveTurnLineageMap, materializeTurns, overlayLiveTurn, type LiveTurnProjection, type TurnTimelineItem } from '@maka/ui'; +import { deriveTurnLineageMap, materializeTurns, overlayLiveTurn, type LiveTurnProjection } from '@maka/ui'; import type { StoredMessage } from '@maka/core'; -// #1307: reasoning + tool groups fold into collapsible `processing` blocks -// between answer texts. These ordering tests care about the interleave, not the -// folding, so they assert against the unfolded timeline; the folding itself is -// covered by packages/ui/src/__tests__/materialize.test.ts and asserted here in -// the dedicated "processing grouping" test below. -function flattenTimeline(timeline: readonly TurnTimelineItem[]): TurnTimelineItem[] { - return timeline.flatMap((item) => (item.kind === 'processing' ? item.children : [item])); -} -function timelineKinds(timeline: readonly TurnTimelineItem[]): string[] { - return flattenTimeline(timeline).map((item) => item.kind); -} - function userMsg(turnId: string, ts: number, text: string, id?: string): StoredMessage { return { type: 'user', id: id ?? `u-${turnId}`, turnId, ts, text }; } @@ -471,7 +459,7 @@ describe('materializeTurns timeline', () => { }, ); - assert.deepEqual(timelineKinds(turns[0]!.timeline), ['thinking', 'tools']); + assert.deepEqual(turns[0]?.timeline.map((item) => item.kind), ['thinking', 'tools']); }); it('appends the current live step after earlier committed steps in thinking -> text -> tools order', () => { @@ -491,8 +479,8 @@ describe('materializeTurns timeline', () => { }, ); - assert.deepEqual(timelineKinds(turns[0]!.timeline), ['text', 'thinking', 'text', 'tools']); - assert.equal((flattenTimeline(turns[0]!.timeline)[2] as { text: string } | undefined)?.text, 'second answer'); + assert.deepEqual(turns[0]?.timeline.map((item) => item.kind), ['text', 'thinking', 'text', 'tools']); + assert.equal((turns[0]?.timeline[2] as { text: string } | undefined)?.text, 'second answer'); }); it('keeps multiple uncommitted live steps in production order', () => { @@ -519,7 +507,7 @@ describe('materializeTurns timeline', () => { }, ); - assert.deepEqual(timelineKinds(turns[0]!.timeline), ['thinking', 'tools', 'thinking', 'text']); + assert.deepEqual(turns[0]?.timeline.map((item) => item.kind), ['thinking', 'tools', 'thinking', 'text']); }); it('interleaves each step: thinking -> text -> that step’s tools', () => { @@ -532,7 +520,7 @@ describe('materializeTurns timeline', () => { toolResultMsg('t1', 105, 'c2'), assistantStep('t1', 106, 'a2', 'step two', 'think two'), ]); - const timeline = flattenTimeline(turns[0]!.timeline); + const timeline = turns[0]!.timeline; assert.deepEqual(timeline.map((i) => i.kind), ['thinking', 'text', 'tools', 'thinking', 'text', 'tools']); assert.equal((timeline[0] as { text: string }).text, 'think one'); assert.equal((timeline[1] as { text: string }).text, 'step one'); @@ -560,7 +548,7 @@ describe('materializeTurns timeline', () => { }, ]); - assert.deepEqual(timelineKinds(turns[0]!.timeline), ['tools', 'thinking', 'text']); + assert.deepEqual(turns[0]?.timeline.map((item) => item.kind), ['tools', 'thinking', 'text']); }); it('renders a pure-tool step’s orphan tools before the next step’s answer', () => { @@ -574,7 +562,7 @@ describe('materializeTurns timeline', () => { toolResultMsg('t1', 102, 'c1'), assistantStep('t1', 103, 'a2', 'summary', 'think'), ]); - const timeline = flattenTimeline(turns[0]!.timeline); + const timeline = turns[0]!.timeline; assert.deepEqual(timeline.map((i) => i.kind), ['tools', 'thinking', 'text']); assert.equal((timeline[0] as { items: { toolUseId: string }[] }).items[0]?.toolUseId, 'c1'); assert.equal((timeline[2] as { text: string }).text, 'summary'); @@ -587,7 +575,7 @@ describe('materializeTurns timeline', () => { toolResultMsg('t1', 102, 'c1'), assistantMsg('t1', 103, 'summary'), ]); - const timeline = flattenTimeline(turns[0]!.timeline); + const timeline = turns[0]!.timeline; assert.deepEqual(timeline.map((i) => i.kind), ['tools', 'text']); assert.equal((timeline[1] as { text: string }).text, 'summary'); }); @@ -597,7 +585,7 @@ describe('materializeTurns timeline', () => { userMsg('t1', 100, 'q'), toolCallStep('t1', 101, 'c1', 'a1'), ]); - const timeline = flattenTimeline(turns[0]!.timeline); + const timeline = turns[0]!.timeline; assert.deepEqual(timeline.map((i) => i.kind), ['tools']); assert.equal((timeline[0] as { items: { status: string }[] }).items[0]?.status, 'interrupted'); }); @@ -612,7 +600,7 @@ describe('materializeTurns timeline', () => { }], }, ); - const timeline = flattenTimeline(turns[0]!.timeline); + const timeline = turns[0]!.timeline; assert.deepEqual(timeline.map((i) => i.kind), ['text', 'tools']); assert.equal((timeline[1] as { items: { toolUseId: string }[] }).items[0]?.toolUseId, 'live-1'); }); @@ -623,7 +611,7 @@ describe('materializeTurns timeline', () => { assistantStep('t1', 101, 'a1', '', 'first'), assistantStep('t1', 102, 'a2', '', 'second'), ]); - const tl1 = flattenTimeline(thinkingOnly[0]!.timeline); + const tl1 = thinkingOnly[0]!.timeline; assert.deepEqual(tl1.map((i) => i.kind), ['thinking']); assert.equal((tl1[0] as { text: string }).text, 'first\n\nsecond'); @@ -634,37 +622,10 @@ describe('materializeTurns timeline', () => { toolCallStep('t1', 103, 'c2', 'a2'), assistantStep('t1', 104, 'a2', ''), ]); - const tl2 = flattenTimeline(toolsOnly[0]!.timeline); + const tl2 = toolsOnly[0]!.timeline; assert.deepEqual(tl2.map((i) => i.kind), ['tools']); assert.equal((tl2[0] as { items: unknown[] }).items.length, 2); }); - - it('folds each maximal reasoning + tool run between answers into a processing block (#1307)', () => { - const turns = materializeTurns([ - userMsg('t1', 100, 'q'), - toolCallStep('t1', 101, 'c1', 'a1'), - toolResultMsg('t1', 102, 'c1'), - assistantStep('t1', 103, 'a1', 'step one', 'think one'), - toolCallStep('t1', 104, 'c2', 'a2'), - toolResultMsg('t1', 105, 'c2'), - assistantStep('t1', 106, 'a2', 'step two', 'think two'), - ]); - const timeline = turns[0]!.timeline; - // Answer text is the only boundary; a run folds only when it contains tool - // activity, so the lone pre-answer reasoning stays a bare thinking entry. - assert.deepEqual(timeline.map((item) => item.kind), [ - 'thinking', - 'text', - 'processing', - 'text', - 'processing', - ]); - const middle = timeline[2]; - assert.deepEqual( - middle?.kind === 'processing' ? middle.children.map((child) => child.kind) : [], - ['tools', 'thinking'], - ); - }); }); describe('deriveTurnLineageMap', () => { diff --git a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts index b7f47343a5..8782d64392 100644 --- a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts @@ -74,11 +74,11 @@ describe('single live-turn handoff', () => { }], }); - // #1307: the answer text is the grouping boundary and a pure-thinking run - // stays bare, so the reasoning renders as the 深度思考 disclosure above the - // answer while the tool folds into one collapsed "Processing" block below - // it (its body is not in the static markup; the summary line carries the - // tool roll-up). + // #1307: the render-layer fold (foldTimeline) keeps answer text as the + // grouping boundary and leaves a pure-thinking run bare, so the reasoning + // renders as the 深度思考 disclosure above the answer while the tool folds + // into one collapsed "Processing" block below it (its body is not in the + // static markup; the summary line carries the tool roll-up). assert.equal((markup.match(/data-processing="block"/g) ?? []).length, 1); assert.ok(markup.indexOf('深度思考') >= 0); assert.ok(markup.indexOf('深度思考') < markup.indexOf('最终答案')); diff --git a/packages/ui/src/__tests__/live-turn-projection.test.ts b/packages/ui/src/__tests__/live-turn-projection.test.ts index 40b0045223..ced6d1dbcb 100644 --- a/packages/ui/src/__tests__/live-turn-projection.test.ts +++ b/packages/ui/src/__tests__/live-turn-projection.test.ts @@ -372,14 +372,7 @@ describe('applyLiveTurnEvent', () => { }); const timeline = overlayLiveTurn([], withLateThinking)[0]?.timeline; - // Folded into one Processing block (#1307); the tool still renders before - // the late reasoning inside the block — the ordering this test guards. - assert.deepEqual(timeline?.map((item) => item.kind), ['processing']); - const block = timeline?.[0]; - assert.deepEqual( - block?.kind === 'processing' ? block.children.map((child) => child.kind) : [], - ['tools', 'thinking'], - ); + assert.deepEqual(timeline?.map((item) => item.kind), ['tools', 'thinking']); }); it('drops a terminal projection only after its last live step settles', () => { diff --git a/packages/ui/src/__tests__/materialize.test.ts b/packages/ui/src/__tests__/materialize.test.ts index 341e5df1c0..5f3b8694d3 100644 --- a/packages/ui/src/__tests__/materialize.test.ts +++ b/packages/ui/src/__tests__/materialize.test.ts @@ -80,131 +80,67 @@ describe('materializeChat attachments', () => { }); }); -// ── #1307: reasoning + tool calls fold into collapsible Processing blocks ───── +// ── #1307: the timeline model stays flat (fold is a render concern) ────────── function userMsg(turnId: string, ts: number, text: string): StoredMessage { return { type: 'user', id: `u-${turnId}`, turnId, ts, text }; } -function assistantStep( - turnId: string, - ts: number, - id: string, - text: string, - thinking?: string, -): StoredMessage { +function shellRunResult(revision: number) { return { - type: 'assistant', - id, - turnId, - ts, - text, - modelId: 'm', - ...(thinking !== undefined ? { thinking: { text: thinking } } : {}), - } as StoredMessage; + kind: 'shell_run' as const, + ref: 'maka://runtime/background-tasks/pty-1', + mode: 'pty' as const, + status: 'running' as const, + cwd: '/repo', + cmd: 'job', + startedAt: 1, + updatedAt: revision, + revision, + output: { + mode: 'pty' as const, + screen: 'ready', + scrollback: '', + cols: 80, + rows: 24, + cursor: { x: 0, y: 0, visible: true }, + alternateScreen: false, + truncated: false, + redacted: false, + }, + }; } -function toolCallStep(turnId: string, ts: number, id: string, stepId: string, toolName = 'Read'): StoredMessage { - return { type: 'tool_call', id, turnId, ts, toolName, args: {}, stepId }; -} - -function toolResult(turnId: string, ts: number, toolUseId: string): StoredMessage { - return { type: 'tool_result', id: `r-${toolUseId}`, turnId, ts, toolUseId, isError: false, content: { kind: 'text', text: 'ok' } }; -} - -function childKinds(item: TurnTimelineItem | undefined): string[] { - return item?.kind === 'processing' ? item.children.map((child) => child.kind) : []; -} - -describe('materializeTurns processing grouping (#1307)', () => { - test('leaves a pure-thinking run bare instead of folding it (review fix)', () => { - const turns = materializeTurns([ - userMsg('t1', 100, 'q'), - assistantStep('t1', 101, 'a1', '', 'reasoning only'), - ]); - const timeline = turns[0]!.timeline; - // No tools in the run → no processing block; the 深度思考 disclosure - // renders the reasoning directly. - assert.deepEqual(timeline.map((item) => item.kind), ['thinking']); - }); - - test('folds a pure-tools run into one processing block', () => { - const turns = materializeTurns([ - userMsg('t1', 100, 'q'), - toolCallStep('t1', 101, 'c1', 'a1'), - toolResult('t1', 102, 'c1'), - assistantStep('t1', 103, 'a1', ''), - ]); - const timeline = turns[0]!.timeline; - assert.deepEqual(timeline.map((item) => item.kind), ['processing']); - assert.deepEqual(childKinds(timeline[0]), ['tools']); - }); - - test('keeps interleaved thinking + tools inside one block, in order', () => { - const turns = materializeTurns([ - userMsg('t1', 100, 'q'), - toolCallStep('t1', 101, 'c1', 'a1'), - toolResult('t1', 102, 'c1'), - assistantStep('t1', 103, 'a1', '', 'think then call'), - ]); - const timeline = turns[0]!.timeline; - assert.deepEqual(timeline.map((item) => item.kind), ['processing']); - // Reasoning renders above the tools it precedes, full timeline preserved. - assert.deepEqual(childKinds(timeline[0]), ['thinking', 'tools']); - }); - - test('answer text is a boundary: two steps yield several processing blocks around the texts', () => { - const turns = materializeTurns([ - userMsg('t1', 100, 'q'), - toolCallStep('t1', 101, 'c1', 'a1'), - toolResult('t1', 102, 'c1'), - assistantStep('t1', 103, 'a1', 'step one', 'think one'), - toolCallStep('t1', 104, 'c2', 'a2'), - toolResult('t1', 105, 'c2'), - assistantStep('t1', 106, 'a2', 'step two', 'think two'), - ]); - const timeline = turns[0]!.timeline; - // thinking, text, tools, thinking, text, tools -> - // thinking (pure run stays bare), text, processing[tools, thinking], - // text, processing[tools] - assert.deepEqual(timeline.map((item) => item.kind), [ - 'thinking', - 'text', - 'processing', - 'text', - 'processing', +describe('flat timeline under tool projection (#1307 P1 regression)', () => { + test('shell-run folding away a turn’s only tool leaves a flat thinking-only timeline', () => { + // Turn t1 owns the Bash ShellRun parent; the live turn t2's ONLY tool is a + // Read carrying a shell_run result with the same ref, so foldShellRunTurns + // merges it into t1's Bash and drops it from t2 entirely. With the fold + // living in the model this used to strand an illegal thinking-only + // "processing" block with an empty summary; the flat model simply drops + // the emptied tools group. + const settled = materializeTurns([ + { type: 'tool_call', id: 'bash-1', turnId: 't1', ts: 1, toolName: 'Bash', args: { command: 'job', pty: true } }, + { type: 'tool_result', id: 'r-bash-1', turnId: 't1', ts: 2, toolUseId: 'bash-1', isError: false, content: shellRunResult(1) }, + userMsg('t2', 3, 'q'), ]); - assert.deepEqual(childKinds(timeline[2]), ['tools', 'thinking']); - assert.deepEqual(childKinds(timeline[4]), ['tools']); - assert.equal((timeline[1] as { text: string }).text, 'step one'); - assert.equal((timeline[3] as { text: string }).text, 'step two'); - }); - - test('live overlay path folds a streaming step the same way as settled history', () => { - const timeline = overlayLiveTurn([], { - turnId: 't1', - phase: 'streamed', - steps: [{ - stepId: 'a1', - thinking: { text: '先测试工具', truncated: false, complete: false }, - tools: [{ toolUseId: 'c1', toolName: 'Read', stepId: 'a1', status: 'running', args: {} }], - }], - })[0]?.timeline; - assert.deepEqual(timeline?.map((item) => item.kind), ['processing']); - assert.deepEqual(childKinds(timeline?.[0]), ['thinking', 'tools']); - // The live processing block keeps its answer texts as boundaries: a live - // step with text splits reasoning/tools out of the answer, and a lone - // pre-answer thinking run stays bare (same rule as settled history). - const withText = overlayLiveTurn([], { + const turns = overlayLiveTurn(settled, { turnId: 't2', phase: 'streamed', steps: [{ stepId: 'a1', - thinking: { text: 'think', truncated: false, complete: true }, - text: { text: 'answer', truncated: false, complete: false }, - tools: [{ toolUseId: 'c2', toolName: 'Bash', stepId: 'a1', status: 'running', args: {} }], + thinking: { text: 'watching the background job', truncated: false, complete: false }, + tools: [{ + toolUseId: 'read-1', + toolName: 'Read', + stepId: 'a1', + status: 'completed', + args: {}, + result: shellRunResult(2), + }], }], - })[0]?.timeline; - assert.deepEqual(withText?.map((item) => item.kind), ['thinking', 'text', 'processing']); + }); + const liveTurn = turns.find((turn) => turn.turnId === 't2'); + assert.deepEqual(liveTurn?.timeline.map((item: TurnTimelineItem) => item.kind), ['thinking']); }); }); diff --git a/packages/ui/src/__tests__/processing-block.test.tsx b/packages/ui/src/__tests__/processing-block.test.tsx new file mode 100644 index 0000000000..e2343fc5ca --- /dev/null +++ b/packages/ui/src/__tests__/processing-block.test.tsx @@ -0,0 +1,54 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { createElement, type ReactNode } from 'react'; +import { renderToStaticMarkup as renderReactToStaticMarkup } from 'react-dom/server'; +import { LocaleProvider } from '../locale-context.js'; +import type { ToolActivityItem, TurnViewModel } from '../materialize.js'; +import { TurnView } from '../chat-turn.js'; + +function renderToStaticMarkup(node: ReactNode): string { + return renderReactToStaticMarkup(createElement(LocaleProvider, { + locale: 'zh', + children: node, + })); +} + +function turnWithTools(tools: ToolActivityItem[]): TurnViewModel { + return { + turnId: 'turn-1', + status: 'completed', + partialOutputRetained: false, + tools, + notes: [], + timeline: [ + { kind: 'thinking', text: 'reasoning', messageId: 'a1' }, + { kind: 'tools', items: tools }, + ], + startedAt: 1, + }; +} + +describe('ProcessingBlock disclosure wiring (#1307)', () => { + it('a waiting_permission tool inside the block forces the disclosure open', () => { + const markup = renderToStaticMarkup(createElement(TurnView, { + turn: turnWithTools([ + { toolUseId: 'w1', toolName: 'Write', activityKind: 'edit', status: 'waiting_permission', args: {}, intent: '写入配置' }, + ]), + })); + // The folded run renders as one Processing block whose panel is OPEN — + // the nested tool trow (and thereby the actionable permission row) is in + // the static markup, not hidden behind the collapsed summary. + assert.match(markup, /data-processing="block"/); + assert.match(markup, /data-trow="group"/); + }); + + it('ordinary settled work stays collapsed (no panel content in static markup)', () => { + const markup = renderToStaticMarkup(createElement(TurnView, { + turn: turnWithTools([ + { toolUseId: 'r1', toolName: 'Read', activityKind: 'read', status: 'completed', args: {} }, + ]), + })); + assert.match(markup, /data-processing="block"/); + assert.doesNotMatch(markup, /data-trow="group"/); + }); +}); diff --git a/packages/ui/src/__tests__/timeline-fold.test.ts b/packages/ui/src/__tests__/timeline-fold.test.ts new file mode 100644 index 0000000000..8952d1c57f --- /dev/null +++ b/packages/ui/src/__tests__/timeline-fold.test.ts @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import type { ToolActivityItem, TurnTimelineItem } from '../materialize.js'; +import { foldTimeline, type FoldedTimelineEntry } from '../timeline-fold.js'; + +function thinking(messageId: string, live?: boolean): TurnTimelineItem { + return { kind: 'thinking', text: `reasoning ${messageId}`, messageId, ...(live !== undefined ? { live } : {}) }; +} + +function text(messageId: string, body = `answer ${messageId}`): TurnTimelineItem { + return { kind: 'text', text: body, messageId }; +} + +function tool(id: string, toolName = 'Read'): ToolActivityItem { + return { toolUseId: id, toolName, status: 'completed', args: {} }; +} + +function tools(...items: ToolActivityItem[]): TurnTimelineItem { + return { kind: 'tools', items }; +} + +function kinds(entries: readonly FoldedTimelineEntry[]): string[] { + return entries.map((entry) => entry.kind); +} + +function childKinds(entry: FoldedTimelineEntry | undefined): string[] { + return entry?.kind === 'processing' ? entry.children.map((child) => child.kind) : []; +} + +describe('foldTimeline (#1307)', () => { + test('leaves a pure-thinking run bare instead of folding it', () => { + const folded = foldTimeline([thinking('a1')]); + // No tools in the run → no processing block; the 深度思考 disclosure + // renders the reasoning directly. + assert.deepEqual(kinds(folded), ['thinking']); + }); + + test('folds a pure-tools run into one processing block', () => { + const folded = foldTimeline([tools(tool('c1'))]); + assert.deepEqual(kinds(folded), ['processing']); + assert.deepEqual(childKinds(folded[0]), ['tools']); + }); + + test('keeps interleaved thinking + tools inside one block, in order', () => { + const folded = foldTimeline([thinking('a1'), tools(tool('c1'))]); + assert.deepEqual(kinds(folded), ['processing']); + assert.deepEqual(childKinds(folded[0]), ['thinking', 'tools']); + }); + + test('answer text is a boundary: runs around each text fold independently', () => { + const folded = foldTimeline([ + thinking('a1'), + text('a1', 'step one'), + tools(tool('c1')), + thinking('a2'), + text('a2', 'step two'), + tools(tool('c2')), + ]); + // thinking (pure run stays bare), text, processing[tools, thinking], + // text, processing[tools] + assert.deepEqual(kinds(folded), ['thinking', 'text', 'processing', 'text', 'processing']); + assert.deepEqual(childKinds(folded[2]), ['tools', 'thinking']); + assert.deepEqual(childKinds(folded[4]), ['tools']); + assert.equal((folded[1] as { text: string }).text, 'step one'); + assert.equal((folded[3] as { text: string }).text, 'step two'); + }); + + test('block ids derive from the preceding text and are stable across tool projection', () => { + const before = foldTimeline([ + text('a1'), + thinking('a2'), + tools(tool('c1'), tool('c2')), + ]); + // Shell-run folding can project the FIRST tool out of the group; the block + // id must not change (a first-child-derived key would remount the + // disclosure and drop a manual open/close). + const after = foldTimeline([ + text('a1'), + thinking('a2'), + tools(tool('c2')), + ]); + assert.equal(before[1]?.kind, 'processing'); + assert.equal(after[1]?.kind, 'processing'); + assert.equal( + before[1]?.kind === 'processing' ? before[1].id : undefined, + after[1]?.kind === 'processing' ? after[1].id : undefined, + ); + assert.equal(before[1]?.kind === 'processing' ? before[1].id : undefined, 'a1'); + }); + + test('a block that opens the turn uses the stable "start" id', () => { + const folded = foldTimeline([tools(tool('c1')), text('a1')]); + assert.equal(folded[0]?.kind === 'processing' ? folded[0].id : undefined, 'start'); + }); +}); diff --git a/packages/ui/src/__tests__/tool-trow-summary.test.ts b/packages/ui/src/__tests__/tool-trow-summary.test.ts index 9ea27d08cf..bb22aa362b 100644 --- a/packages/ui/src/__tests__/tool-trow-summary.test.ts +++ b/packages/ui/src/__tests__/tool-trow-summary.test.ts @@ -13,7 +13,8 @@ import { summarizeProcessing, summarizeTrowTools, } from '../tool-activity/trow-summary.js'; -import type { ProcessingTimelineChild, ToolActivityItem } from '../materialize.js'; +import type { ToolActivityItem } from '../materialize.js'; +import type { FoldedTimelineChild } from '../timeline-fold.js'; const toolActivitySource = readFileSync( join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'src', 'tool-activity.tsx'), @@ -107,11 +108,11 @@ describe('tool trow summary aggregation', () => { }); }); -function thinking(live?: boolean): ProcessingTimelineChild { +function thinking(live?: boolean): FoldedTimelineChild { return { kind: 'thinking', text: 'reasoning', messageId: 'a1', ...(live !== undefined ? { live } : {}) }; } -function tools(items: ToolActivityItem[]): ProcessingTimelineChild { +function tools(items: ToolActivityItem[]): FoldedTimelineChild { return { kind: 'tools', items }; } diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index 9ad41f3a53..78b1a28e14 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -1,4 +1,4 @@ -import { Fragment, memo, useEffect, useRef, useState, type ReactNode } from 'react'; +import { Fragment, memo, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { Button as BaseButton } from '@base-ui/react/button'; import { useMountedRef } from './use-mounted-ref.js'; import { AlertOctagon, Ban, Brain, Check, ChevronRight, Copy, Cpu, GitBranch, Info, Loader2, RefreshCcw, Timer } from './icons.js'; @@ -9,7 +9,8 @@ import { prepareSmoothStreamText, useSmoothStreamContent } from './smooth-stream import { tokenizeFade, useStreamFade, type StreamFade } from './stream-fade.js'; import { Button as UiButton, cn, DialogContent, DialogRoot } from './ui.js'; import type { AttachmentRef } from '@maka/core'; -import type { ProcessingTimelineChild, TurnTimelineItem, TurnViewModel } from './materialize.js'; +import type { TurnTimelineItem, TurnViewModel } from './materialize.js'; +import { foldTimeline, type FoldedTimelineChild } from './timeline-fold.js'; import { AttachmentFileCard } from './attachment-file-card.js'; import { Collapsible, CollapsibleTrigger, CollapsiblePanel } from './primitives/collapsible.js'; import { Bubble, Marker, markerVariants, Message, TextShimmer } from './primitives/chat.js'; @@ -315,7 +316,17 @@ export const TurnView = memo(function TurnView(props: { // this is the live streaming tail (a thinking-only / textless streaming turn // has an empty committed timeline but must still show its live answer block). const showAssistantMessage = turn.timeline.length > 0 || !!props.liveStreaming; - const hasLiveTimelineContent = turn.timeline.some(timelineItemHasLiveContent); + const hasLiveTimelineContent = turn.timeline.some((item) => + item.kind === 'thinking' + ? item.live === true + : item.kind === 'text' + ? item.live === true + : item.items.some((tool) => tool.status === 'pending' || tool.status === 'running' || tool.status === 'waiting_permission'), + ); + // #1307: the collapsed "Processing" fold is derived at render time from the + // flat timeline. Settled turn identities are stable (memoized projections), + // so this only recomputes for the turn whose timeline actually changed. + const foldedTimeline = useMemo(() => foldTimeline(turn.timeline), [turn.timeline]); return (
( - - ))} + and Codex-style tool trow in the order the model produced them. + #1307: runs of reasoning + tools between answer texts render + through the derived fold as collapsed Processing blocks. */} + {foldedTimeline.map((item, index) => + item.kind === 'processing' ? ( + + ) : ( + + ), + )} {props.liveStreaming && ( <> {props.liveStreaming.processingIndicator && !hasLiveTimelineContent && } @@ -779,39 +796,14 @@ function StreamingAssistantBubble(props: { text: string; live: boolean; truncate * re-positioned mid-timeline without remounting — and thereby collapsing — * the disclosures after it. */ -function timelineEntryKey(item: TurnTimelineItem | ProcessingTimelineChild, index: number): string { +function timelineEntryKey(item: TurnTimelineItem, index: number): string { if (item.kind === 'tools') return `tools-${item.items[0]?.toolUseId ?? index}`; - if (item.kind === 'processing') { - const first = item.children[0]; - const inner = first - ? (first.kind === 'tools' ? first.items[0]?.toolUseId : first.messageId) - : index; - return `processing-${inner ?? index}`; - } return `${item.kind}-${item.messageId}`; } -/** True when a timeline entry (including a folded processing block) is still - * streaming reasoning/answer text or running a tool — drives the tail turn's - * live indicators. */ -function timelineItemHasLiveContent(item: TurnTimelineItem): boolean { - switch (item.kind) { - case 'thinking': - case 'text': - return item.live === true; - case 'tools': - return item.items.some( - (tool) => tool.status === 'pending' || tool.status === 'running' || tool.status === 'waiting_permission', - ); - case 'processing': - return item.children.some(timelineItemHasLiveContent); - } -} - -/** Render one timeline entry: reasoning disclosure / answer bubble / tool trow / - * folded Processing block. */ +/** Render one timeline entry: reasoning disclosure / answer bubble / tool trow. */ function TurnTimelineEntry(props: { - item: TurnTimelineItem | ProcessingTimelineChild; + item: TurnTimelineItem; onStreamingSettled?: (messageId?: string) => void; }) { const { item } = props; @@ -819,9 +811,6 @@ function TurnTimelineEntry(props: { return ; } if (item.kind === 'tools') return ; - if (item.kind === 'processing') { - return ; - } if (item.kind === 'text' && item.live) { return ( void; -}) { +function ProcessingBlock(props: { entries: FoldedTimelineChild[] }) { const locale = useUiLocale(); const { entries } = props; const running = isProcessingRunning(entries); @@ -897,11 +884,7 @@ function ProcessingBlock(props: {
{entries.map((entry, index) => ( - + ))}
diff --git a/packages/ui/src/materialize.ts b/packages/ui/src/materialize.ts index fc0a5dfb34..4a5a39c603 100644 --- a/packages/ui/src/materialize.ts +++ b/packages/ui/src/materialize.ts @@ -222,25 +222,16 @@ function mergeLiveOverPersisted(persisted: ToolActivityItem, live: ToolActivityI * step's wall-clock for hover meta. * - `tools`: one contiguous group of tool activity, rendered as a single * Codex-style trow. Adjacent groups are pre-merged. - * - `processing`: a maximal run of `thinking` + `tools` entries between two - * answer texts, folded into one collapsed "Processing" disclosure (#1307). - * A run folds only when it contains at least one tools group — a - * pure-thinking run stays bare. `children` preserve the original interleaved - * order so the expanded block is the full timeline; answer `text` stays a - * grouping boundary and always renders in place, so a turn can hold several - * processing blocks. + * + * The model stays FLAT: the collapsed "Processing" fold (#1307) is a render + * concern applied by `foldTimeline` (timeline-fold.ts) at the component layer, + * so timeline-rewriting passes (overlayLiveTurn, projectTurnTools, shell-run + * folding) never have to maintain a nesting invariant. */ -export type ThinkingTimelineItem = { kind: 'thinking'; text: string; messageId: string; live?: boolean; truncated?: boolean }; -export type TextTimelineItem = { kind: 'text'; text: string; messageId: string; ts?: number; live?: boolean; complete?: boolean; truncated?: boolean }; -export type ToolsTimelineItem = { kind: 'tools'; items: ToolActivityItem[] }; -/** A single entry inside a folded processing block: reasoning or a tool group. */ -export type ProcessingTimelineChild = ThinkingTimelineItem | ToolsTimelineItem; -export type ProcessingTimelineItem = { kind: 'processing'; children: ProcessingTimelineChild[] }; export type TurnTimelineItem = - | ThinkingTimelineItem - | TextTimelineItem - | ToolsTimelineItem - | ProcessingTimelineItem; + | { kind: 'thinking'; text: string; messageId: string; live?: boolean; truncated?: boolean } + | { kind: 'text'; text: string; messageId: string; ts?: number; live?: boolean; complete?: boolean; truncated?: boolean } + | { kind: 'tools'; items: ToolActivityItem[] }; /** * A single conversational turn — typically one user message, the assistant's @@ -333,11 +324,7 @@ export function overlayLiveTurn( } } const timeline: TurnTimelineItem[] = []; - // Rebuild from a flat timeline: unfold committed processing blocks so the - // settled-tool filter + live-step append operate on raw thinking/text/tools, - // then re-fold once via finalizeTimeline. Keeps grouping a single source of - // truth shared with the settled path (#1307). - for (const item of flattenProcessing(current.timeline)) { + for (const item of current.timeline) { if (item.kind !== 'tools') { timeline.push(item); continue; @@ -378,7 +365,7 @@ export function overlayLiveTurn( } } } - const next = { ...current, tools, timeline: finalizeTimeline(timeline) }; + const next = { ...current, tools, timeline: mergeAdjacentTimeline(timeline) }; const overlaid = targetIndex < 0 ? [...turns, next] : turns.map((turn, index) => index === targetIndex ? next : turn); @@ -616,33 +603,16 @@ function projectTurnTools( return projectedTool ? [projectedTool] : []; }); let timelineChanged = false; - const projectTools = (source: readonly ToolActivityItem[]): { items: ToolActivityItem[]; changed: boolean } => { - const items = source.flatMap((tool) => { + const timeline = turn.timeline.flatMap((item): TurnTimelineItem[] => { + if (item.kind !== 'tools') return [item]; + const items = item.items.flatMap((tool) => { const projectedTool = projected.get(tool.toolUseId); return projectedTool ? [projectedTool] : []; }); - const changed = items.length !== source.length || items.some((tool, index) => tool !== source[index]); - return { items, changed }; - }; - const timeline = turn.timeline.flatMap((item): TurnTimelineItem[] => { - if (item.kind === 'tools') { - const { items, changed } = projectTools(item.items); - if (changed) timelineChanged = true; - return items.length > 0 ? [{ kind: 'tools' as const, items }] : []; - } - if (item.kind === 'processing') { - let childChanged = false; - const children = item.children.flatMap((child): ProcessingTimelineChild[] => { - if (child.kind !== 'tools') return [child]; - const { items, changed } = projectTools(child.items); - if (changed) childChanged = true; - return items.length > 0 ? [{ kind: 'tools' as const, items }] : []; - }); - if (!childChanged) return [item]; + if (items.length !== item.items.length || items.some((tool, index) => tool !== item.items[index])) { timelineChanged = true; - return children.length > 0 ? [{ kind: 'processing' as const, children }] : []; } - return [item]; + return items.length > 0 ? [{ kind: 'tools' as const, items }] : []; }); const toolsChanged = nextTools.length !== turn.tools.length || nextTools.some((tool, index) => tool !== turn.tools[index]); @@ -741,53 +711,7 @@ function buildTurnTimeline( } } flushTools(pending); - return finalizeTimeline(raw); -} - -/** - * Shared final pass for both the settled (`buildTurnTimeline`) and live-overlay - * (`overlayLiveTurn`) paths: merge adjacent thinking/tool runs, then fold every - * maximal thinking+tools run between answer texts into one collapsed - * `processing` block (#1307). Answer `text` is the only grouping boundary, so a - * turn can carry several processing blocks and the answer always reads in place. - */ -function finalizeTimeline(items: readonly TurnTimelineItem[]): TurnTimelineItem[] { - return groupProcessing(mergeAdjacentTimeline(items)); -} - -/** Expand any folded processing blocks back into their raw thinking/tools - * children, so the overlay/projection passes can rebuild from a flat timeline - * and re-group once at the end. Text passes through untouched. */ -function flattenProcessing(items: readonly TurnTimelineItem[]): TurnTimelineItem[] { - return items.flatMap((item) => (item.kind === 'processing' ? item.children : [item])); -} - -function groupProcessing(items: readonly TurnTimelineItem[]): TurnTimelineItem[] { - const out: TurnTimelineItem[] = []; - let buffer: ProcessingTimelineChild[] | null = null; - const flush = (): void => { - if (buffer && buffer.length > 0) { - // A run folds only when it contains tool activity. A pure-thinking run - // stays bare so the existing 深度思考 disclosure renders it directly — - // wrapping a lone reasoning block would just double the fold. - if (buffer.some((child) => child.kind === 'tools')) { - out.push({ kind: 'processing', children: buffer }); - } else { - out.push(...buffer); - } - } - buffer = null; - }; - for (const item of items) { - if (item.kind === 'thinking' || item.kind === 'tools') { - (buffer ??= []).push(item); - } else { - flush(); - out.push(item); - } - } - flush(); - return out; + return mergeAdjacentTimeline(raw); } function mergeAdjacentTimeline(items: readonly TurnTimelineItem[]): TurnTimelineItem[] { diff --git a/packages/ui/src/timeline-fold.ts b/packages/ui/src/timeline-fold.ts new file mode 100644 index 0000000000..e4c9cb7007 --- /dev/null +++ b/packages/ui/src/timeline-fold.ts @@ -0,0 +1,64 @@ +import type { TurnTimelineItem } from './materialize.js'; + +/** + * Render-layer fold for the collapsed "Processing" block (#1307). + * + * The turn timeline model (`TurnTimelineItem`) stays FLAT — every + * timeline-rewriting pass (overlayLiveTurn, projectTurnTools, shell-run + * folding) operates on the raw thinking/text/tools sequence and never has to + * maintain a nesting invariant. This module derives the folded view right + * before rendering: + * + * - answer `text` entries stay in place and are the only grouping boundary; + * - a maximal thinking+tools run between two texts folds into ONE + * `processing` block when it contains at least one tools group, preserving + * the run's interleaved order as `children`; + * - a pure-thinking run stays bare (the 深度思考 disclosure renders it + * directly — wrapping a lone reasoning block would just double the fold). + * + * Each block carries a stable `id` derived from the PRECEDING answer text's + * messageId (`'start'` when the block opens the turn). Between two texts there + * is at most one block, so the id is unique per turn — and, unlike a key + * guessed from the first child, it survives the first tool being projected + * away (shell-run folding) without remounting the disclosure or dropping a + * manual open/close. + */ + +/** An entry folded inside a processing block: reasoning or a tool group. */ +export type FoldedTimelineChild = Extract; + +export interface ProcessingFold { + kind: 'processing'; + /** Stable identity: `'start'` or the preceding answer text's messageId. */ + id: string; + children: FoldedTimelineChild[]; +} + +export type FoldedTimelineEntry = TurnTimelineItem | ProcessingFold; + +export function foldTimeline(items: readonly TurnTimelineItem[]): FoldedTimelineEntry[] { + const out: FoldedTimelineEntry[] = []; + let anchor = 'start'; + let buffer: FoldedTimelineChild[] | null = null; + const flush = (): void => { + if (buffer && buffer.length > 0) { + if (buffer.some((child) => child.kind === 'tools')) { + out.push({ kind: 'processing', id: anchor, children: buffer }); + } else { + out.push(...buffer); + } + } + buffer = null; + }; + for (const item of items) { + if (item.kind === 'thinking' || item.kind === 'tools') { + (buffer ??= []).push(item); + } else { + flush(); + out.push(item); + anchor = item.messageId; + } + } + flush(); + return out; +} diff --git a/packages/ui/src/tool-activity/trow-summary.ts b/packages/ui/src/tool-activity/trow-summary.ts index b2b935ff05..380a48a4e0 100644 --- a/packages/ui/src/tool-activity/trow-summary.ts +++ b/packages/ui/src/tool-activity/trow-summary.ts @@ -12,7 +12,8 @@ */ import type { ToolActivityKind, UiLocale } from '@maka/core'; -import type { ProcessingTimelineChild, ToolActivityItem } from '../materialize.js'; +import type { ToolActivityItem } from '../materialize.js'; +import type { FoldedTimelineChild } from '../timeline-fold.js'; import { getToolActivityCopy } from './copy.js'; import { formatUserVisibleToolText } from './preview-utils.js'; @@ -130,18 +131,19 @@ export function trowNeedsAttention(items: readonly ToolActivityItem[]): boolean // ── Processing block (#1307) ──────────────────────────────────────────────── // A processing block folds a maximal run of reasoning + tool groups between two // answer texts (a run folds only when it contains tool activity — see -// groupProcessing). Its summary reuses the trow bucket clauses; folded +// foldTimeline in timeline-fold.ts). Its summary reuses the trow bucket +// clauses; folded // reasoning stays inside the block but is not counted in the summary line. The // failed count stays visible (errored tools remain collapsed, so the summary // line is the failure signal, matching the trow). /** All tool items across the block's tool groups, in order. */ -function processingTools(children: readonly ProcessingTimelineChild[]): ToolActivityItem[] { +function processingTools(children: readonly FoldedTimelineChild[]): ToolActivityItem[] { return children.flatMap((child) => (child.kind === 'tools' ? child.items : [])); } /** True while any tool is in flight or any reasoning block is still streaming. */ -export function isProcessingRunning(children: readonly ProcessingTimelineChild[]): boolean { +export function isProcessingRunning(children: readonly FoldedTimelineChild[]): boolean { return children.some((child) => child.kind === 'thinking' ? child.live === true : isTrowRunning(child.items), ); @@ -152,7 +154,7 @@ export function isProcessingRunning(children: readonly ProcessingTimelineChild[] * Mirrors `trowNeedsAttention` — an errored tool does NOT force-open; the * settled summary carries the failure count (「N 个失败」 in destructive color). */ -export function processingNeedsAttention(children: readonly ProcessingTimelineChild[]): boolean { +export function processingNeedsAttention(children: readonly FoldedTimelineChild[]): boolean { return children.some((child) => child.kind === 'tools' && trowNeedsAttention(child.items)); } @@ -164,7 +166,7 @@ export function processingNeedsAttention(children: readonly ProcessingTimelineCh * and only thinking is still streaming), prefixed with "正在". */ export function summarizeProcessing( - children: readonly ProcessingTimelineChild[], + children: readonly FoldedTimelineChild[], options?: { live?: boolean; locale?: UiLocale }, ): string { const locale = options?.locale ?? 'zh'; @@ -174,7 +176,7 @@ export function summarizeProcessing( /** Current-activity line for a running processing block. */ function processingLiveSummary( - children: readonly ProcessingTimelineChild[], + children: readonly FoldedTimelineChild[], locale: UiLocale, ): string { const copy = getToolActivityCopy(locale).summary; From f8aa430c099ab8ba7a0850f73995d5eb7e9f8373 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 22 Jul 2026 19:05:33 +0800 Subject: [PATCH 5/6] fix(ui): correct the live Processing summary's failure count, activity pick, and localization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External review (Codex + Kimi) adjudication on #1307, live-summary findings: - The live line now appends the failed clause whenever the block already holds an errored tool (「正在运行测试,1 个失败」) — errored tools stay collapsed, so the summary must carry the failure signal before settle, matching the trow (P2). - The current activity is the LAST live entry in timeline order: children are walked in reverse and a still-streaming thinking block or a group's active tool wins, instead of flattening all tools and letting an earlier running tool outrank a later streaming reasoning block (P3). - The no-intent/no-displayName fallback routes through resolveToolDisplayName, so a bare load_tools call reads as the localized 「加载工具组」 (P3). The resolver and isConnectorTool move down into trow-summary.ts (the leaf module) to avoid an import cycle; presentation.ts re-exports them for its existing consumers. --- .../src/__tests__/tool-trow-summary.test.ts | 37 +++++++++- packages/ui/src/tool-activity/presentation.ts | 20 ++---- packages/ui/src/tool-activity/trow-summary.ts | 70 ++++++++++++++----- 3 files changed, 95 insertions(+), 32 deletions(-) diff --git a/packages/ui/src/__tests__/tool-trow-summary.test.ts b/packages/ui/src/__tests__/tool-trow-summary.test.ts index bb22aa362b..9921bda2eb 100644 --- a/packages/ui/src/__tests__/tool-trow-summary.test.ts +++ b/packages/ui/src/__tests__/tool-trow-summary.test.ts @@ -130,14 +130,22 @@ describe('processing block summary (#1307)', () => { assert.equal(summarizeProcessing(children, {}), '读取 1 个文件,搜索 1 次,1 个失败'); }); - it('shows the running tool intent as the live current activity', () => { + it('shows the running tool intent as the live current activity and appends the failed count', () => { const children = [ tools([ { toolUseId: 'r1', toolName: 'Read', activityKind: 'read', status: 'errored', args: {} }, { toolUseId: 'b1', toolName: 'Bash', activityKind: 'command', status: 'running', args: {}, intent: '运行测试' }, ]), ]; - // 运行中显示当前活动(最后一个在飞行中的工具意图),带「正在」前缀。 + // 运行中显示当前活动(带「正在」前缀);区块内已有失败工具时,失败计数 + // 不等 settle 才出现——摘要行是折叠错误的唯一信号。 + assert.equal(summarizeProcessing(children, { live: true }), '正在运行测试,1 个失败'); + }); + + it('live summary without failures stays a bare current-activity line', () => { + const children = [ + tools([{ toolUseId: 'b1', toolName: 'Bash', activityKind: 'command', status: 'running', args: {}, intent: '运行测试' }]), + ]; assert.equal(summarizeProcessing(children, { live: true }), '正在运行测试'); }); @@ -149,6 +157,31 @@ describe('processing block summary (#1307)', () => { assert.equal(summarizeProcessing(children, { live: true }), '正在深度思考'); }); + it('live summary picks the LAST live entry in timeline order, skipping settled thinking', () => { + // A settled reasoning block after a still-running tool must not steal the + // current-activity line: the last LIVE entry is the running tool. + const children = [ + tools([{ toolUseId: 'b1', toolName: 'Bash', activityKind: 'command', status: 'running', args: {}, intent: '运行测试' }]), + thinking(false), + ]; + assert.equal(summarizeProcessing(children, { live: true }), '正在运行测试'); + // And a LATER streaming thinking block outranks an earlier running tool. + const laterThinking = [ + tools([{ toolUseId: 'b1', toolName: 'Bash', activityKind: 'command', status: 'running', args: {}, intent: '运行测试' }]), + thinking(true), + ]; + assert.equal(summarizeProcessing(laterThinking, { live: true }), '正在深度思考'); + }); + + it('localizes the connector-tool fallback in the live current activity', () => { + // A load_tools call with no intent/displayName must read as the localized + // 「加载工具组」, not the raw tool name (resolveToolDisplayName fallback). + const children = [ + tools([{ toolUseId: 'l1', toolName: 'load_tools', status: 'running', args: {} }]), + ]; + assert.equal(summarizeProcessing(children, { live: true }), '正在加载工具组'); + }); + it('is running while any tool is in flight or reasoning is still streaming', () => { assert.equal(isProcessingRunning([thinking(true)]), true); assert.equal(isProcessingRunning([thinking(false)]), false); diff --git a/packages/ui/src/tool-activity/presentation.ts b/packages/ui/src/tool-activity/presentation.ts index c8d89cad35..be95642674 100644 --- a/packages/ui/src/tool-activity/presentation.ts +++ b/packages/ui/src/tool-activity/presentation.ts @@ -1,8 +1,12 @@ import type { UiLocale } from '@maka/core'; import type { ToolActivityItem } from '../materialize.js'; -import { loadToolDisplayName } from '../tool-format.js'; import { formatUserVisibleToolText } from './preview-utils.js'; -import { trowActivityKind, type TrowActivityKind } from './trow-summary.js'; +import { resolveToolDisplayName, trowActivityKind, type TrowActivityKind } from './trow-summary.js'; + +// Definitions moved into trow-summary.ts (the leaf module) so the live +// processing summary can use the localized display-name fallback without an +// import cycle; re-exported here for existing consumers. +export { isConnectorTool, resolveToolDisplayName } from './trow-summary.js'; export interface ToolActivityPresentation { kind: TrowActivityKind; @@ -15,18 +19,6 @@ export interface ToolDisclosureState { manuallySet: boolean; } -const CONNECTOR_TOOL_NAMES: ReadonlySet = new Set(['load_tools', 'load_tool']); - -export function isConnectorTool(name: string): boolean { - return CONNECTOR_TOOL_NAMES.has(name); -} - -export function resolveToolDisplayName(item: ToolActivityItem, locale: UiLocale): string { - if (item.displayName) return item.displayName; - if (isConnectorTool(item.toolName)) return loadToolDisplayName(locale); - return item.toolName; -} - export function deriveToolActivityPresentation( item: ToolActivityItem, locale: UiLocale, diff --git a/packages/ui/src/tool-activity/trow-summary.ts b/packages/ui/src/tool-activity/trow-summary.ts index 380a48a4e0..da8ca5a459 100644 --- a/packages/ui/src/tool-activity/trow-summary.ts +++ b/packages/ui/src/tool-activity/trow-summary.ts @@ -14,11 +14,28 @@ import type { ToolActivityKind, UiLocale } from '@maka/core'; import type { ToolActivityItem } from '../materialize.js'; import type { FoldedTimelineChild } from '../timeline-fold.js'; +import { loadToolDisplayName } from '../tool-format.js'; import { getToolActivityCopy } from './copy.js'; import { formatUserVisibleToolText } from './preview-utils.js'; export type TrowActivityKind = ToolActivityKind; +// Connector-tool naming lives in this leaf module (rather than +// presentation.ts, which imports us) so the live processing summary below can +// reuse the same localized fallback without an import cycle. presentation.ts +// re-exports both for its existing consumers. +const CONNECTOR_TOOL_NAMES: ReadonlySet = new Set(['load_tools', 'load_tool']); + +export function isConnectorTool(name: string): boolean { + return CONNECTOR_TOOL_NAMES.has(name); +} + +export function resolveToolDisplayName(item: ToolActivityItem, locale: UiLocale): string { + if (item.displayName) return item.displayName; + if (isConnectorTool(item.toolName)) return loadToolDisplayName(locale); + return item.toolName; +} + /** * Prefer a declared semantic category. Legacy rows fall back to the canonical * tool name (case-insensitive); unknown names use the generic `tool` bucket. @@ -162,8 +179,10 @@ export function processingNeedsAttention(children: readonly FoldedTimelineChild[ * Summary line for a processing block. Settled: the tool-activity roll-up only * (per-bucket clauses + failed count, exactly the trow summary) — folded * reasoning is not counted. Live (`{ live: true }`): the current activity — - * the running tool's intent (or the reasoning label when the tools are done - * and only thinking is still streaming), prefixed with "正在". + * the LAST live entry in timeline order (a running tool's intent, or the + * reasoning label when a later thinking block is still streaming), prefixed + * with "正在" — plus the failed clause whenever the block already holds an + * errored tool, so the failure signal is never deferred to settle. */ export function summarizeProcessing( children: readonly FoldedTimelineChild[], @@ -180,19 +199,38 @@ function processingLiveSummary( locale: UiLocale, ): string { const copy = getToolActivityCopy(locale).summary; - const tools = processingTools(children); - const activeTool = [...tools] - .reverse() - .find( - (tool) => - tool.status === 'running' || tool.status === 'pending' || tool.status === 'waiting_permission', - ); - if (activeTool) { - const label = - formatUserVisibleToolText(activeTool.intent ?? '', locale) - || activeTool.displayName - || activeTool.toolName; - return copy.live(label); + const line = copy.live(currentProcessingActivity(children, locale) ?? copy.thinkingActivity); + const failed = processingTools(children).filter((tool) => isFailed(tool.status)).length; + return failed > 0 ? copy.join([line, copy.failed(failed)]) : line; +} + +/** + * The block's current activity: walk the children in reverse timeline order + * and return the first live entry found — a still-streaming thinking block + * (reasoning label) or a tool group's active tool (intent, falling back to the + * localized display name via resolveToolDisplayName so connector tools read as + * 「加载工具组」, not `load_tools`). + */ +function currentProcessingActivity( + children: readonly FoldedTimelineChild[], + locale: UiLocale, +): string | undefined { + for (let index = children.length - 1; index >= 0; index -= 1) { + const child = children[index]!; + if (child.kind === 'thinking') { + if (child.live === true) return getToolActivityCopy(locale).summary.thinkingActivity; + continue; + } + const activeTool = [...child.items] + .reverse() + .find( + (tool) => + tool.status === 'running' || tool.status === 'pending' || tool.status === 'waiting_permission', + ); + if (activeTool) { + return formatUserVisibleToolText(activeTool.intent ?? '', locale) + || resolveToolDisplayName(activeTool, locale); + } } - return copy.live(copy.thinkingActivity); + return undefined; } From 5d1bfbb20d8555f78fdfdeef62480ffdb8338d49 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 22 Jul 2026 19:26:17 +0800 Subject: [PATCH 6/6] docs(ui): note the Processing fold dissolves when its last tools group is projected away --- packages/ui/src/timeline-fold.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/timeline-fold.ts b/packages/ui/src/timeline-fold.ts index e4c9cb7007..3c89676c82 100644 --- a/packages/ui/src/timeline-fold.ts +++ b/packages/ui/src/timeline-fold.ts @@ -21,7 +21,11 @@ import type { TurnTimelineItem } from './materialize.js'; * is at most one block, so the id is unique per turn — and, unlike a key * guessed from the first child, it survives the first tool being projected * away (shell-run folding) without remounting the disclosure or dropping a - * manual open/close. + * manual open/close. When projection removes a block's LAST tools group the + * block itself dissolves (the remaining run is pure thinking), so the bare + * 深度思考 entries remount and any manual open state inside is reset — the + * accepted cost of deriving block existence at render time instead of + * representing a tools-less block in the model. */ /** An entry folded inside a processing block: reasoning or a tool group. */