Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
446716d
fix(ui): keep tool diagnostics collapsed by default
Astro-Han Jul 10, 2026
70cc0f7
feat(core): carry semantic tool activity kinds
Astro-Han Jul 10, 2026
c9fa0e9
fix(ui): preserve manual tool disclosure
Astro-Han Jul 10, 2026
7f7647d
test(ui): align tool visual regression fixture
Astro-Han Jul 10, 2026
e7f0cfc
refactor(ui): unify tool output into one quiet panel
Astro-Han Jul 10, 2026
37c39cf
fix(ui): size deep-thinking body at font-size-base
Astro-Han Jul 10, 2026
47e9bc5
fix(ui): present Read/Grep results as quiet text, not JSON
Astro-Han Jul 10, 2026
2020f8a
fix(ui): stop dumping JSON for all tool args and results
Astro-Han Jul 10, 2026
43bb01d
fix(ui): redact quiet-panel keys and keep matching headlines
Astro-Han Jul 10, 2026
e4f507a
test: align contracts and visual harness with quiet tool panel
Astro-Han Jul 10, 2026
6ebefa6
fix(ui): keep quiet previews faithful, redacted, and shell_run-aware
Astro-Han Jul 10, 2026
59d203f
fix(ui): avoid nested shell_run panels and secret-bearing keys
Astro-Han Jul 10, 2026
9671ce7
fix(ui): close residual quiet-panel cancel, live, and redaction gaps
Astro-Han Jul 10, 2026
f9d6c15
fix(ui): close residual quiet-panel secrecy, cancel, and chrome gaps
Astro-Han Jul 10, 2026
17656bc
fix(ui): honor isError for cancel status and empty live meta
Astro-Han Jul 10, 2026
ec2cd5d
fix(ui): close multi-word key redaction and empty shell_run live handoff
Astro-Han Jul 10, 2026
f444842
fix(ui): keep tool evidence across text settle without locking footer
Astro-Han Jul 11, 2026
5500923
fix(ui): let terminal live evidence outrank wait indicators
Astro-Han Jul 11, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,10 @@ function renderAutomationResult(text: string): string {
const item: ToolActivityItem = {
toolUseId: 'tu-1',
toolName: AUTOMATION_TOOL_NAME,
// 'running' keeps the collapsible card open by default so the static
// markup includes the body (a settled card collapses and Base UI unmounts
// closed panel content — nothing to assert on).
status: 'running',
// This test exercises the result parser, not disclosure defaults. Use an
// attention state so Base UI mounts the panel in static markup; ordinary
// running tools now stay collapsed until the user asks for diagnostics.
status: 'errored',
args: { mode: 'create' },
result: { kind: 'text', text },
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,19 @@ describe('chat status cluster layout contract', () => {
/const showAssistantMessage = turn\.timeline\.length > 0 \|\| !!props\.liveStreaming;/,
'the assistant Message must mount when the turn has timeline content OR is the live tail',
);
// Terminal liveTurn is evidence-only (empty shell_run chunks). Footer must
// stay actionable — do not treat terminal projection as in-flight stream,
// and do not let lagging wait indicators re-lock the footer over it.
assert.match(
src,
/liveInFlight = !!\(props\.liveTurn && !props\.liveTurn\.terminal\)/,
'only non-terminal liveTurn blocks the footer as streaming',
);
assert.match(
src,
/streamingActive = liveInFlight \|\| \(!props\.liveTurn\?\.terminal && waitIndicators\)/,
'terminal evidence outranks delayed processing/continuing indicators',
);
});

it('uses an in-flow wrapping row instead of absolute positioning', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { REPO_ROOT, TOKENS_FILE, readAllRendererCss, stripCssComments } from './
/**
* Zero-visual governance contract for issue #332 PR3 — the tool live-output
* stream (`ToolOutputStream`) moved onto the `@maka/ui` chat substrate: the
* panel/header/counts/body/chunk shell onto the `streamVariants` literalize
* panel/header/flags/body/chunk shell onto the `streamVariants` literalize
* table, and the pulsing "live" dot onto the governed `LiveIndicator` primitive.
*
* The shell halves of "zero visual change" are locked the same way as PR2: the
Expand All @@ -19,6 +19,34 @@ import { REPO_ROOT, TOKENS_FILE, readAllRendererCss, stripCssComments } from './
* by before/after screenshots rather than the computed-style diff harness.
*/
describe('chat tool-output stream migration contract (#332 PR3)', () => {
it('keeps the computed-style fixture aligned with the quiet tool-output panel', async () => {
const harness = await readFile(
resolve(REPO_ROOT, 'scripts', 'check-chat-marker-computed-style.mjs'),
'utf8',
);

// Stream variants are no longer the production tool body; the quiet panel is.
assert.match(harness, /TOOL_OUTPUT_PANEL_CLASS/);
assert.match(harness, /TOOL_OUTPUT_BODY_CLASS/);
assert.match(harness, /data-slot="tool-output"/);
// Live code must not still import/call streamVariants (comments may mention history).
assert.doesNotMatch(harness, /streamVariants\s*\(/);
assert.doesNotMatch(harness, /from\s+.*primitives\/chat.*streamVariants|streamVariants\s*\}/);
assert.doesNotMatch(harness, /toolOutputPanel[\s\S]*maka-tool-output-stream|sv\s*\(/);
});

it('keeps the computed-style fixture aligned with production disclosure defaults', async () => {
const harness = await readFile(
resolve(REPO_ROOT, 'scripts', 'check-chat-marker-computed-style.mjs'),
'utf8',
);

assert.match(
harness,
/const openByDefault = \(s\) => s === 'waiting_permission' \|\| s === 'errored';/,
);
});

it('retires the bespoke stream shell selectors + the per-feature pulse keyframe', async () => {
const css = stripCssComments(await readAllRendererCss());
for (const selector of [
Expand Down Expand Up @@ -65,8 +93,8 @@ describe('chat tool-output stream migration contract (#332 PR3)', () => {
});

it('pins the live indicator dot — the one part the computed-style diff cannot cover', async () => {
// The stream SHELL (container/header/counts/body/chunk) is proven by the
// computed-style diff harness (38 rows, 0 delta), so this test does NOT
// The stream SHELL (container/header/flags/body/chunk) is proven by the
// computed-style diff harness (0 delta), so this test does NOT
// re-assert those literals — that would just mirror the implementation. The
// dot is the exception: an animation can't be a leaf-literal and
// `getComputedStyle` reads a phase-dependent value, so the diff can't see it.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,18 +56,11 @@ describe('PR-DISCLOSURE-COLLAPSIBLE-0 contract', () => {
}
});

it('tool-activity Collapsible is controlled (open follows item.status), not defaultOpen', async () => {
// A `defaultOpen` card decides open only on first render, so a card that
// defaults open while pending/running would NOT auto-collapse when it
// settles to completed/interrupted — the pre-Collapsible `<details
// open={isOpenByDefault(status)}>` re-evaluated open every render. The
// controlled form (open + onOpenChange, re-synced via useEffect on
// [item.status]) restores that: status change collapses/expands the card,
// the user can still toggle in between.
it('tool-activity Collapsible is controlled by the shared disclosure state, not defaultOpen', async () => {
const src = await readFile(resolve(REPO_ROOT, 'packages/ui/src/tool-activity.tsx'), 'utf8');
assert.ok(!/defaultOpen=/.test(src), 'tool-activity must not use defaultOpen (a running card that defaults open would not auto-collapse when it settles); use controlled open that follows item.status');
assert.ok(!/defaultOpen=/.test(src), 'tool-activity must not use defaultOpen; shared disclosure state preserves manual choices and surfaces new attention states');
assert.match(src, /\bonOpenChange\b/, 'tool-activity Collapsible must be controlled via onOpenChange');
assert.match(src, /useEffect\([^]*\[item\.status\]/, 'tool-activity must re-sync open when item.status changes (useEffect on [item.status])');
assert.match(src, /useToolDisclosure/, 'tool-activity must route open state through the shared disclosure controller');
});
});

Expand All @@ -80,4 +73,4 @@ describe('disclosure-collapsible negative cases', () => {
const withImport = 'import { Collapsible } from "@maka/ui";\nexport function X() { return <Collapsible.Root />; }';
assert.ok(COLLAPSIBLE_IMPORT_RE.test(withImport), 'a Collapsible import must match');
});
});
});
27 changes: 27 additions & 0 deletions apps/desktop/src/main/__tests__/materialize-turns.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,33 @@ describe('materializeTurns', () => {
assert.equal(turns[0]?.tools[0]?.status, 'interrupted');
});

it('surfaces cancelled terminal results as interrupted instead of failed', () => {
const turns = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallMsg('t1', 101, 'bash-cancel', 'Bash'),
{
type: 'tool_result',
id: 'r-bash-cancel',
turnId: 't1',
ts: 102,
toolUseId: 'bash-cancel',
isError: true,
content: {
kind: 'terminal',
cwd: '/repo',
cmd: 'sleep 99',
status: 'cancelled',
exitCode: 130,
stdout: '',
stderr: '',
stdoutTruncated: false,
stderrTruncated: false,
},
} as StoredMessage,
]);
assert.equal(turns[0]?.tools[0]?.status, 'interrupted');
});

it('surfaces canceled ExploreAgent results as interrupted instead of failed', () => {
const turns = materializeTurns([
userMsg('t1', 100, 'q'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,13 +171,13 @@ describe('Office document capability contract', () => {
assert.ok(officeBranch > 0, 'Office document branch must exist');
assert.ok(jsonBranch > 0, 'JSON branch must exist');
assert.ok(officeBranch < jsonBranch, 'Office document results must be intercepted before raw JSON rendering');
// #332 PR4: the office preview shell migrated onto the @maka/ui previewVariants
// literalize table; the bespoke selectors are retired and the render site wires
// the governed parts instead.
// Tool-output quiet panel: office structure nests inside the shared
// tool-output body classes (no second card chrome / retired CSS selectors).
assert.doesNotMatch(styles, /\.maka-office-document-preview/, 'retired office preview selector must be gone post-migration');
assert.doesNotMatch(styles, /\.maka-office-document-stream/, 'retired office stream selector must be gone post-migration');
assert.match(previewSource, /previewVariants\(\{ part: 'office' \}\)/, 'office preview must render the governed previewVariants office surface');
assert.match(previewSource, /previewVariants\(\{ part: 'office-stream' \}\)/, 'office stdout/stderr must render the governed previewVariants office-stream surface');
assert.match(previewSource, /data-kind="office_document"/, 'office preview must keep a stable data-kind hook');
assert.match(previewSource, /TOOL_OUTPUT_BODY_CLASS/, 'office stdout/stderr must use the shared tool-output body surface');
assert.match(previewSource, /TOOL_OUTPUT_COMMAND_CLASS/, 'officecli args must use the shared command surface');
});

it('summarizes Office document edits in the permission dialog before raw args', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,7 @@
* its onClick handler. The scan also requires every discovered button to
* be value-pinned here (or in EXCEPTIONS) so a too-small `min-w-[1rem]`
* can't bypass the value lock; the whitelist pins the exact value so a
* refactor can't shrink a real lock to the wrong value. Chat stream-count variant locks live in
* the variant definition, so they're pinned by their literal declaration
* substrings.
* refactor can't shrink a real lock to the wrong value.
*/
import { strict as assert } from 'node:assert';
import { readFile } from 'node:fs/promises';
Expand Down Expand Up @@ -108,13 +106,6 @@ const TEXT_SWAP_BUTTONS: Array<{ file: string; onClick: string; minW: string; no
{ file: 'apps/desktop/src/renderer/error-boundary.tsx', onClick: 'onClick={this.handleCopyReport}', minW: '5.5rem', note: '复制诊断信息 ↔ 复制中… ↔ 已复制 ↔ 复制失败' },
];

// Chat stream-count variant lock: the min-w-[Nrem]
// declaration lives in the variant definition (chat.tsx), not at the call
// site, so we pin the literal declaration substrings.
const CHAT_VARIANT_LOCKS: Array<{ file: string; substr: string; note: string }> = [
{ file: 'packages/ui/src/primitives/chat.tsx', substr: 'min-w-[5rem] [font-variant-numeric:tabular-nums]', note: 'streamVariants count (stdout/stderr/已脱敏 N)' },
];

const BUTTON_OPEN_RE = /<(?:Ui)?Button\b/g;

// --- Heuristic scan (DISCOVERY, scoped to PR3 files) ------------------------
Expand Down Expand Up @@ -173,24 +164,6 @@ describe('PR-ANTI-LAYOUT-SHIFT-TEXT-SWAP-0 contract', () => {
}
});

it('chat stream-count variants keep their min-w declarations', async () => {
const byFile = new Map<string, typeof CHAT_VARIANT_LOCKS>();
for (const l of CHAT_VARIANT_LOCKS) {
const arr = byFile.get(l.file) ?? [];
arr.push(l);
byFile.set(l.file, arr);
}
for (const [file, locks] of byFile) {
const src = await readFile(resolve(REPO_ROOT, file), 'utf8');
for (const { substr, note } of locks) {
assert.ok(
src.includes(substr),
`${file}: missing variant declaration "${substr}" (${note})`,
);
}
}
});

it('no state-swap Button with a string-ternary child slips through without min-w-[Nrem] + whitelist value pin', () => {
// Two failure modes, both must fail closed:
// (a) no min-w-[Nrem] at all — the width lock is missing;
Expand Down Expand Up @@ -269,4 +242,4 @@ describe('PR-ANTI-LAYOUT-SHIFT-TEXT-SWAP-0 contract', () => {
'STRING_TERNARY_RE must NOT match a computed-label child ({label}); if it did, the scan would wrongly claim to cover computed-label buttons. They must stay hand-pinned in TEXT_SWAP_BUTTONS.',
);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ describe('ToolActivity result preview contract', () => {
stdoutTruncated: false,
stderrTruncated: false,
},
expected: [/data-kind="terminal"/, /退出码 1/, /stdout 已隐藏 1 行/, /复制研读提示/],
expected: [/data-kind="terminal"/, /失败 · 退出码 1/, /stdout 已隐藏 1 行/, /输出已截断/],
},
{
kind: 'office_document',
Expand Down Expand Up @@ -151,7 +151,9 @@ describe('ToolActivity result preview contract', () => {

const json = renderPreview({ kind: 'json', value: { token: SECRET, ok: true } });
assert.match(json, /data-kind="json"/);
assert.match(json, /&quot;ok&quot;: true/);
// Quiet panel: plain key:value lines, not pretty-printed JSON quotes.
assert.match(json, /ok:\s*true/);
assert.doesNotMatch(json, /&quot;ok&quot;:\s*true/);
assert.doesNotMatch(json, new RegExp(SECRET));

const fileWrite = renderPreview({ kind: 'file_write', path: 'out.txt', bytes: 12 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,24 @@ describe('tool and permission args redaction', () => {
assert.match(rendered, /command/);
});

it('routes ToolActivity and PermissionDialog args through formatRedactedJson', async () => {
const [toolSource, permissionSource] = await Promise.all([
it('routes ToolActivity args through quiet formatters and PermissionDialog through formatRedactedJson', async () => {
const [toolSource, permissionSource, quietSource] = await Promise.all([
readFile(join(process.cwd(), '../../packages/ui/src/tool-activity.tsx'), 'utf8'),
readFile(join(process.cwd(), '../../packages/ui/src/permission-dialog.tsx'), 'utf8'),
readFile(join(process.cwd(), '../../packages/ui/src/tool-activity/builtin-preview.ts'), 'utf8'),
]);
const toolActivity = toolSource.match(/export function ToolActivity[\s\S]*?function ToolOutputStream/)?.[0] ?? '';
const permissionDialog = permissionSource.match(/export function PermissionDialog[\s\S]*?function renderPermissionSummary/)?.[0] ?? '';

assert.match(toolActivity, /\{formatRedactedJson\(item\.args\)\}/);
// Quiet panel: never stringify args; use formatToolInvocationLine / formatQuietJsonValue.
assert.match(toolActivity, /formatToolInvocationLine\(item\)/);
assert.match(toolActivity, /formatQuietJsonValue/);
assert.doesNotMatch(toolActivity, /JSON\.stringify\(item\.args/);
assert.doesNotMatch(toolActivity, /formatRedactedJson\(item\.args\)/);
// Keys and full lines are redacted in the quiet key/value formatter.
assert.match(quietSource, /redactSecrets\(key\)/);
assert.match(quietSource, /push\(redactSecrets\(line\)\)|lines\.push\(redactSecrets\(line\)\)/);
// Permission dialog still uses formatRedactedJson for its summary dump.
assert.match(permissionDialog, /\{formatRedactedJson\(props\.request\.args\)\}/);
assert.doesNotMatch(permissionDialog, /JSON\.stringify\(props\.request\.args/);
});
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/__tests__/trow-summary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ describe('trowActivityKind', () => {
assert.equal(trowActivityKind('Write'), 'edit');
assert.equal(trowActivityKind('Edit'), 'edit');
assert.equal(trowActivityKind('Bash'), 'command');
assert.equal(trowActivityKind('StopBackgroundTask'), 'command');
assert.equal(trowActivityKind('stop_background_task'), 'command');
assert.equal(trowActivityKind('ExploreAgent'), 'explore');
assert.equal(trowActivityKind('browser_click'), 'browser');
assert.equal(trowActivityKind('OfficeDocument'), 'tool');
Expand Down
Loading
Loading