diff --git a/apps/desktop/src/renderer/maka-tokens.css b/apps/desktop/src/renderer/maka-tokens.css index 1d2dffc1ea..292728a289 100644 --- a/apps/desktop/src/renderer/maka-tokens.css +++ b/apps/desktop/src/renderer/maka-tokens.css @@ -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 ---------------------------------------------- */ diff --git a/packages/ui/src/__tests__/processing-block.test.tsx b/packages/ui/src/__tests__/processing-block.test.tsx index ef45b054d6..eebbd25c89 100644 --- a/packages/ui/src/__tests__/processing-block.test.tsx +++ b/packages/ui/src/__tests__/processing-block.test.tsx @@ -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', @@ -69,13 +69,35 @@ describe('deep-thinking disclosure', () => { }, })); - assert.match(markup, /data-slot="reasoning-disclosure"/); - assert.match(markup, /]*data-slot="reasoning-trigger"[^>]*aria-expanded="false"[^>]*aria-controls="[^"]+"/); - const trigger = markup.match(/(]*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, /<redacted>/); + assert.doesNotMatch(markup, new RegExp(secret)); + assert.doesNotMatch(markup, /data-truncated="true"/); }); }); diff --git a/packages/ui/src/__tests__/tool-trow-summary.test.ts b/packages/ui/src/__tests__/tool-trow-summary.test.ts index 68127fc236..4c07bcc5d1 100644 --- a/packages/ui/src/__tests__/tool-trow-summary.test.ts +++ b/packages/ui/src/__tests__/tool-trow-summary.test.ts @@ -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'), @@ -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, - ); - }); -}); diff --git a/packages/ui/src/astryx-chat-reasoning.tsx b/packages/ui/src/astryx-chat-reasoning.tsx new file mode 100644 index 0000000000..89768f6b0d --- /dev/null +++ b/packages/ui/src/astryx-chat-reasoning.tsx @@ -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 { + children: ReactNode; + label?: string; + duration?: string; + isStreaming?: boolean; + isExpanded?: boolean; + defaultIsExpanded?: boolean; + onExpandedChange?: (isExpanded: boolean) => void; +} + +function ThinkingIcon() { + return ( + + ); +} + +function ChevronDownIcon() { + return ( + + ); +} + +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 ( +
+
{ + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + toggle(); + } + }} + className="x78zum5 x6s0dn4 x1s4dlld x1ypdohk x87ps6o xjwf9q1 x13f7esw" + > + + + +
+ + {label} + + {duration != null && !isStreaming ? ( + <> + · + {duration} + + ) : null} + {!isExpanded && previewText && !isStreaming ? ( + <> + + {previewText} + + ) : null} +
+ + + +
+
+
+
{children}
+
+
+
+ ); +} + +ChatReasoning.displayName = 'ChatReasoning'; diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index a7038f86f8..18a9f90f61 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -1,20 +1,18 @@ -import { Fragment, memo, useEffect, useId, useMemo, useRef, useState, type ComponentPropsWithoutRef, type ReactNode } from 'react'; +import { Fragment, memo, useEffect, useMemo, useRef, useState, type ComponentPropsWithoutRef, type ReactNode } from 'react'; import { Button as BaseButton } from '@base-ui/react/button'; import { useMountedRef } from './use-mounted-ref.js'; -import { AlertOctagon, Ban, Brain, Check, ChevronDown, Copy, GitBranch, Info, Loader2, Pencil, RefreshCcw, Timer } from './icons.js'; +import { AlertOctagon, Ban, Check, Copy, GitBranch, Info, Loader2, Pencil, RefreshCcw, Timer } from './icons.js'; import { type ClipboardCopyPhase, useClipboardCopyFeedback } from './clipboard-feedback.js'; import { Markdown } from './markdown.js'; import { formatAbsoluteTimestamp, formatClockTime, turnAbortMarkerLabel } from './chat-display-helpers.js'; import { prepareSmoothStreamText, useSmoothStreamContent } from './smooth-stream.js'; -import { tokenizeFade, useStreamFade, type StreamFade } from './stream-fade.js'; import { Button as UiButton, ChatMessage, ChatMessageBubble, IconButton as UiIconButton, - Text as AstryxText, } from '@astryxdesign/core'; -import { useCollapsible } from '@astryxdesign/core/Collapsible'; +import { ChatReasoning } from './astryx-chat-reasoning.js'; import { Dialog } from '@astryxdesign/core/Dialog'; import { Layout, LayoutContent } from '@astryxdesign/core/Layout'; import { Tooltip } from '@astryxdesign/core/Tooltip'; @@ -519,7 +517,7 @@ export const TurnView = memo(function TurnView(props: { data-turn-status={turn.status} className="maka-chat-message group/answer" > -
+
{/* PR109d-c: aborted turn gets a muted "(已中断)" marker + Ban icon so the user sees this turn was cancelled without it looking like a fault state (reserved for `failed`). Rendered as its own row so @@ -984,159 +982,49 @@ function ProcessingBlock(props: { entries: FoldedTimelineChild[] }) { } /** - * "深度思考" — the unified reasoning disclosure for both live streaming and - * committed history. Astryx's public `useCollapsible` owns disclosure state; - * the custom row follows ChatToolCalls geometry without pretending reasoning - * is a tool call. It starts collapsed with the fixed title "深度思考". - * - * `live=true` (thinking still flowing): the title shimmers (TextShimmer) and the - * expanded body streams plain redacted text through `useSmoothStreamContent` - * (non-Markdown for the same frame-pacing reason as the old ReasoningPanel), - * auto-following the tail. `live=false` (settled / committed): plain title, - * Markdown render + a "复制思考过程" button. + * "深度思考" uses Astryx's official ChatReasoning directly. Astryx owns the + * disclosure geometry, keyboard behavior, streaming shimmer, collapsed preview, + * and expanded prose. Maka owns only its product data and capabilities: stream + * pacing, redaction/capping before render, and clipboard feedback. * * `props.text` is the already-redacted-and-capped buffer (C0 chokepoint); * `prepareSmoothStreamText` re-runs `redactSecrets` (idempotent) as - * defense-in-depth so the smoother never sees a raw secret. The "已截断" pill - * fires when the thinking cap dropped content. + * defense-in-depth so the smoother never sees a raw secret. A truncated buffer + * is announced in the official label instead of adding parallel visual chrome. */ function DeepThinking(props: { text: string; live: boolean; truncated?: boolean }) { const copy = getConversationCopy(useUiLocale()).messages; const snap = useStreamSnap(); const safeText = prepareSmoothStreamText(props.text); const { displayed } = useSmoothStreamContent(safeText, { streaming: props.live, snap }); - // Per-word fade over the freshly revealed reasoning tail — same entrance as the - // main answer bubble (replaces the old caret). Plain-text path (no Markdown), - // so we tokenize `displayed` directly and wrap post-boundary tokens. Inactive - // (returns undefined) when settled or under snap. - const streamFade = useStreamFade(displayed, props.live && !snap); - // Astryx's state machine keeps a manual disclosure choice stable across the - // ~60Hz streaming re-renders. Collapsed by default so the answer reads cleanly. - const { isOpen: open, toggle } = useCollapsible({ - isCollapsible: { defaultIsOpen: false }, - }); - const contentId = useId(); - const bodyRef = useRef(null); - useEffect(() => { - if (!props.live || !open) return; - const el = bodyRef.current; - if (el) el.scrollTop = el.scrollHeight; - }, [displayed, props.live, open]); + const [open, setOpen] = useState(false); + const visibleText = props.live ? displayed : safeText; + const label = props.truncated ? `${copy.thinking} · ${copy.truncated}` : copy.thinking; return (
- - - {/* ChatToolCalls indents detail by its 16px status slot plus the 6px row - gap. Reasoning uses the same 22px content edge while retaining a - prose body instead of impersonating tool output. `live` and settled - render the SAME plain-text body at the caption tier so the two states - never jump size; settled is muted + regular weight (long reasoning in - italic reads poorly). - The copy action is an icon-only hover affordance pinned top-right so - it never squeezes the reading column into a vertical char stack. */} - + {visibleText} + + {open && !props.live ? ( +
+ +
+ ) : null}
); } -/** - * Plain-text reasoning body with the same per-word fade as the answer bubble. - * When `streamFade` is absent (settled / snap) it renders the raw string so the - * deterministic capture shows the full text with no spans. Otherwise it splits - * the whole buffer at grapheme 0 and wraps each post-boundary token in a - * `.maka-stream-fade` span with a negative `animation-delay` (= -age) so the - * entrance resumes mid-flight across the ~60Hz streaming re-renders. - */ -function DeepThinkingBody(props: { text: string; streamFade?: StreamFade }) { - const fade = props.streamFade; - if (!fade) return <>{props.text}; - const { tokens } = tokenizeFade(props.text, 0, fade.boundaryOffset); - return ( - <> - {tokens.map((token, index) => - token.fade ? ( - - {token.text} - - ) : ( - {token.text} - ), - )} - - ); -} - /** * PR-UI-RENDER-1 — reduced-motion / e2e-fixture probe for the * streaming smoother. diff --git a/packages/ui/src/tool-activity/trow-summary.ts b/packages/ui/src/tool-activity/trow-summary.ts index d5170c8992..f64d8b6862 100644 --- a/packages/ui/src/tool-activity/trow-summary.ts +++ b/packages/ui/src/tool-activity/trow-summary.ts @@ -13,10 +13,8 @@ import type { ToolActivityKind, UiLocale } from '@maka/core'; import type { ToolActivityItem } from '../materialize.js'; -import type { FoldedTimelineChild } from '../timeline-fold.js'; import { loadToolDisplayName } from '../tool-format.js'; import { getToolActivityCopy } from './copy.js'; -import { formatUserVisibleToolText } from './preview-utils.js'; import { isSandboxDeniedTool } from './sandbox-denial.js'; export type TrowActivityKind = ToolActivityKind; @@ -148,106 +146,3 @@ export function isTrowRunning(items: readonly ToolActivityItem[]): boolean { export function trowNeedsAttention(items: readonly ToolActivityItem[]): boolean { return items.some((item) => item.status === 'waiting_permission'); } - -// ── Processing block (#1307) ──────────────────────────────────────────────── -// A processing block folds a maximal run of reasoning + tool groups between two -// answer texts (a run folds only when it contains tool activity — see -// foldTimeline in timeline-fold.ts). Its summary reuses the trow bucket -// clauses; folded -// reasoning stays inside the block but is not counted in the summary line. The -// failed count stays visible (errored tools remain collapsed, so the summary -// line is the failure signal, matching the trow). - -/** All tool items across the block's tool groups, in order. */ -function processingTools(children: readonly FoldedTimelineChild[]): ToolActivityItem[] { - return children.flatMap((child) => (child.kind === 'tools' ? child.items : [])); -} - -/** The first tool bucket represented by a processing block's summary and icon. */ -export function processingActivityKind( - children: readonly FoldedTimelineChild[], -): TrowActivityKind { - const firstTool = processingTools(children)[0]; - return firstTool ? trowActivityKind(firstTool.toolName, firstTool.activityKind) : 'tool'; -} - -/** True while any tool is in flight or any reasoning block is still streaming. */ -export function isProcessingRunning(children: readonly FoldedTimelineChild[]): boolean { - return children.some((child) => - child.kind === 'thinking' ? child.live === true : isTrowRunning(child.items), - ); -} - -/** - * True when the block must force itself open: a permission prompt sits inside. - * Mirrors `trowNeedsAttention` — an errored tool does NOT force-open; the - * settled summary carries the failure count (「N 个失败」 in destructive color). - */ -export function processingNeedsAttention(children: readonly FoldedTimelineChild[]): boolean { - return children.some((child) => child.kind === 'tools' && trowNeedsAttention(child.items)); -} - -/** - * Summary line for a processing block. Settled: the tool-activity roll-up only - * (per-bucket clauses + failed count, exactly the trow summary) — folded - * reasoning is not counted. Live (`{ live: true }`): the current activity — - * the LAST live entry in timeline order (a running tool's intent, or the - * reasoning label when a later thinking block is still streaming), prefixed - * with "正在" — plus the failed clause whenever the block already holds an - * errored tool, so the failure signal is never deferred to settle. - */ -export function summarizeProcessing( - children: readonly FoldedTimelineChild[], - options?: { live?: boolean; locale?: UiLocale }, -): string { - const locale = options?.locale ?? 'zh'; - if (options?.live) return processingLiveSummary(children, locale); - return summarizeTrowTools(processingTools(children), { locale }); -} - -/** Current-activity line for a running processing block. */ -function processingLiveSummary( - children: readonly FoldedTimelineChild[], - locale: UiLocale, -): string { - const copy = getToolActivityCopy(locale).summary; - const line = copy.live(currentProcessingActivity(children, locale) ?? copy.thinkingActivity); - const tools = processingTools(children); - const sandboxBlocked = tools.filter(isSandboxDeniedTool).length; - const failed = tools.filter(isFailed).length; - const clauses = [line]; - if (sandboxBlocked > 0) clauses.push(copy.sandboxBlocked(sandboxBlocked)); - if (failed > 0) clauses.push(copy.failed(failed)); - return copy.join(clauses); -} - -/** - * The block's current activity: walk the children in reverse timeline order - * and return the first live entry found — a still-streaming thinking block - * (reasoning label) or a tool group's active tool (intent, falling back to the - * localized display name via resolveToolDisplayName so connector tools read as - * 「加载工具组」, not `load_tools`). - */ -function currentProcessingActivity( - children: readonly FoldedTimelineChild[], - locale: UiLocale, -): string | undefined { - for (let index = children.length - 1; index >= 0; index -= 1) { - const child = children[index]!; - if (child.kind === 'thinking') { - if (child.live === true) return getToolActivityCopy(locale).summary.thinkingActivity; - continue; - } - const activeTool = [...child.items] - .reverse() - .find( - (tool) => - tool.status === 'running' || tool.status === 'pending' || tool.status === 'waiting_permission', - ); - if (activeTool) { - return formatUserVisibleToolText(activeTool.intent ?? '', locale) - || resolveToolDisplayName(activeTool, locale); - } - } - return undefined; -}