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/apps/desktop/src/main/__tests__/streaming-handoff.test.ts b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts index 10138281f9..8782d64392 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: 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('最终答案')); + 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 6570c010a9..5f3b8694d3 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,68 @@ describe('materializeChat attachments', () => { ); }); }); + +// ── #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 shellRunResult(revision: number) { + return { + 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, + }, + }; +} + +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'), + ]); + const turns = overlayLiveTurn(settled, { + turnId: 't2', + phase: 'streamed', + steps: [{ + stepId: 'a1', + thinking: { text: 'watching the background job', truncated: false, complete: false }, + tools: [{ + toolUseId: 'read-1', + toolName: 'Read', + stepId: 'a1', + status: 'completed', + args: {}, + result: shellRunResult(2), + }], + }], + }); + 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 cc1293f5db..9921bda2eb 100644 --- a/packages/ui/src/__tests__/tool-trow-summary.test.ts +++ b/packages/ui/src/__tests__/tool-trow-summary.test.ts @@ -7,8 +7,14 @@ 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 { + isProcessingRunning, + processingNeedsAttention, + summarizeProcessing, + summarizeTrowTools, +} from '../tool-activity/trow-summary.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'), @@ -101,3 +107,106 @@ describe('tool trow summary aggregation', () => { assert.equal((toolActivitySource.match(/\bSETTLE_FADE\b/g) ?? []).length, 2); }); }); + +function thinking(live?: boolean): FoldedTimelineChild { + return { kind: 'thinking', text: 'reasoning', messageId: 'a1', ...(live !== undefined ? { live } : {}) }; +} + +function tools(items: ToolActivityItem[]): FoldedTimelineChild { + return { kind: 'tools', items }; +} + +describe('processing block summary (#1307)', () => { + it('settled summary rolls up tool activity only — folded reasoning is not counted', () => { + const children = [ + thinking(), + tools([ + { toolUseId: 'r1', toolName: 'Read', activityKind: 'read', status: 'completed', args: {} }, + { toolUseId: 'g1', toolName: 'Grep', activityKind: 'search', status: 'errored', args: {} }, + ]), + thinking(), + ]; + // 只汇总工具桶 + 标红失败计数(沿用 summarizeTrowTools 文案),不出现「思考 N 次」。 + assert.equal(summarizeProcessing(children, {}), '读取 1 个文件,搜索 1 次,1 个失败'); + }); + + 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 }), '正在运行测试'); + }); + + 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('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); + 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 a4eddd9078..acb5613167 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -1,21 +1,23 @@ -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, GitBranch, Info, Loader2, Pencil, RefreshCcw, Timer } from './icons.js'; +import { AlertOctagon, Ban, Brain, Check, ChevronRight, Copy, Cpu, GitBranch, Info, Loader2, Pencil, 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, QuoteRef } from '@maka/core'; import type { TurnTimelineItem, TurnViewModel } from './materialize.js'; +import { foldTimeline, type FoldedTimelineChild } from './timeline-fold.js'; import { AttachmentFileCard } from './attachment-file-card.js'; import { QuoteRefChip } from './quote-ref-chip.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'; @@ -377,6 +379,10 @@ export const TurnView = memo(function TurnView(props: { ? 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 && } @@ -901,6 +913,74 @@ function TurnTimelineEntry(props: { return ; } +/** + * "Processing" — a folded run of the model's reasoning + tool activity between + * two answer texts (#1307; the fold is derived at render time by + * `foldTimeline`, which only folds runs containing 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 + * and tool trows, just nested one indent in — so nothing is lost, only folded. + */ +function ProcessingBlock(props: { entries: FoldedTimelineChild[] }) { + 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 0303ed649d..3d5c399ff0 100644 --- a/packages/ui/src/materialize.ts +++ b/packages/ui/src/materialize.ts @@ -225,6 +225,11 @@ 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. + * + * 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 TurnTimelineItem = | { kind: 'thinking'; text: string; messageId: string; live?: boolean; truncated?: boolean } diff --git a/packages/ui/src/timeline-fold.ts b/packages/ui/src/timeline-fold.ts new file mode 100644 index 0000000000..3c89676c82 --- /dev/null +++ b/packages/ui/src/timeline-fold.ts @@ -0,0 +1,68 @@ +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. 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. */ +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.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..8d4161a74e 100644 --- a/packages/ui/src/tool-activity/copy.ts +++ b/packages/ui/src/tool-activity/copy.ts @@ -51,6 +51,9 @@ export interface ToolActivityCopy { failed: (count: number) => string; join: (clauses: readonly string[]) => string; live: (summary: string) => string; + /** Live current-activity label when a processing group's tools are done + * and only reasoning is still streaming. */ + thinkingActivity: string; }; automation: { created: (name: string) => string; @@ -178,6 +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}`, + 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 +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}`, + 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/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 d22893b735..da8ca5a459 100644 --- a/packages/ui/src/tool-activity/trow-summary.ts +++ b/packages/ui/src/tool-activity/trow-summary.ts @@ -13,10 +13,29 @@ 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. @@ -125,3 +144,93 @@ 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 (a run folds only when it contains tool activity — see +// 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 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 FoldedTimelineChild[]): 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 FoldedTimelineChild[]): boolean { + return children.some((child) => child.kind === 'tools' && trowNeedsAttention(child.items)); +} + +/** + * 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 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[], + options?: { live?: boolean; locale?: UiLocale }, +): string { + const locale = options?.locale ?? 'zh'; + if (options?.live) return processingLiveSummary(children, locale); + return summarizeTrowTools(processingTools(children), { locale }); +} + +/** Current-activity line for a running processing block. */ +function processingLiveSummary( + children: readonly FoldedTimelineChild[], + locale: UiLocale, +): string { + const copy = getToolActivityCopy(locale).summary; + 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 undefined; +} diff --git a/packages/ui/stories/chat-surface.stories.tsx b/packages/ui/stories/chat-surface.stories.tsx index aba7545b0b..44509036d9 100644 --- a/packages/ui/stories/chat-surface.stories.tsx +++ b/packages/ui/stories/chat-surface.stories.tsx @@ -390,6 +390,120 @@ 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 — 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 的环边界,跑一下单测确认。'), + { + 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: () => (