From d667f2d57bc48209d20546c55b3acc6eded7c3b0 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 11 Jul 2026 18:38:52 +0800 Subject: [PATCH 1/5] fix(tool-trow): aggregate multi-tool running summary to stop parallel jitter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A multi-tool running trow showed the active tool's description as the summary line, cycling through each tool's intent as tools started/finished in parallel — the 1234567 jitter. Switch to the whole-group bucket aggregation with a "正在" prefix (e.g. "正在读取 7 个文件,搜索 2 次"), counting the whole group including settled tools so the summary does not decrement as tools finish in batches. Single-tool rows keep the tool's own description (locked by existing tests, and the "what exactly is running" signal is useful when there is one). Running summary omits the failed count (it changes mid-group as tools error); errored tools still force-open their disclosure (trowNeedsAttention), so the failure signal is not lost — it just stops jittering the summary line. --- .../src/__tests__/tool-trow-summary.test.ts | 36 +++++++++++++++++++ packages/ui/src/tool-activity.tsx | 8 ++++- packages/ui/src/tool-activity/trow-summary.ts | 14 ++++++-- 3 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 packages/ui/src/__tests__/tool-trow-summary.test.ts diff --git a/packages/ui/src/__tests__/tool-trow-summary.test.ts b/packages/ui/src/__tests__/tool-trow-summary.test.ts new file mode 100644 index 0000000000..18d4eaa480 --- /dev/null +++ b/packages/ui/src/__tests__/tool-trow-summary.test.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { ToolTrow } from '../tool-activity.js'; +import type { ToolActivityItem } from '../materialize.js'; + +describe('tool trow summary aggregation', () => { + it('multi-tool running summary shows aggregated bucket with 正在 prefix, not the active tool description', () => { + const markup = renderToStaticMarkup(createElement(ToolTrow, { + items: [ + { toolUseId: 'r1', toolName: 'Read', activityKind: 'read', status: 'running', args: {}, intent: '读取 a.ts' }, + { toolUseId: 'r2', toolName: 'Read', activityKind: 'read', status: 'running', args: {}, intent: '读取 b.ts' }, + { toolUseId: 'g1', toolName: 'Grep', activityKind: 'search', status: 'running', args: {}, intent: '搜索 foo' }, + ] satisfies ToolActivityItem[], + })); + + // 整组 bucket 聚合 + "正在"前缀,不跟 active 工具走 + assert.match(markup, /正在读取 2 个文件,搜索 1 次/); + // 不显示 active 工具的具体描述(避免并发时 1234567 跳) + assert.doesNotMatch(markup, /搜索 foo/); + assert.doesNotMatch(markup, /读取 b\.ts/); + }); + + it('counts the whole group including settled tools, so the summary does not decrement as tools finish', () => { + const markup = renderToStaticMarkup(createElement(ToolTrow, { + items: [ + { toolUseId: 'r1', toolName: 'Read', activityKind: 'read', status: 'running', args: {} }, + { toolUseId: 'r2', toolName: 'Read', activityKind: 'read', status: 'completed', args: {} }, + { toolUseId: 'g1', toolName: 'Grep', activityKind: 'search', status: 'running', args: {} }, + ] satisfies ToolActivityItem[], + })); + // 整组总数(含已完成),不随完成数递减 — 并行 result 一起返回时不 1234567 + assert.match(markup, /正在读取 2 个文件,搜索 1 次/); + }); +}); \ No newline at end of file diff --git a/packages/ui/src/tool-activity.tsx b/packages/ui/src/tool-activity.tsx index ec4ecaad79..245b32cdc1 100644 --- a/packages/ui/src/tool-activity.tsx +++ b/packages/ui/src/tool-activity.tsx @@ -503,8 +503,14 @@ function ToolTrowGroup({ items }: { items: ToolActivityItem[] }) { const settling = settled && everRunningRef.current; const hasError = items.some((item) => item.status === 'errored'); const SummaryIcon = TROW_KIND_ICON[activePresentation.kind]; + // Multi-tool running group shows the whole-group bucket aggregation with a + // "正在" prefix instead of the active tool's description, so the summary line + // stops cycling through each tool's intent as tools start/finish in + // parallel (the 1234567 jitter). Single-tool rows keep the tool's own + // description — the "what exactly is running" signal is useful when there is + // only one, and it is locked by existing tests. const summary = running - ? activePresentation.summary + ? (items.length > 1 ? summarizeTrowTools(items, { live: true }) : activePresentation.summary) : summarizeTrowTools(items); return ( diff --git a/packages/ui/src/tool-activity/trow-summary.ts b/packages/ui/src/tool-activity/trow-summary.ts index 613ab5bb08..b2fc7319e8 100644 --- a/packages/ui/src/tool-activity/trow-summary.ts +++ b/packages/ui/src/tool-activity/trow-summary.ts @@ -95,7 +95,10 @@ function isFailed(status: ToolActivityItem['status']): boolean { * "N 个失败" clause when any tool errored. A failed tool still counts toward * its type bucket (a failed read is "读取 1 个文件" + "1 个失败"). */ -export function summarizeTrowTools(items: readonly ToolActivityItem[]): string { +export function summarizeTrowTools( + items: readonly ToolActivityItem[], + options?: { live?: boolean }, +): string { const order: TrowActivityKind[] = []; const counts = new Map(); let failed = 0; @@ -106,8 +109,13 @@ export function summarizeTrowTools(items: readonly ToolActivityItem[]): string { if (isFailed(item.status)) failed += 1; } const clauses = order.map((kind) => KIND_CLAUSE[kind](counts.get(kind) ?? 0)); - if (failed > 0) clauses.push(`${failed} 个失败`); - return clauses.join(','); + // Running summary prioritizes stability: the failed count changes as tools + // error mid-group, so it is shown only once the group settles. Errored tools + // still force-open their disclosure (trowNeedsAttention), so the failure + // signal is not lost — it just doesn't jitter the summary line mid-run. + if (!options?.live && failed > 0) clauses.push(`${failed} 个失败`); + const base = clauses.join(','); + return options?.live ? `正在${base}` : base; } /** From b1d7556ac412d25cace6fbb556bf59b149699c78 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 11 Jul 2026 18:43:52 +0800 Subject: [PATCH 2/5] fix(tool-trow): drop per-row settle fade so parallel finishes don't stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tool row settled with a one-shot opacity-0→1 fade (SETTLE_FADE). When parallel tools finished together, N fades stacked into the 1234567 jitter. The row now settles by its shimmer stopping — the same seam as the 深度思考 disclosure title (light band → static muted text), no opacity fade — so a batch settle is N light bands dropping, not N fades stacking. Removes the now-dead deriveToolRowMotion / ToolRowMotion (the group summary keeps its own settle fade as the whole-group signal; the row no longer tracks everRunning) and the two settle-fade contract tests that locked the removed behavior. isToolRowRunning / isToolRowSettled stay (the row still uses them). --- .../main/__tests__/tool-row-motion.test.ts | 25 ----------- packages/ui/src/index.ts | 7 ++-- packages/ui/src/tool-activity.tsx | 40 +++++++++--------- .../ui/src/tool-activity/tool-row-motion.ts | 41 ++++--------------- 4 files changed, 32 insertions(+), 81 deletions(-) diff --git a/apps/desktop/src/main/__tests__/tool-row-motion.test.ts b/apps/desktop/src/main/__tests__/tool-row-motion.test.ts index 7a854227e3..6b0740a16b 100644 --- a/apps/desktop/src/main/__tests__/tool-row-motion.test.ts +++ b/apps/desktop/src/main/__tests__/tool-row-motion.test.ts @@ -9,7 +9,6 @@ import { describe, it } from 'node:test'; import { isToolRowRunning, isToolRowSettled, - deriveToolRowMotion, type ToolActivityItem, } from '@maka/ui'; @@ -33,28 +32,4 @@ describe('tool-row run→done seam (#646)', () => { } }); - it('shimmers while running regardless of whether it was ever running', () => { - for (const status of RUNNING) { - const motion = deriveToolRowMotion({ status, everRunning: true }); - assert.deepEqual(motion, { shimmer: true, settled: false, settling: false }); - } - }); - - it('plays the settle fade only for a row that was seen running in this view', () => { - for (const status of SETTLED) { - // A live run→done: the row was running, then settled → land it. - assert.deepEqual( - deriveToolRowMotion({ status, everRunning: true }), - { shimmer: false, settled: true, settling: true }, - `${status} after a live run settles with a fade`, - ); - // A replayed transcript row: mounted already terminal, never ran here → - // stays static so loaded history does not fade in on scroll. - assert.deepEqual( - deriveToolRowMotion({ status, everRunning: false }), - { shimmer: false, settled: true, settling: false }, - `${status} replayed from history does not fade`, - ); - } - }); }); diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 5b91c06b48..f87b8b74ee 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -130,13 +130,12 @@ export { trowNeedsAttention, type TrowActivityKind, } from './tool-activity/trow-summary.js'; -// #646 run→done seam: pure status→motion mapping for a tool row (delayed shimmer -// + one-shot settle fade gated to live settles). Unit-tested. +// #646 run→done seam: a tool row shimmers while running and settles by the +// light band stopping (no opacity fade — parallel settles don't stack). +// Unit-tested. export { isToolRowRunning, isToolRowSettled, - deriveToolRowMotion, - type ToolRowMotion, } from './tool-activity/tool-row-motion.js'; // Streaming UI rework: per-word fade-in for streamed text (replaces the ▎ // caret). Pure append-record ring + tokenizer are unit-tested; the hook feeds diff --git a/packages/ui/src/tool-activity.tsx b/packages/ui/src/tool-activity.tsx index 245b32cdc1..2f10fcf9f5 100644 --- a/packages/ui/src/tool-activity.tsx +++ b/packages/ui/src/tool-activity.tsx @@ -26,7 +26,7 @@ import { trowNeedsAttention, type TrowActivityKind, } from './tool-activity/trow-summary.js'; -import { deriveToolRowMotion, isToolRowRunning } from './tool-activity/tool-row-motion.js'; +import { isToolRowRunning, isToolRowSettled } from './tool-activity/tool-row-motion.js'; import { createToolDisclosureState, deriveToolActivityPresentation, @@ -464,13 +464,15 @@ const TROW_KIND_ICON: Record> = { tool: Settings, }; -// #646 run→done seam: the one-shot settle "landing". Reuses the whitelisted -// `maka-stream-fade-in` keyframe (opacity 0→1, one-shot `both`) — no new keyframe -// (design-406 governance) — and rides `var(--duration-emphasized)` / -// `var(--ease-out-strong)` so it converges with the motion tokens. Applied only -// when `motion.settling` (the row was seen running here and just settled), so a -// replayed transcript's rows stay static. Auto-frozen under reduced-motion / -// visual-smoke by the global rules in styles/base.css. +// #646 run→done seam: the one-shot settle "landing" for the group summary line. +// Reuses the whitelisted `maka-stream-fade-in` keyframe (opacity 0→1, one-shot +// `both`) — no new keyframe (design-406 governance) — and rides +// `var(--duration-emphasized)` / `var(--ease-out-strong)` so it converges with +// the motion tokens. Applied only when the group was seen running here and +// just settled, so a replayed transcript's summary stays static. Auto-frozen +// under reduced-motion / visual-smoke 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]'; /** @@ -552,13 +554,12 @@ function ToolTrowRow({ item }: { item: ToolActivityItem }) { const presentation = deriveToolActivityPresentation(item); const disclosure = useToolDisclosure(presentation); const duration = formatDuration(item.durationMs); - // #646 run→done seam: `everRunning` is sticky across this row's renders so the - // settle fade fires only for a tool that ran here, never for a replayed row - // mounted already terminal. The delayed shimmer + one-shot fade share the same - // ~200ms window, so a sub-second tool neither sweeps nor lands — it just appears. - const everRunningRef = useRef(false); - if (isToolRowRunning(item.status)) everRunningRef.current = true; - const motion = deriveToolRowMotion({ status: item.status, everRunning: everRunningRef.current }); + // #tool-jitter: a row settles by its shimmer stopping — the same seam as the + // 深度思考 disclosure title (light band → static muted text), with no opacity + // fade. Parallel tools finishing together each just drop their light band + // instead of stacking N opacity-0→1 fades, so a batch settle no longer 1234567. + const running = isToolRowRunning(item.status); + const settled = isToolRowSettled(item.status); const errored = item.status === 'errored'; const RowIcon = TROW_KIND_ICON[presentation.kind]; // One row language with the multi-tool summary row: a kind icon + a @@ -566,21 +567,20 @@ function ToolTrowRow({ item }: { item: ToolActivityItem }) { // word. Running shimmers the model's intent (or the friendly tool name); // settled prefers the intent, falls back to the display name. const summaryTone = errored ? 'text-[color:var(--destructive)]' : 'text-[color:var(--muted-foreground)]'; - const settleFade = motion.settling ? SETTLE_FADE : undefined; return ( - +