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/apps/desktop/src/main/__tests__/trow-summary.test.ts b/apps/desktop/src/main/__tests__/trow-summary.test.ts index e310aad19a..4755903cc5 100644 --- a/apps/desktop/src/main/__tests__/trow-summary.test.ts +++ b/apps/desktop/src/main/__tests__/trow-summary.test.ts @@ -7,7 +7,6 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import { - activeTrowTool, isTrowRunning, summarizeTrowTools, trowActivityKind, @@ -72,22 +71,15 @@ describe('summarizeTrowTools', () => { }); }); -describe('activeTrowTool + isTrowRunning', () => { - it('reports running while any tool is in flight and picks the last in-flight tool', () => { +describe('isTrowRunning', () => { + it('reports running while any tool is in flight', () => { const items = [tool('Read', 'completed'), tool('Bash', 'running'), tool('Grep', 'completed')]; assert.equal(isTrowRunning(items), true); - assert.equal(activeTrowTool(items)?.toolName, 'Bash'); }); - it('reports settled and falls back to the last tool when nothing is in flight', () => { + it('reports settled when nothing is in flight', () => { const items = [tool('Read'), tool('Grep')]; assert.equal(isTrowRunning(items), false); - assert.equal(activeTrowTool(items)?.toolName, 'Grep'); - }); - - it('prefers waiting_permission as active', () => { - const items = [tool('Read', 'completed'), tool('Write', 'waiting_permission')]; - assert.equal(activeTrowTool(items)?.status, 'waiting_permission'); }); }); 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..e53b05859a --- /dev/null +++ b/packages/ui/src/__tests__/tool-trow-summary.test.ts @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { describe, it } from 'node:test'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { ToolTrow } from '../tool-activity.js'; +import { summarizeTrowTools } from '../tool-activity/trow-summary.js'; +import type { ToolActivityItem } from '../materialize.js'; + +const toolActivitySource = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'src', 'tool-activity.tsx'), + 'utf8', +); + +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 次/); + }); + + it('multi-tool group icon uses the first bucket kind, not the active tool', () => { + const markup = renderToStaticMarkup(createElement(ToolTrow, { + items: [ + { toolUseId: 'r1', toolName: 'Read', activityKind: 'read', status: 'running', args: {} }, + { toolUseId: 'g1', toolName: 'Grep', activityKind: 'search', status: 'running', args: {} }, + ] satisfies ToolActivityItem[], + })); + // 首个 bucket = read = FileText,不跟 active (Grep = Search) 切 + assert.match(markup, /lucide-file-text/); + assert.doesNotMatch(markup, /lucide-search/); + }); + + it('live summary omits the failed count (it changes mid-group); settled includes it', () => { + const items: ToolActivityItem[] = [ + { toolUseId: 'r1', toolName: 'Read', activityKind: 'read', status: 'completed', args: {} }, + { toolUseId: 'g1', toolName: 'Grep', activityKind: 'search', status: 'errored', args: {} }, + ]; + assert.equal(summarizeTrowTools(items, { live: true }), '正在读取 1 个文件,搜索 1 次'); + assert.equal(summarizeTrowTools(items), '读取 1 个文件,搜索 1 次,1 个失败'); + }); + + it('ToolTrowRow never reintroduces the per-row settle fade or the motion abstraction', () => { + // The per-row seam is a light-band stop only. The group keeps one + // SETTLE_FADE (its summary span); rows must not bring back the motion + // abstraction (deriveToolRowMotion / motion.* / settleFade) that would + // re-stack parallel fades. A dynamic running→settled rerender contract is + // tracked separately (packages/ui has only renderToStaticMarkup); this + // source contract locks the implementation shape until that infra exists. + assert.doesNotMatch(toolActivitySource, /deriveToolRowMotion/); + assert.doesNotMatch(toolActivitySource, /\bmotion\.(settling|shimmer|settled)\b/); + assert.doesNotMatch(toolActivitySource, /\bsettleFade\b/); + assert.equal((toolActivitySource.match(/\bSETTLE_FADE\b/g) ?? []).length, 2); + }); +}); \ No newline at end of file diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 5b91c06b48..529c3198e5 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -125,18 +125,16 @@ export type { PageHeaderProps } from './primitives/page-header.js'; export { summarizeTrowTools, trowActivityKind, - activeTrowTool, isTrowRunning, 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 ec4ecaad79..f189256881 100644 --- a/packages/ui/src/tool-activity.tsx +++ b/packages/ui/src/tool-activity.tsx @@ -20,13 +20,12 @@ import { useClipboardCopyFeedback } from './clipboard-feedback.js'; import { detectUiLocale } from './locale-helpers.js'; import { type ToolActivityItem, type ToolOutputChunk } from './materialize.js'; import { - activeTrowTool, isTrowRunning, summarizeTrowTools, 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 +463,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]'; /** @@ -488,12 +489,16 @@ export function ToolTrow({ items }: { items: ToolActivityItem[] }) { function ToolTrowGroup({ items }: { items: ToolActivityItem[] }) { const running = isTrowRunning(items); const attention = trowNeedsAttention(items); - const active = activeTrowTool(items) ?? items[0]!; - const activePresentation = deriveToolActivityPresentation(active); + // The group's presentation follows the first item (the first-seen bucket the + // summary clauses and icon use). The active-tool lookup is gone: a multi-tool + // running group shows the whole-group aggregation, a single-tool group's + // active tool is items[0] anyway, and disclosure attention is overridden by + // the whole-group trowNeedsAttention below. + const firstPresentation = deriveToolActivityPresentation(items[0]!); // Groups share the same disclosure state as a single row: ordinary work is // summarized; a new permission/error state opens diagnostics; manual choice // survives ordinary status changes. - const disclosure = useToolDisclosure({ ...activePresentation, needsAttention: attention }); + const disclosure = useToolDisclosure({ ...firstPresentation, needsAttention: attention }); // #646: a group settles when all its tools do; the settle fade plays only if // the group was ever seen running here (not a replayed transcript). The // delayed shimmer de-flickers a group whose tools all finish sub-second. @@ -502,9 +507,18 @@ function ToolTrowGroup({ items }: { items: ToolActivityItem[] }) { const settled = !running; const settling = settled && everRunningRef.current; const hasError = items.some((item) => item.status === 'errored'); - const SummaryIcon = TROW_KIND_ICON[activePresentation.kind]; + // #tool-jitter: the group icon stays on the first bucket's kind (the same + // first-seen order the summary clauses use), not the active tool's kind — so + // a mixed-kind group's icon doesn't flip as the active tool changes mid-run. + const SummaryIcon = TROW_KIND_ICON[firstPresentation.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 }) : firstPresentation.summary) : summarizeTrowTools(items); return ( @@ -546,13 +560,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 @@ -560,21 +573,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 ( - +