Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 0 additions & 25 deletions apps/desktop/src/main/__tests__/tool-row-motion.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,6 @@ import { describe, it } from 'node:test';
import {
isToolRowRunning,
isToolRowSettled,
deriveToolRowMotion,
type ToolActivityItem,
} from '@maka/ui';

Expand All@@ -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`,
);
}
});
});
14 changes: 3 additions & 11 deletions apps/desktop/src/main/__tests__/trow-summary.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,6 @@
import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
import {
activeTrowTool,
isTrowRunning,
summarizeTrowTools,
trowActivityKind,
Expand DownExpand Up@@ -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');
});
});

Expand Down
79 changes: 79 additions & 0 deletions packages/ui/src/__tests__/tool-trow-summary.test.ts
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
8 changes: 3 additions & 5 deletions packages/ui/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
64 changes: 38 additions & 26 deletions packages/ui/src/tool-activity.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -464,13 +463,15 @@ const TROW_KIND_ICON: Record<TrowActivityKind, ComponentType<LucideProps>> = {
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]';

/**
Expand All@@ -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.
Expand All@@ -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 (
<Collapsible className="flex flex-col" data-trow="group" data-settled={settled ? 'true' : undefined} open={disclosure.open} onOpenChange={disclosure.setOpen}>
Expand DownExpand Up@@ -546,35 +560,33 @@ 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
// user-language phrase, never the old status-dot + mono tool-name + status
// 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 (
<Collapsible className="flex flex-col" data-trow="row" data-status={item.status} data-settled={motion.settled ? 'true' : undefined} open={disclosure.open} onOpenChange={disclosure.setOpen}>
<Collapsible className="flex flex-col" data-trow="row" data-status={item.status} data-settled={settled ? 'true' : undefined} open={disclosure.open} onOpenChange={disclosure.setOpen}>
<CollapsibleTrigger className="group flex w-full items-center gap-2 py-0.5 text-left">
<RowIcon
size={16}
aria-hidden="true"
className={cn('shrink-0', errored ? 'text-[color:var(--destructive)]' : 'text-[color:var(--muted-foreground)]')}
/>
{motion.shimmer ? (
{running ? (
<TextShimmer active delayed className="min-w-0 truncate text-[length:var(--font-size-base)]">{presentation.summary}</TextShimmer>
) : item.intent ? (
<span className={cn('min-w-0 truncate text-[length:var(--font-size-base)]', summaryTone, settleFade)}>{formatToolIntent(item.intent)}</span>
<span className={cn('min-w-0 truncate text-[length:var(--font-size-base)]', summaryTone)}>{formatToolIntent(item.intent)}</span>
) : (
<span className={cn('min-w-0 truncate text-[length:var(--font-size-base)]', summaryTone, settleFade)}>{resolveToolDisplayName(item)}</span>
<span className={cn('min-w-0 truncate text-[length:var(--font-size-base)]', summaryTone)}>{resolveToolDisplayName(item)}</span>
)}
{/* Quiet meta sits right after the label (near the text, not pinned to
the far edge): duration + chevron ride in on hover / open, matching
Expand Down
41 changes: 9 additions & 32 deletions packages/ui/src/tool-activity/tool-row-motion.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,16 +3,15 @@ import { type ToolActivityItem } from '../materialize.js';
type ToolStatus = ToolActivityItem['status'];

/**
* The run→done seam (#646). A tool row is visible the instant it starts, but its
* two motions are gated so history stays quiet and sub-second tools never flicker:
* The run→done seam (#646 + #tool-jitter). A tool row is visible the instant it
* starts; running statuses shimmer (the light band via `TextShimmer delayed`),
* and the row settles by that band stopping — the same seam as the 深度思考
* disclosure title, with no opacity fade so parallel tools finishing together
* don't stack N fades.
*
* - `shimmer` — the working light-band sweeps the label while the tool is
* in flight. The ~200ms de-flicker delay is CSS (`animation-delay` on the
* sweep, see `TextShimmer delayed`), so a tool that settles inside the window
* unmounts mid-delay and never visibly sweeps — no logic needed here.
* - `settling` — the one-shot "landing" fade only plays for a row that was seen
* running in THIS view and just settled, never for a replayed transcript's rows
* (mounted already terminal). The caller tracks `everRunning` with a ref.
* The ~200ms de-flicker before the sweep starts is CSS (`animation-delay` on
* `TextShimmer delayed`), so a sub-second tool that settles inside the window
* unmounts mid-delay and never visibly sweeps — no logic needed here.
*/

/**
Expand All@@ -28,26 +27,4 @@ export function isToolRowRunning(status: ToolStatus): boolean {
/** Terminal statuses — the row has landed on a result (success, error, or interrupt). */
export function isToolRowSettled(status: ToolStatus): boolean {
return status === 'completed' || status === 'errored' || status === 'interrupted';
}

export interface ToolRowMotion {
/** Shimmer the working label (the delay that de-flickers sub-second tools is CSS). */
shimmer: boolean;
/** The row is on a terminal result. */
settled: boolean;
/**
* Settled *after being seen running in this view* — plays the one-shot settle
* fade. False for a replayed transcript's rows (mounted already terminal), so a
* loaded session's tool history stays static instead of fading in on scroll.
*/
settling: boolean;
}

export function deriveToolRowMotion(input: { status: ToolStatus; everRunning: boolean }): ToolRowMotion {
const settled = isToolRowSettled(input.status);
return {
shimmer: isToolRowRunning(input.status),
settled,
settling: settled && input.everRunning,
};
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 0 additions & 25 deletions apps/desktop/src/main/__tests__/tool-row-motion.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,6 @@ import { describe, it } from 'node:test';
import {
isToolRowRunning,
isToolRowSettled,
deriveToolRowMotion,
type ToolActivityItem,
} from '@maka/ui';

Expand All@@ -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`,
);
}
});
});
14 changes: 3 additions & 11 deletions apps/desktop/src/main/__tests__/trow-summary.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,6 @@
import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
import {
activeTrowTool,
isTrowRunning,
summarizeTrowTools,
trowActivityKind,
Expand DownExpand Up@@ -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');
});
});

Expand Down
79 changes: 79 additions & 0 deletions packages/ui/src/__tests__/tool-trow-summary.test.ts
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
8 changes: 3 additions & 5 deletions packages/ui/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
64 changes: 38 additions & 26 deletions packages/ui/src/tool-activity.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -464,13 +463,15 @@ const TROW_KIND_ICON: Record<TrowActivityKind, ComponentType<LucideProps>> = {
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]';

/**
Expand All@@ -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.
Expand All@@ -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 (
<Collapsible className="flex flex-col" data-trow="group" data-settled={settled ? 'true' : undefined} open={disclosure.open} onOpenChange={disclosure.setOpen}>
Expand DownExpand Up@@ -546,35 +560,33 @@ 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
// user-language phrase, never the old status-dot + mono tool-name + status
// 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 (
<Collapsible className="flex flex-col" data-trow="row" data-status={item.status} data-settled={motion.settled ? 'true' : undefined} open={disclosure.open} onOpenChange={disclosure.setOpen}>
<Collapsible className="flex flex-col" data-trow="row" data-status={item.status} data-settled={settled ? 'true' : undefined} open={disclosure.open} onOpenChange={disclosure.setOpen}>
<CollapsibleTrigger className="group flex w-full items-center gap-2 py-0.5 text-left">
<RowIcon
size={16}
aria-hidden="true"
className={cn('shrink-0', errored ? 'text-[color:var(--destructive)]' : 'text-[color:var(--muted-foreground)]')}
/>
{motion.shimmer ? (
{running ? (
<TextShimmer active delayed className="min-w-0 truncate text-[length:var(--font-size-base)]">{presentation.summary}</TextShimmer>
) : item.intent ? (
<span className={cn('min-w-0 truncate text-[length:var(--font-size-base)]', summaryTone, settleFade)}>{formatToolIntent(item.intent)}</span>
<span className={cn('min-w-0 truncate text-[length:var(--font-size-base)]', summaryTone)}>{formatToolIntent(item.intent)}</span>
) : (
<span className={cn('min-w-0 truncate text-[length:var(--font-size-base)]', summaryTone, settleFade)}>{resolveToolDisplayName(item)}</span>
<span className={cn('min-w-0 truncate text-[length:var(--font-size-base)]', summaryTone)}>{resolveToolDisplayName(item)}</span>
)}
{/* Quiet meta sits right after the label (near the text, not pinned to
the far edge): duration + chevron ride in on hover / open, matching
Expand Down
41 changes: 9 additions & 32 deletions packages/ui/src/tool-activity/tool-row-motion.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,16 +3,15 @@ import { type ToolActivityItem } from '../materialize.js';
type ToolStatus = ToolActivityItem['status'];

/**
* The run→done seam (#646). A tool row is visible the instant it starts, but its
* two motions are gated so history stays quiet and sub-second tools never flicker:
* The run→done seam (#646 + #tool-jitter). A tool row is visible the instant it
* starts; running statuses shimmer (the light band via `TextShimmer delayed`),
* and the row settles by that band stopping — the same seam as the 深度思考
* disclosure title, with no opacity fade so parallel tools finishing together
* don't stack N fades.
*
* - `shimmer` — the working light-band sweeps the label while the tool is
* in flight. The ~200ms de-flicker delay is CSS (`animation-delay` on the
* sweep, see `TextShimmer delayed`), so a tool that settles inside the window
* unmounts mid-delay and never visibly sweeps — no logic needed here.
* - `settling` — the one-shot "landing" fade only plays for a row that was seen
* running in THIS view and just settled, never for a replayed transcript's rows
* (mounted already terminal). The caller tracks `everRunning` with a ref.
* The ~200ms de-flicker before the sweep starts is CSS (`animation-delay` on
* `TextShimmer delayed`), so a sub-second tool that settles inside the window
* unmounts mid-delay and never visibly sweeps — no logic needed here.
*/

/**
Expand All@@ -28,26 +27,4 @@ export function isToolRowRunning(status: ToolStatus): boolean {
/** Terminal statuses — the row has landed on a result (success, error, or interrupt). */
export function isToolRowSettled(status: ToolStatus): boolean {
return status === 'completed' || status === 'errored' || status === 'interrupted';
}

export interface ToolRowMotion {
/** Shimmer the working label (the delay that de-flickers sub-second tools is CSS). */
shimmer: boolean;
/** The row is on a terminal result. */
settled: boolean;
/**
* Settled *after being seen running in this view* — plays the one-shot settle
* fade. False for a replayed transcript's rows (mounted already terminal), so a
* loaded session's tool history stays static instead of fading in on scroll.
*/
settling: boolean;
}

export function deriveToolRowMotion(input: { status: ToolStatus; everRunning: boolean }): ToolRowMotion {
const settled = isToolRowSettled(input.status);
return {
shimmer: isToolRowRunning(input.status),
settled,
settling: settled && input.everRunning,
};
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 0 additions & 25 deletions apps/desktop/src/main/__tests__/tool-row-motion.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,6 @@ import { describe, it } from 'node:test';
import {
isToolRowRunning,
isToolRowSettled,
deriveToolRowMotion,
type ToolActivityItem,
} from '@maka/ui';

Expand All@@ -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`,
);
}
});
});
14 changes: 3 additions & 11 deletions apps/desktop/src/main/__tests__/trow-summary.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,6 @@
import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
import {
activeTrowTool,
isTrowRunning,
summarizeTrowTools,
trowActivityKind,
Expand DownExpand Up@@ -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');
});
});

Expand Down
79 changes: 79 additions & 0 deletions packages/ui/src/__tests__/tool-trow-summary.test.ts
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
8 changes: 3 additions & 5 deletions packages/ui/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
64 changes: 38 additions & 26 deletions packages/ui/src/tool-activity.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -464,13 +463,15 @@ const TROW_KIND_ICON: Record<TrowActivityKind, ComponentType<LucideProps>> = {
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]';

/**
Expand All@@ -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.
Expand All@@ -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 (
<Collapsible className="flex flex-col" data-trow="group" data-settled={settled ? 'true' : undefined} open={disclosure.open} onOpenChange={disclosure.setOpen}>
Expand DownExpand Up@@ -546,35 +560,33 @@ 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
// user-language phrase, never the old status-dot + mono tool-name + status
// 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 (
<Collapsible className="flex flex-col" data-trow="row" data-status={item.status} data-settled={motion.settled ? 'true' : undefined} open={disclosure.open} onOpenChange={disclosure.setOpen}>
<Collapsible className="flex flex-col" data-trow="row" data-status={item.status} data-settled={settled ? 'true' : undefined} open={disclosure.open} onOpenChange={disclosure.setOpen}>
<CollapsibleTrigger className="group flex w-full items-center gap-2 py-0.5 text-left">
<RowIcon
size={16}
aria-hidden="true"
className={cn('shrink-0', errored ? 'text-[color:var(--destructive)]' : 'text-[color:var(--muted-foreground)]')}
/>
{motion.shimmer ? (
{running ? (
<TextShimmer active delayed className="min-w-0 truncate text-[length:var(--font-size-base)]">{presentation.summary}</TextShimmer>
) : item.intent ? (
<span className={cn('min-w-0 truncate text-[length:var(--font-size-base)]', summaryTone, settleFade)}>{formatToolIntent(item.intent)}</span>
<span className={cn('min-w-0 truncate text-[length:var(--font-size-base)]', summaryTone)}>{formatToolIntent(item.intent)}</span>
) : (
<span className={cn('min-w-0 truncate text-[length:var(--font-size-base)]', summaryTone, settleFade)}>{resolveToolDisplayName(item)}</span>
<span className={cn('min-w-0 truncate text-[length:var(--font-size-base)]', summaryTone)}>{resolveToolDisplayName(item)}</span>
)}
{/* Quiet meta sits right after the label (near the text, not pinned to
the far edge): duration + chevron ride in on hover / open, matching
Expand Down
41 changes: 9 additions & 32 deletions packages/ui/src/tool-activity/tool-row-motion.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,16 +3,15 @@ import { type ToolActivityItem } from '../materialize.js';
type ToolStatus = ToolActivityItem['status'];

/**
* The run→done seam (#646). A tool row is visible the instant it starts, but its
* two motions are gated so history stays quiet and sub-second tools never flicker:
* The run→done seam (#646 + #tool-jitter). A tool row is visible the instant it
* starts; running statuses shimmer (the light band via `TextShimmer delayed`),
* and the row settles by that band stopping — the same seam as the 深度思考
* disclosure title, with no opacity fade so parallel tools finishing together
* don't stack N fades.
*
* - `shimmer` — the working light-band sweeps the label while the tool is
* in flight. The ~200ms de-flicker delay is CSS (`animation-delay` on the
* sweep, see `TextShimmer delayed`), so a tool that settles inside the window
* unmounts mid-delay and never visibly sweeps — no logic needed here.
* - `settling` — the one-shot "landing" fade only plays for a row that was seen
* running in THIS view and just settled, never for a replayed transcript's rows
* (mounted already terminal). The caller tracks `everRunning` with a ref.
* The ~200ms de-flicker before the sweep starts is CSS (`animation-delay` on
* `TextShimmer delayed`), so a sub-second tool that settles inside the window
* unmounts mid-delay and never visibly sweeps — no logic needed here.
*/

/**
Expand All@@ -28,26 +27,4 @@ export function isToolRowRunning(status: ToolStatus): boolean {
/** Terminal statuses — the row has landed on a result (success, error, or interrupt). */
export function isToolRowSettled(status: ToolStatus): boolean {
return status === 'completed' || status === 'errored' || status === 'interrupted';
}

export interface ToolRowMotion {
/** Shimmer the working label (the delay that de-flickers sub-second tools is CSS). */
shimmer: boolean;
/** The row is on a terminal result. */
settled: boolean;
/**
* Settled *after being seen running in this view* — plays the one-shot settle
* fade. False for a replayed transcript's rows (mounted already terminal), so a
* loaded session's tool history stays static instead of fading in on scroll.
*/
settling: boolean;
}

export function deriveToolRowMotion(input: { status: ToolStatus; everRunning: boolean }): ToolRowMotion {
const settled = isToolRowSettled(input.status);
return {
shimmer: isToolRowRunning(input.status),
settled,
settling: settled && input.everRunning,
};
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 0 additions & 25 deletions apps/desktop/src/main/__tests__/tool-row-motion.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,6 @@ import { describe, it } from 'node:test';
import {
isToolRowRunning,
isToolRowSettled,
deriveToolRowMotion,
type ToolActivityItem,
} from '@maka/ui';

Expand All@@ -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`,
);
}
});
});
14 changes: 3 additions & 11 deletions apps/desktop/src/main/__tests__/trow-summary.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,6 @@
import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
import {
activeTrowTool,
isTrowRunning,
summarizeTrowTools,
trowActivityKind,
Expand DownExpand Up@@ -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');
});
});

Expand Down
79 changes: 79 additions & 0 deletions packages/ui/src/__tests__/tool-trow-summary.test.ts
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
8 changes: 3 additions & 5 deletions packages/ui/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
64 changes: 38 additions & 26 deletions packages/ui/src/tool-activity.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -464,13 +463,15 @@ const TROW_KIND_ICON: Record<TrowActivityKind, ComponentType<LucideProps>> = {
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]';

/**
Expand All@@ -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.
Expand All@@ -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 (
<Collapsible className="flex flex-col" data-trow="group" data-settled={settled ? 'true' : undefined} open={disclosure.open} onOpenChange={disclosure.setOpen}>
Expand DownExpand Up@@ -546,35 +560,33 @@ 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
// user-language phrase, never the old status-dot + mono tool-name + status
// 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 (
<Collapsible className="flex flex-col" data-trow="row" data-status={item.status} data-settled={motion.settled ? 'true' : undefined} open={disclosure.open} onOpenChange={disclosure.setOpen}>
<Collapsible className="flex flex-col" data-trow="row" data-status={item.status} data-settled={settled ? 'true' : undefined} open={disclosure.open} onOpenChange={disclosure.setOpen}>
<CollapsibleTrigger className="group flex w-full items-center gap-2 py-0.5 text-left">
<RowIcon
size={16}
aria-hidden="true"
className={cn('shrink-0', errored ? 'text-[color:var(--destructive)]' : 'text-[color:var(--muted-foreground)]')}
/>
{motion.shimmer ? (
{running ? (
<TextShimmer active delayed className="min-w-0 truncate text-[length:var(--font-size-base)]">{presentation.summary}</TextShimmer>
) : item.intent ? (
<span className={cn('min-w-0 truncate text-[length:var(--font-size-base)]', summaryTone, settleFade)}>{formatToolIntent(item.intent)}</span>
<span className={cn('min-w-0 truncate text-[length:var(--font-size-base)]', summaryTone)}>{formatToolIntent(item.intent)}</span>
) : (
<span className={cn('min-w-0 truncate text-[length:var(--font-size-base)]', summaryTone, settleFade)}>{resolveToolDisplayName(item)}</span>
<span className={cn('min-w-0 truncate text-[length:var(--font-size-base)]', summaryTone)}>{resolveToolDisplayName(item)}</span>
)}
{/* Quiet meta sits right after the label (near the text, not pinned to
the far edge): duration + chevron ride in on hover / open, matching
Expand Down
41 changes: 9 additions & 32 deletions packages/ui/src/tool-activity/tool-row-motion.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,16 +3,15 @@ import { type ToolActivityItem } from '../materialize.js';
type ToolStatus = ToolActivityItem['status'];

/**
* The run→done seam (#646). A tool row is visible the instant it starts, but its
* two motions are gated so history stays quiet and sub-second tools never flicker:
* The run→done seam (#646 + #tool-jitter). A tool row is visible the instant it
* starts; running statuses shimmer (the light band via `TextShimmer delayed`),
* and the row settles by that band stopping — the same seam as the 深度思考
* disclosure title, with no opacity fade so parallel tools finishing together
* don't stack N fades.
*
* - `shimmer` — the working light-band sweeps the label while the tool is
* in flight. The ~200ms de-flicker delay is CSS (`animation-delay` on the
* sweep, see `TextShimmer delayed`), so a tool that settles inside the window
* unmounts mid-delay and never visibly sweeps — no logic needed here.
* - `settling` — the one-shot "landing" fade only plays for a row that was seen
* running in THIS view and just settled, never for a replayed transcript's rows
* (mounted already terminal). The caller tracks `everRunning` with a ref.
* The ~200ms de-flicker before the sweep starts is CSS (`animation-delay` on
* `TextShimmer delayed`), so a sub-second tool that settles inside the window
* unmounts mid-delay and never visibly sweeps — no logic needed here.
*/

/**
Expand All@@ -28,26 +27,4 @@ export function isToolRowRunning(status: ToolStatus): boolean {
/** Terminal statuses — the row has landed on a result (success, error, or interrupt). */
export function isToolRowSettled(status: ToolStatus): boolean {
return status === 'completed' || status === 'errored' || status === 'interrupted';
}

export interface ToolRowMotion {
/** Shimmer the working label (the delay that de-flickers sub-second tools is CSS). */
shimmer: boolean;
/** The row is on a terminal result. */
settled: boolean;
/**
* Settled *after being seen running in this view* — plays the one-shot settle
* fade. False for a replayed transcript's rows (mounted already terminal), so a
* loaded session's tool history stays static instead of fading in on scroll.
*/
settling: boolean;
}

export function deriveToolRowMotion(input: { status: ToolStatus; everRunning: boolean }): ToolRowMotion {
const settled = isToolRowSettled(input.status);
return {
shimmer: isToolRowRunning(input.status),
settled,
settling: settled && input.everRunning,
};
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 0 additions & 25 deletions apps/desktop/src/main/__tests__/tool-row-motion.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,6 @@ import { describe, it } from 'node:test';
import {
isToolRowRunning,
isToolRowSettled,
deriveToolRowMotion,
type ToolActivityItem,
} from '@maka/ui';

Expand All@@ -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`,
);
}
});
});
14 changes: 3 additions & 11 deletions apps/desktop/src/main/__tests__/trow-summary.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,6 @@
import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
import {
activeTrowTool,
isTrowRunning,
summarizeTrowTools,
trowActivityKind,
Expand DownExpand Up@@ -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');
});
});

Expand Down
79 changes: 79 additions & 0 deletions packages/ui/src/__tests__/tool-trow-summary.test.ts
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
8 changes: 3 additions & 5 deletions packages/ui/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
64 changes: 38 additions & 26 deletions packages/ui/src/tool-activity.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -464,13 +463,15 @@ const TROW_KIND_ICON: Record<TrowActivityKind, ComponentType<LucideProps>> = {
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]';

/**
Expand All@@ -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.
Expand All@@ -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 (
<Collapsible className="flex flex-col" data-trow="group" data-settled={settled ? 'true' : undefined} open={disclosure.open} onOpenChange={disclosure.setOpen}>
Expand DownExpand Up@@ -546,35 +560,33 @@ 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
// user-language phrase, never the old status-dot + mono tool-name + status
// 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 (
<Collapsible className="flex flex-col" data-trow="row" data-status={item.status} data-settled={motion.settled ? 'true' : undefined} open={disclosure.open} onOpenChange={disclosure.setOpen}>
<Collapsible className="flex flex-col" data-trow="row" data-status={item.status} data-settled={settled ? 'true' : undefined} open={disclosure.open} onOpenChange={disclosure.setOpen}>
<CollapsibleTrigger className="group flex w-full items-center gap-2 py-0.5 text-left">
<RowIcon
size={16}
aria-hidden="true"
className={cn('shrink-0', errored ? 'text-[color:var(--destructive)]' : 'text-[color:var(--muted-foreground)]')}
/>
{motion.shimmer ? (
{running ? (
<TextShimmer active delayed className="min-w-0 truncate text-[length:var(--font-size-base)]">{presentation.summary}</TextShimmer>
) : item.intent ? (
<span className={cn('min-w-0 truncate text-[length:var(--font-size-base)]', summaryTone, settleFade)}>{formatToolIntent(item.intent)}</span>
<span className={cn('min-w-0 truncate text-[length:var(--font-size-base)]', summaryTone)}>{formatToolIntent(item.intent)}</span>
) : (
<span className={cn('min-w-0 truncate text-[length:var(--font-size-base)]', summaryTone, settleFade)}>{resolveToolDisplayName(item)}</span>
<span className={cn('min-w-0 truncate text-[length:var(--font-size-base)]', summaryTone)}>{resolveToolDisplayName(item)}</span>
)}
{/* Quiet meta sits right after the label (near the text, not pinned to
the far edge): duration + chevron ride in on hover / open, matching
Expand Down
41 changes: 9 additions & 32 deletions packages/ui/src/tool-activity/tool-row-motion.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,16 +3,15 @@ import { type ToolActivityItem } from '../materialize.js';
type ToolStatus = ToolActivityItem['status'];

/**
* The run→done seam (#646). A tool row is visible the instant it starts, but its
* two motions are gated so history stays quiet and sub-second tools never flicker:
* The run→done seam (#646 + #tool-jitter). A tool row is visible the instant it
* starts; running statuses shimmer (the light band via `TextShimmer delayed`),
* and the row settles by that band stopping — the same seam as the 深度思考
* disclosure title, with no opacity fade so parallel tools finishing together
* don't stack N fades.
*
* - `shimmer` — the working light-band sweeps the label while the tool is
* in flight. The ~200ms de-flicker delay is CSS (`animation-delay` on the
* sweep, see `TextShimmer delayed`), so a tool that settles inside the window
* unmounts mid-delay and never visibly sweeps — no logic needed here.
* - `settling` — the one-shot "landing" fade only plays for a row that was seen
* running in THIS view and just settled, never for a replayed transcript's rows
* (mounted already terminal). The caller tracks `everRunning` with a ref.
* The ~200ms de-flicker before the sweep starts is CSS (`animation-delay` on
* `TextShimmer delayed`), so a sub-second tool that settles inside the window
* unmounts mid-delay and never visibly sweeps — no logic needed here.
*/

/**
Expand All@@ -28,26 +27,4 @@ export function isToolRowRunning(status: ToolStatus): boolean {
/** Terminal statuses — the row has landed on a result (success, error, or interrupt). */
export function isToolRowSettled(status: ToolStatus): boolean {
return status === 'completed' || status === 'errored' || status === 'interrupted';
}

export interface ToolRowMotion {
/** Shimmer the working label (the delay that de-flickers sub-second tools is CSS). */
shimmer: boolean;
/** The row is on a terminal result. */
settled: boolean;
/**
* Settled *after being seen running in this view* — plays the one-shot settle
* fade. False for a replayed transcript's rows (mounted already terminal), so a
* loaded session's tool history stays static instead of fading in on scroll.
*/
settling: boolean;
}

export function deriveToolRowMotion(input: { status: ToolStatus; everRunning: boolean }): ToolRowMotion {
const settled = isToolRowSettled(input.status);
return {
shimmer: isToolRowRunning(input.status),
settled,
settling: settled && input.everRunning,
};
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 0 additions & 25 deletions apps/desktop/src/main/__tests__/tool-row-motion.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,6 @@ import { describe, it } from 'node:test';
import {
isToolRowRunning,
isToolRowSettled,
deriveToolRowMotion,
type ToolActivityItem,
} from '@maka/ui';

Expand All@@ -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`,
);
}
});
});
14 changes: 3 additions & 11 deletions apps/desktop/src/main/__tests__/trow-summary.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,6 @@
import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
import {
activeTrowTool,
isTrowRunning,
summarizeTrowTools,
trowActivityKind,
Expand DownExpand Up@@ -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');
});
});

Expand Down
79 changes: 79 additions & 0 deletions packages/ui/src/__tests__/tool-trow-summary.test.ts
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
8 changes: 3 additions & 5 deletions packages/ui/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
64 changes: 38 additions & 26 deletions packages/ui/src/tool-activity.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -464,13 +463,15 @@ const TROW_KIND_ICON: Record<TrowActivityKind, ComponentType<LucideProps>> = {
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]';

/**
Expand All@@ -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.
Expand All@@ -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 (
<Collapsible className="flex flex-col" data-trow="group" data-settled={settled ? 'true' : undefined} open={disclosure.open} onOpenChange={disclosure.setOpen}>
Expand DownExpand Up@@ -546,35 +560,33 @@ 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
// user-language phrase, never the old status-dot + mono tool-name + status
// 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 (
<Collapsible className="flex flex-col" data-trow="row" data-status={item.status} data-settled={motion.settled ? 'true' : undefined} open={disclosure.open} onOpenChange={disclosure.setOpen}>
<Collapsible className="flex flex-col" data-trow="row" data-status={item.status} data-settled={settled ? 'true' : undefined} open={disclosure.open} onOpenChange={disclosure.setOpen}>
<CollapsibleTrigger className="group flex w-full items-center gap-2 py-0.5 text-left">
<RowIcon
size={16}
aria-hidden="true"
className={cn('shrink-0', errored ? 'text-[color:var(--destructive)]' : 'text-[color:var(--muted-foreground)]')}
/>
{motion.shimmer ? (
{running ? (
<TextShimmer active delayed className="min-w-0 truncate text-[length:var(--font-size-base)]">{presentation.summary}</TextShimmer>
) : item.intent ? (
<span className={cn('min-w-0 truncate text-[length:var(--font-size-base)]', summaryTone, settleFade)}>{formatToolIntent(item.intent)}</span>
<span className={cn('min-w-0 truncate text-[length:var(--font-size-base)]', summaryTone)}>{formatToolIntent(item.intent)}</span>
) : (
<span className={cn('min-w-0 truncate text-[length:var(--font-size-base)]', summaryTone, settleFade)}>{resolveToolDisplayName(item)}</span>
<span className={cn('min-w-0 truncate text-[length:var(--font-size-base)]', summaryTone)}>{resolveToolDisplayName(item)}</span>
)}
{/* Quiet meta sits right after the label (near the text, not pinned to
the far edge): duration + chevron ride in on hover / open, matching
Expand Down
41 changes: 9 additions & 32 deletions packages/ui/src/tool-activity/tool-row-motion.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,16 +3,15 @@ import { type ToolActivityItem } from '../materialize.js';
type ToolStatus = ToolActivityItem['status'];

/**
* The run→done seam (#646). A tool row is visible the instant it starts, but its
* two motions are gated so history stays quiet and sub-second tools never flicker:
* The run→done seam (#646 + #tool-jitter). A tool row is visible the instant it
* starts; running statuses shimmer (the light band via `TextShimmer delayed`),
* and the row settles by that band stopping — the same seam as the 深度思考
* disclosure title, with no opacity fade so parallel tools finishing together
* don't stack N fades.
*
* - `shimmer` — the working light-band sweeps the label while the tool is
* in flight. The ~200ms de-flicker delay is CSS (`animation-delay` on the
* sweep, see `TextShimmer delayed`), so a tool that settles inside the window
* unmounts mid-delay and never visibly sweeps — no logic needed here.
* - `settling` — the one-shot "landing" fade only plays for a row that was seen
* running in THIS view and just settled, never for a replayed transcript's rows
* (mounted already terminal). The caller tracks `everRunning` with a ref.
* The ~200ms de-flicker before the sweep starts is CSS (`animation-delay` on
* `TextShimmer delayed`), so a sub-second tool that settles inside the window
* unmounts mid-delay and never visibly sweeps — no logic needed here.
*/

/**
Expand All@@ -28,26 +27,4 @@ export function isToolRowRunning(status: ToolStatus): boolean {
/** Terminal statuses — the row has landed on a result (success, error, or interrupt). */
export function isToolRowSettled(status: ToolStatus): boolean {
return status === 'completed' || status === 'errored' || status === 'interrupted';
}

export interface ToolRowMotion {
/** Shimmer the working label (the delay that de-flickers sub-second tools is CSS). */
shimmer: boolean;
/** The row is on a terminal result. */
settled: boolean;
/**
* Settled *after being seen running in this view* — plays the one-shot settle
* fade. False for a replayed transcript's rows (mounted already terminal), so a
* loaded session's tool history stays static instead of fading in on scroll.
*/
settling: boolean;
}

export function deriveToolRowMotion(input: { status: ToolStatus; everRunning: boolean }): ToolRowMotion {
const settled = isToolRowSettled(input.status);
return {
shimmer: isToolRowRunning(input.status),
settled,
settling: settled && input.everRunning,
};
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 0 additions & 25 deletions apps/desktop/src/main/__tests__/tool-row-motion.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,6 @@ import { describe, it } from 'node:test';
import {
isToolRowRunning,
isToolRowSettled,
deriveToolRowMotion,
type ToolActivityItem,
} from '@maka/ui';

Expand All@@ -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`,
);
}
});
});
14 changes: 3 additions & 11 deletions apps/desktop/src/main/__tests__/trow-summary.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,6 @@
import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
import {
activeTrowTool,
isTrowRunning,
summarizeTrowTools,
trowActivityKind,
Expand DownExpand Up@@ -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');
});
});

Expand Down
79 changes: 79 additions & 0 deletions packages/ui/src/__tests__/tool-trow-summary.test.ts
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
8 changes: 3 additions & 5 deletions packages/ui/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
64 changes: 38 additions & 26 deletions packages/ui/src/tool-activity.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -464,13 +463,15 @@ const TROW_KIND_ICON: Record<TrowActivityKind, ComponentType<LucideProps>> = {
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]';

/**
Expand All@@ -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.
Expand All@@ -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 (
<Collapsible className="flex flex-col" data-trow="group" data-settled={settled ? 'true' : undefined} open={disclosure.open} onOpenChange={disclosure.setOpen}>
Expand DownExpand Up@@ -546,35 +560,33 @@ 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
// user-language phrase, never the old status-dot + mono tool-name + status
// 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 (
<Collapsible className="flex flex-col" data-trow="row" data-status={item.status} data-settled={motion.settled ? 'true' : undefined} open={disclosure.open} onOpenChange={disclosure.setOpen}>
<Collapsible className="flex flex-col" data-trow="row" data-status={item.status} data-settled={settled ? 'true' : undefined} open={disclosure.open} onOpenChange={disclosure.setOpen}>
<CollapsibleTrigger className="group flex w-full items-center gap-2 py-0.5 text-left">
<RowIcon
size={16}
aria-hidden="true"
className={cn('shrink-0', errored ? 'text-[color:var(--destructive)]' : 'text-[color:var(--muted-foreground)]')}
/>
{motion.shimmer ? (
{running ? (
<TextShimmer active delayed className="min-w-0 truncate text-[length:var(--font-size-base)]">{presentation.summary}</TextShimmer>
) : item.intent ? (
<span className={cn('min-w-0 truncate text-[length:var(--font-size-base)]', summaryTone, settleFade)}>{formatToolIntent(item.intent)}</span>
<span className={cn('min-w-0 truncate text-[length:var(--font-size-base)]', summaryTone)}>{formatToolIntent(item.intent)}</span>
) : (
<span className={cn('min-w-0 truncate text-[length:var(--font-size-base)]', summaryTone, settleFade)}>{resolveToolDisplayName(item)}</span>
<span className={cn('min-w-0 truncate text-[length:var(--font-size-base)]', summaryTone)}>{resolveToolDisplayName(item)}</span>
)}
{/* Quiet meta sits right after the label (near the text, not pinned to
the far edge): duration + chevron ride in on hover / open, matching
Expand Down
41 changes: 9 additions & 32 deletions packages/ui/src/tool-activity/tool-row-motion.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,16 +3,15 @@ import { type ToolActivityItem } from '../materialize.js';
type ToolStatus = ToolActivityItem['status'];

/**
* The run→done seam (#646). A tool row is visible the instant it starts, but its
* two motions are gated so history stays quiet and sub-second tools never flicker:
* The run→done seam (#646 + #tool-jitter). A tool row is visible the instant it
* starts; running statuses shimmer (the light band via `TextShimmer delayed`),
* and the row settles by that band stopping — the same seam as the 深度思考
* disclosure title, with no opacity fade so parallel tools finishing together
* don't stack N fades.
*
* - `shimmer` — the working light-band sweeps the label while the tool is
* in flight. The ~200ms de-flicker delay is CSS (`animation-delay` on the
* sweep, see `TextShimmer delayed`), so a tool that settles inside the window
* unmounts mid-delay and never visibly sweeps — no logic needed here.
* - `settling` — the one-shot "landing" fade only plays for a row that was seen
* running in THIS view and just settled, never for a replayed transcript's rows
* (mounted already terminal). The caller tracks `everRunning` with a ref.
* The ~200ms de-flicker before the sweep starts is CSS (`animation-delay` on
* `TextShimmer delayed`), so a sub-second tool that settles inside the window
* unmounts mid-delay and never visibly sweeps — no logic needed here.
*/

/**
Expand All@@ -28,26 +27,4 @@ export function isToolRowRunning(status: ToolStatus): boolean {
/** Terminal statuses — the row has landed on a result (success, error, or interrupt). */
export function isToolRowSettled(status: ToolStatus): boolean {
return status === 'completed' || status === 'errored' || status === 'interrupted';
}

export interface ToolRowMotion {
/** Shimmer the working label (the delay that de-flickers sub-second tools is CSS). */
shimmer: boolean;
/** The row is on a terminal result. */
settled: boolean;
/**
* Settled *after being seen running in this view* — plays the one-shot settle
* fade. False for a replayed transcript's rows (mounted already terminal), so a
* loaded session's tool history stays static instead of fading in on scroll.
*/
settling: boolean;
}

export function deriveToolRowMotion(input: { status: ToolStatus; everRunning: boolean }): ToolRowMotion {
const settled = isToolRowSettled(input.status);
return {
shimmer: isToolRowRunning(input.status),
settled,
settling: settled && input.everRunning,
};
}
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 0 additions & 25 deletions apps/desktop/src/main/__tests__/tool-row-motion.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,6 @@ import { describe, it } from 'node:test';
import {
isToolRowRunning,
isToolRowSettled,
deriveToolRowMotion,
type ToolActivityItem,
} from '@maka/ui';

Expand All@@ -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`,
);
}
});
});
14 changes: 3 additions & 11 deletions apps/desktop/src/main/__tests__/trow-summary.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,6 @@
import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
import {
activeTrowTool,
isTrowRunning,
summarizeTrowTools,
trowActivityKind,
Expand DownExpand Up@@ -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');
});
});

Expand Down
79 changes: 79 additions & 0 deletions packages/ui/src/__tests__/tool-trow-summary.test.ts
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
8 changes: 3 additions & 5 deletions packages/ui/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
64 changes: 38 additions & 26 deletions packages/ui/src/tool-activity.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -464,13 +463,15 @@ const TROW_KIND_ICON: Record<TrowActivityKind, ComponentType<LucideProps>> = {
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]';

/**
Expand All@@ -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.
Expand All@@ -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 (
<Collapsible className="flex flex-col" data-trow="group" data-settled={settled ? 'true' : undefined} open={disclosure.open} onOpenChange={disclosure.setOpen}>
Expand DownExpand Up@@ -546,35 +560,33 @@ 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
// user-language phrase, never the old status-dot + mono tool-name + status
// 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 (
<Collapsible className="flex flex-col" data-trow="row" data-status={item.status} data-settled={motion.settled ? 'true' : undefined} open={disclosure.open} onOpenChange={disclosure.setOpen}>
<Collapsible className="flex flex-col" data-trow="row" data-status={item.status} data-settled={settled ? 'true' : undefined} open={disclosure.open} onOpenChange={disclosure.setOpen}>
<CollapsibleTrigger className="group flex w-full items-center gap-2 py-0.5 text-left">
<RowIcon
size={16}
aria-hidden="true"
className={cn('shrink-0', errored ? 'text-[color:var(--destructive)]' : 'text-[color:var(--muted-foreground)]')}
/>
{motion.shimmer ? (
{running ? (
<TextShimmer active delayed className="min-w-0 truncate text-[length:var(--font-size-base)]">{presentation.summary}</TextShimmer>
) : item.intent ? (
<span className={cn('min-w-0 truncate text-[length:var(--font-size-base)]', summaryTone, settleFade)}>{formatToolIntent(item.intent)}</span>
<span className={cn('min-w-0 truncate text-[length:var(--font-size-base)]', summaryTone)}>{formatToolIntent(item.intent)}</span>
) : (
<span className={cn('min-w-0 truncate text-[length:var(--font-size-base)]', summaryTone, settleFade)}>{resolveToolDisplayName(item)}</span>
<span className={cn('min-w-0 truncate text-[length:var(--font-size-base)]', summaryTone)}>{resolveToolDisplayName(item)}</span>
)}
{/* Quiet meta sits right after the label (near the text, not pinned to
the far edge): duration + chevron ride in on hover / open, matching
Expand Down
41 changes: 9 additions & 32 deletions packages/ui/src/tool-activity/tool-row-motion.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,16 +3,15 @@ import { type ToolActivityItem } from '../materialize.js';
type ToolStatus = ToolActivityItem['status'];

/**
* The run→done seam (#646). A tool row is visible the instant it starts, but its
* two motions are gated so history stays quiet and sub-second tools never flicker:
* The run→done seam (#646 + #tool-jitter). A tool row is visible the instant it
* starts; running statuses shimmer (the light band via `TextShimmer delayed`),
* and the row settles by that band stopping — the same seam as the 深度思考
* disclosure title, with no opacity fade so parallel tools finishing together
* don't stack N fades.
*
* - `shimmer` — the working light-band sweeps the label while the tool is
* in flight. The ~200ms de-flicker delay is CSS (`animation-delay` on the
* sweep, see `TextShimmer delayed`), so a tool that settles inside the window
* unmounts mid-delay and never visibly sweeps — no logic needed here.
* - `settling` — the one-shot "landing" fade only plays for a row that was seen
* running in THIS view and just settled, never for a replayed transcript's rows
* (mounted already terminal). The caller tracks `everRunning` with a ref.
* The ~200ms de-flicker before the sweep starts is CSS (`animation-delay` on
* `TextShimmer delayed`), so a sub-second tool that settles inside the window
* unmounts mid-delay and never visibly sweeps — no logic needed here.
*/

/**
Expand All@@ -28,26 +27,4 @@ export function isToolRowRunning(status: ToolStatus): boolean {
/** Terminal statuses — the row has landed on a result (success, error, or interrupt). */
export function isToolRowSettled(status: ToolStatus): boolean {
return status === 'completed' || status === 'errored' || status === 'interrupted';
}

export interface ToolRowMotion {
/** Shimmer the working label (the delay that de-flickers sub-second tools is CSS). */
shimmer: boolean;
/** The row is on a terminal result. */
settled: boolean;
/**
* Settled *after being seen running in this view* — plays the one-shot settle
* fade. False for a replayed transcript's rows (mounted already terminal), so a
* loaded session's tool history stays static instead of fading in on scroll.
*/
settling: boolean;
}

export function deriveToolRowMotion(input: { status: ToolStatus; everRunning: boolean }): ToolRowMotion {
const settled = isToolRowSettled(input.status);
return {
shimmer: isToolRowRunning(input.status),
settled,
settling: settled && input.everRunning,
};
}
}
Loading
Loading