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
15 changes: 12 additions & 3 deletions apps/desktop/src/renderer/maka-tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -1231,9 +1231,18 @@ html[data-os="darwin"].dark {
content-visibility: visible;
}

/* Tools now render inline via the flat `ToolTrow`; reasoning uses Astryx
Collapsible through `DeepThinking`. Product text and shimmer remain local,
while disclosure focus, keyboard behavior, chevron, and chrome are Astryx-owned. */
/* ChatReasoning is ejected from Astryx lab 0.1.9 because lab is canary-only.
Core's atom sheet already contains every compiled StyleX atom except the
five shimmer declarations below; these are copied verbatim from lab.css. */
@keyframes x1ofn8cw-B {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.xct3ic7:not(#\#):not(#\#):not(#\#) { background-image: linear-gradient(90deg, var(--color-text-secondary) 0%, var(--color-text-disabled) 50%, var(--color-text-secondary) 100%); }
.xakli9p:not(#\#):not(#\#):not(#\#) { background-size: 200% 100%; }
.x1ta4xzc:not(#\#):not(#\#):not(#\#) { background-clip: text; }
.x3mlza6:not(#\#):not(#\#):not(#\#) { animation-name: x1ofn8cw-B; }
.xeaay5l:not(#\#):not(#\#):not(#\#) { animation-duration: 4s; }

/* ---- Tool activity ---------------------------------------------- */

Expand Down
40 changes: 31 additions & 9 deletions packages/ui/src/__tests__/processing-block.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ describe('ProcessingBlock disclosure wiring (#1307)', () => {
});

describe('deep-thinking disclosure', () => {
it('starts collapsed with the same compact activity-row structure as Astryx tool calls', () => {
it('renders through official Astryx ChatReasoning with its native collapsed preview', () => {
const markup = renderToStaticMarkup(createElement(TurnView, {
turn: {
turnId: 'thinking-turn',
Expand All @@ -69,13 +69,35 @@ describe('deep-thinking disclosure', () => {
},
}));

assert.match(markup, /data-slot="reasoning-disclosure"/);
assert.match(markup, /<button[^>]*data-slot="reasoning-trigger"[^>]*aria-expanded="false"[^>]*aria-controls="[^"]+"/);
const trigger = markup.match(/(<button[^>]*data-slot="reasoning-trigger"[\s\S]*?<\/button>)/)?.[1];
assert.ok(trigger, 'deep-thinking disclosure must expose a collapsed trigger');
assert.match(trigger, /lucide-chevron-down/);
assert.match(trigger, /min-h-6/);
assert.match(trigger, /class="[^"]*astryx-text supporting[^"]*"/);
assert.doesNotMatch(markup, /private reasoning/);
assert.match(markup, /class="[^"]*astryx-chat-reasoning[^"]*min-w-0[^"]*"/);
assert.match(markup, /class="flex min-w-0 w-full flex-col gap-2"/);
assert.match(markup, /role="button"[^>]*aria-expanded="false"/);
assert.match(markup, /private reasoning/);
assert.doesNotMatch(markup, /data-slot="reasoning-trigger"/);
});

it('keeps redaction and truncation product behavior outside the Astryx disclosure', () => {
const secret = 'sk-abcdefghijklmnopqrstuvwxyz123456';
const markup = renderToStaticMarkup(createElement(TurnView, {
turn: {
turnId: 'safe-thinking-turn',
status: 'completed',
partialOutputRetained: false,
tools: [],
notes: [],
timeline: [{
kind: 'thinking',
text: `api_key=${secret}`,
truncated: true,
messageId: 'thinking-2',
}],
startedAt: 1,
},
}));

assert.match(markup, /深度思考 · 已截断/);
assert.match(markup, /&lt;redacted&gt;/);
assert.doesNotMatch(markup, new RegExp(secret));
assert.doesNotMatch(markup, /data-truncated="true"/);
});
});
111 changes: 1 addition & 110 deletions packages/ui/src/__tests__/tool-trow-summary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,8 @@ import { createElement, type ReactNode } from 'react';
import { renderToStaticMarkup as renderReactToStaticMarkup } from 'react-dom/server';
import { LocaleProvider } from '../locale-context.js';
import { ToolTrow } from '../tool-activity.js';
import {
isProcessingRunning,
processingNeedsAttention,
summarizeProcessing,
summarizeTrowTools,
} from '../tool-activity/trow-summary.js';
import { summarizeTrowTools } from '../tool-activity/trow-summary.js';
import type { ToolActivityItem } from '../materialize.js';
import type { FoldedTimelineChild } from '../timeline-fold.js';

const toolActivitySource = readFileSync(
join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'src', 'tool-activity.tsx'),
Expand Down Expand Up @@ -160,106 +154,3 @@ describe('tool trow summary aggregation', () => {
assert.equal((toolActivitySource.match(/\bSETTLE_FADE\b/g) ?? []).length, 2);
});
});

function thinking(live?: boolean): FoldedTimelineChild {
return { kind: 'thinking', text: 'reasoning', messageId: 'a1', ...(live !== undefined ? { live } : {}) };
}

function tools(items: ToolActivityItem[]): FoldedTimelineChild {
return { kind: 'tools', items };
}

describe('processing block summary (#1307)', () => {
it('settled summary rolls up tool activity only — folded reasoning is not counted', () => {
const children = [
thinking(),
tools([
{ toolUseId: 'r1', toolName: 'Read', activityKind: 'read', status: 'completed', args: {} },
{ toolUseId: 'g1', toolName: 'Grep', activityKind: 'search', status: 'errored', args: {} },
]),
thinking(),
];
// 只汇总工具桶 + 标红失败计数(沿用 summarizeTrowTools 文案),不出现「思考 N 次」。
assert.equal(summarizeProcessing(children, {}), '读取 1 个文件,搜索 1 次,1 个失败');
});

it('shows the running tool intent as the live current activity and appends the failed count', () => {
const children = [
tools([
{ toolUseId: 'r1', toolName: 'Read', activityKind: 'read', status: 'errored', args: {} },
{ toolUseId: 'b1', toolName: 'Bash', activityKind: 'command', status: 'running', args: {}, intent: '运行测试' },
]),
];
// 运行中显示当前活动(带「正在」前缀);区块内已有失败工具时,失败计数
// 不等 settle 才出现——摘要行是折叠错误的唯一信号。
assert.equal(summarizeProcessing(children, { live: true }), '正在运行测试,1 个失败');
});

it('live summary without failures stays a bare current-activity line', () => {
const children = [
tools([{ toolUseId: 'b1', toolName: 'Bash', activityKind: 'command', status: 'running', args: {}, intent: '运行测试' }]),
];
assert.equal(summarizeProcessing(children, { live: true }), '正在运行测试');
});

it('live summary falls back to the reasoning label when tools are done and thinking still streams', () => {
const children = [
tools([{ toolUseId: 'r1', toolName: 'Read', activityKind: 'read', status: 'completed', args: {} }]),
thinking(true),
];
assert.equal(summarizeProcessing(children, { live: true }), '正在深度思考');
});

it('live summary picks the LAST live entry in timeline order, skipping settled thinking', () => {
// A settled reasoning block after a still-running tool must not steal the
// current-activity line: the last LIVE entry is the running tool.
const children = [
tools([{ toolUseId: 'b1', toolName: 'Bash', activityKind: 'command', status: 'running', args: {}, intent: '运行测试' }]),
thinking(false),
];
assert.equal(summarizeProcessing(children, { live: true }), '正在运行测试');
// And a LATER streaming thinking block outranks an earlier running tool.
const laterThinking = [
tools([{ toolUseId: 'b1', toolName: 'Bash', activityKind: 'command', status: 'running', args: {}, intent: '运行测试' }]),
thinking(true),
];
assert.equal(summarizeProcessing(laterThinking, { live: true }), '正在深度思考');
});

it('localizes the connector-tool fallback in the live current activity', () => {
// A load_tools call with no intent/displayName must read as the localized
// 「加载工具组」, not the raw tool name (resolveToolDisplayName fallback).
const children = [
tools([{ toolUseId: 'l1', toolName: 'load_tools', status: 'running', args: {} }]),
];
assert.equal(summarizeProcessing(children, { live: true }), '正在加载工具组');
});

it('is running while any tool is in flight or reasoning is still streaming', () => {
assert.equal(isProcessingRunning([thinking(true)]), true);
assert.equal(isProcessingRunning([thinking(false)]), false);
assert.equal(
isProcessingRunning([tools([{ toolUseId: 'r1', toolName: 'Read', status: 'running', args: {} }])]),
true,
);
assert.equal(
isProcessingRunning([tools([{ toolUseId: 'r1', toolName: 'Read', status: 'completed', args: {} }])]),
false,
);
});

it('needs attention (force-open) only for a waiting_permission prompt, not an error', () => {
assert.equal(
processingNeedsAttention([tools([{ toolUseId: 'w1', toolName: 'Write', status: 'waiting_permission', args: {} }])]),
true,
);
// Errored tools stay collapsed — the summary line carries the failure count.
assert.equal(
processingNeedsAttention([
thinking(),
tools([{ toolUseId: 'e1', toolName: 'Bash', status: 'errored', args: {} }]),
]),
false,
);
});
});
140 changes: 140 additions & 0 deletions packages/ui/src/astryx-chat-reasoning.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.
// SPDX-License-Identifier: MIT

/**
* Astryx ChatReasoning 0.1.9, ejected from the official lab package.
*
* Source: packages/lab/src/ChatReasoning/ChatReasoning.tsx at Astryx v0.1.9
* (commit c9fe437). The lab package is canary-only and declares an exact
* canary core peer even though this release is the stable 0.1.9 source. Maka
* therefore uses Astryx's supported swizzle/eject seam instead of forcing an
* invalid dependency tree. DOM, state, keyboard behavior, icons, and compiled
* StyleX atoms below are the official component; only the build-time StyleX
* call has already been compiled, matching the published package output.
*/
import { useCallback, useState, type HTMLAttributes, type ReactNode } from 'react';
import { mergeProps, themeProps } from '@astryxdesign/core/utils';

export interface ChatReasoningProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
label?: string;
duration?: string;
isStreaming?: boolean;
isExpanded?: boolean;
defaultIsExpanded?: boolean;
onExpandedChange?: (isExpanded: boolean) => void;
}

function ThinkingIcon() {
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<circle cx="7" cy="7" r="5.5" stroke="currentColor" strokeWidth="1.5" strokeDasharray="3 2" />
<circle cx="5.5" cy="7" r="0.75" fill="currentColor" />
<circle cx="8.5" cy="7" r="0.75" fill="currentColor" />
</svg>
);
}

function ChevronDownIcon() {
return (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}

export function ChatReasoning(props: ChatReasoningProps) {
const {
children,
label = 'Thinking',
duration,
isStreaming = false,
isExpanded: controlledExpanded,
defaultIsExpanded = false,
onExpandedChange,
className,
style,
...rest
} = props;
const [internalExpanded, setInternalExpanded] = useState(defaultIsExpanded);
const isControlled = controlledExpanded !== undefined;
const isExpanded = isControlled ? controlledExpanded : internalExpanded;
const toggle = useCallback(() => {
const next = !isExpanded;
if (!isControlled) setInternalExpanded(next);
onExpandedChange?.(next);
}, [isExpanded, isControlled, onExpandedChange]);
const previewText = typeof children === 'string' ? children : null;

return (
<div
{...mergeProps(
themeProps('chat-reasoning', {
expanded: isExpanded ? 'expanded' : null,
streaming: isStreaming ? 'streaming' : null,
}),
{ className: 'x78zum5 xdt5ytf xtbrsbv' },
className,
style,
)}
{...rest}
>
<div
role="button"
tabIndex={0}
aria-expanded={isExpanded}
onClick={toggle}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
toggle();
}
}}
className="x78zum5 x6s0dn4 x1s4dlld x1ypdohk x87ps6o xjwf9q1 x13f7esw"
>
<span className="x3nfvp2 x6s0dn4 xl56j7k x2lah0s x1kky2od xlup9mm xv1l7n4">
<ThinkingIcon />
</span>
<div className="x78zum5 x6s0dn4 xzye2dw xeuugli xb3r6kr">
<span
className={
isStreaming
? 'x141an7d x1ltkj2j x9ynric x1e4wzip xuxw1ft x2lah0s xct3ic7 xakli9p x1ta4xzc x19co3pv x3mlza6 xeaay5l x1esw782 xa4qsjk'
: 'x141an7d x1ltkj2j x9ynric x1e4wzip xv1l7n4 xuxw1ft x2lah0s'
}
>
{label}
</span>
{duration != null && !isStreaming ? (
<>
<span className="x141an7d xnbbluu x2lah0s">·</span>
<span className="x141an7d x1ltkj2j x9ynric xnbbluu xuxw1ft x2lah0s">{duration}</span>
</>
) : null}
{!isExpanded && previewText && !isStreaming ? (
<>
<span className="x141an7d xnbbluu x2lah0s">—</span>
<span className="x141an7d x1ltkj2j x9ynric xnbbluu xuxw1ft xb3r6kr xlyipyv xeuugli">{previewText}</span>
</>
) : null}
</div>
<span
className={
isExpanded
? 'x3nfvp2 x6s0dn4 xl56j7k x2lah0s x6jxa94 x1v9usgg xnbbluu x1ob6yzd x19jd1h0'
: 'x3nfvp2 x6s0dn4 xl56j7k x2lah0s x6jxa94 x1v9usgg xnbbluu x1ob6yzd'
}
>
<ChevronDownIcon />
</span>
</div>
<div className={isExpanded ? 'xrvj5dj xb0j27v x1tu4anv' : 'xrvj5dj xihq33y xb0j27v'}>
<div className="xb3r6kr x2lwn1j">
<div className="x1xye8es x1f43n9v x141an7d x1ltkj2j x9ynric xv1l7n4">{children}</div>
</div>
</div>
</div>
);
}

ChatReasoning.displayName = 'ChatReasoning';
Loading
Loading