From ab4e6760854610d8556194490b41e44f99e3fb2f Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 8 Jul 2026 17:04:09 +0800 Subject: [PATCH 01/18] feat(core,runtime): persist tool_call stepId for step-paired UI timeline Add optional ToolCallMessage.stepId, stamped by ToolRuntime from getCurrentStepId() (same source as ToolStartEvent.stepId), and map it into refs.stepId on the backfill path so post-restart model replay re-pairs a tool call with its assistant step. The field ships with its first consumer, the UI turn timeline (materializeTurns, next commit). --- packages/core/src/session.ts | 9 +++++++++ .../runtime/src/__tests__/runtime-event-backfill.test.ts | 2 ++ packages/runtime/src/runtime-event-backfill.ts | 9 ++++++++- packages/runtime/src/tool-runtime.ts | 5 ++++- 4 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index edc587de37..98a16846e0 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -205,6 +205,15 @@ export interface ToolCallMessage { displayName?: string; intent?: string; args: unknown; + /** + * Assistant step this call belongs to (equals the step's AssistantMessage + * id, stamped from the same source as ToolStartEvent.stepId). Optional for + * legacy rows written before per-step persistence. First consumer is the UI + * timeline (materializeTurns), which orders a step's thinking/text ahead of + * the tools whose stepId matches that step; the backfill path also reads it + * to re-pair tools with their step after a restart. + */ + stepId?: string; } export interface ToolResultMessage { diff --git a/packages/runtime/src/__tests__/runtime-event-backfill.test.ts b/packages/runtime/src/__tests__/runtime-event-backfill.test.ts index e003b68fe1..8011af1658 100644 --- a/packages/runtime/src/__tests__/runtime-event-backfill.test.ts +++ b/packages/runtime/src/__tests__/runtime-event-backfill.test.ts @@ -68,6 +68,7 @@ describe('runtime event backfill', () => { displayName: 'Read file', intent: 'inspect', args: { path: 'README.md' }, + stepId: 'step-1', }, { type: 'tool_result', @@ -144,6 +145,7 @@ describe('runtime event backfill', () => { expect(result.events[3]?.content).toEqual({ kind: 'function_call', id: 'tool-1', name: 'Read', args: { path: 'README.md' } }); expect(result.events[3]?.actions?.stateDelta?.displayName).toBe('Read file'); expect(result.events[3]?.actions?.stateDelta?.intent).toBe('inspect'); + expect(result.events[3]?.refs).toEqual({ storedMessageId: 'tool-1', toolCallId: 'tool-1', stepId: 'step-1' }); expect(result.events[4]?.content).toEqual({ kind: 'function_response', id: 'tool-1', name: 'Read', result: { kind: 'text', text: 'file body' }, isError: false }); expect(result.events[4]?.actions?.stateDelta?.durationMs).toBe(42); expect(result.events[5]?.actions?.permissionDecision).toEqual({ requestId: 'perm-1', decision: 'allow', rememberForTurn: true }); diff --git a/packages/runtime/src/runtime-event-backfill.ts b/packages/runtime/src/runtime-event-backfill.ts index 4fc0bd3aa4..645f4bc259 100644 --- a/packages/runtime/src/runtime-event-backfill.ts +++ b/packages/runtime/src/runtime-event-backfill.ts @@ -144,7 +144,14 @@ export function backfillRuntimeEventsFromStoredMessages( ...(message.intent !== undefined ? { intent: message.intent } : {}), }, }, - refs: { storedMessageId: message.id, toolCallId: message.id }, + // Carry the persisted step id into refs.stepId so post-restart model + // replay can re-pair this call with its assistant step, matching the + // live tool_start path (see model-history step grouping). + refs: { + storedMessageId: message.id, + toolCallId: message.id, + ...(message.stepId !== undefined ? { stepId: message.stepId } : {}), + }, }); break; diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index 4539e18339..485a976f1a 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -244,6 +244,7 @@ export class ToolRuntime { const toolIntent = describeToolIntent(tool, args); const trace = this.input.getRunTrace?.() ?? null; + const stepId = this.input.getCurrentStepId?.(); const callMsg: ToolCallMessage = { type: 'tool_call', id: toolUseId, @@ -253,9 +254,11 @@ export class ToolRuntime { ...(tool.displayName ? { displayName: tool.displayName } : {}), ...(toolIntent ? { intent: toolIntent } : {}), args, + // Persist the same step id the tool_start event carries so the UI + // timeline and post-restart backfill can pair this call with its step. + ...(stepId !== undefined ? { stepId } : {}), }; await this.input.appendMessage(callMsg); - const stepId = this.input.getCurrentStepId?.(); const startEv: ToolStartEvent = { type: 'tool_start', id: this.input.newId(), From 178b437477eeba3418504f90c2c3c2464fa54b8f Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 8 Jul 2026 17:14:23 +0800 Subject: [PATCH 02/18] feat(ui): materialize per-turn render timeline from step-paired messages Replace PR1's neutral multi-step concatenation with a TurnTimelineItem sequence (thinking / text / tools) rebuilt in storage order: each step's thinking and text precede the tools whose stepId matches that step; legacy stepless tools keep the tools-then-summary reading before the text; leftover and live-only tools flush as trailing tool groups; adjacent thinking and tool groups merge. Aggregate assistant/assistantThinking stay for copy/export/ prompt-rail consumers, with timeline as the rendering source of truth. ToolActivityItem gains stepId for the pairing. Tests cover interleaved multi-step, legacy single, pure-tool tail, live-only append, and merges. --- .../main/__tests__/materialize-turns.test.ts | 99 +++++++++++++ packages/ui/src/materialize.ts | 136 ++++++++++++++++++ 2 files changed, 235 insertions(+) diff --git a/apps/desktop/src/main/__tests__/materialize-turns.test.ts b/apps/desktop/src/main/__tests__/materialize-turns.test.ts index 527f1aaca9..99caa184fc 100644 --- a/apps/desktop/src/main/__tests__/materialize-turns.test.ts +++ b/apps/desktop/src/main/__tests__/materialize-turns.test.ts @@ -347,6 +347,105 @@ describe('materializeTurns', () => { }); }); +function toolCallStep(turnId: string, ts: number, id: string, stepId: string, toolName = 'Read'): StoredMessage { + return { type: 'tool_call', id, turnId, ts, toolName, args: {}, stepId }; +} + +function assistantStep( + turnId: string, + ts: number, + id: string, + text: string, + thinking?: string, +): StoredMessage { + return { + type: 'assistant', + id, + turnId, + ts, + text, + modelId: 'm', + ...(thinking !== undefined ? { thinking: { text: thinking } } : {}), + } as StoredMessage; +} + +describe('materializeTurns timeline', () => { + it('interleaves each step: thinking -> text -> that step’s tools', () => { + const turns = materializeTurns([ + userMsg('t1', 100, 'q'), + toolCallStep('t1', 101, 'c1', 'a1'), + toolResultMsg('t1', 102, 'c1'), + assistantStep('t1', 103, 'a1', 'step one', 'think one'), + toolCallStep('t1', 104, 'c2', 'a2'), + toolResultMsg('t1', 105, 'c2'), + assistantStep('t1', 106, 'a2', 'step two', 'think two'), + ]); + const timeline = turns[0]!.timeline; + assert.deepEqual(timeline.map((i) => i.kind), ['thinking', 'text', 'tools', 'thinking', 'text', 'tools']); + assert.equal((timeline[0] as { text: string }).text, 'think one'); + assert.equal((timeline[1] as { text: string }).text, 'step one'); + assert.equal((timeline[2] as { items: { toolUseId: string }[] }).items[0]?.toolUseId, 'c1'); + assert.equal((timeline[5] as { items: { toolUseId: string }[] }).items[0]?.toolUseId, 'c2'); + // Aggregate fields still reflect the concatenated whole for legacy consumers. + assert.equal(turns[0]?.assistant?.text, 'step one\n\nstep two'); + assert.equal(turns[0]?.assistantThinking, 'think one\n\nthink two'); + }); + + it('legacy call with no stepId sits before the summary text', () => { + const turns = materializeTurns([ + userMsg('t1', 100, 'q'), + toolCallMsg('t1', 101, 'c1', 'Read'), + toolResultMsg('t1', 102, 'c1'), + assistantMsg('t1', 103, 'summary'), + ]); + const timeline = turns[0]!.timeline; + assert.deepEqual(timeline.map((i) => i.kind), ['tools', 'text']); + assert.equal((timeline[1] as { text: string }).text, 'summary'); + }); + + it('flushes leftover tools as a trailing group when the turn has no assistant row (abort)', () => { + const turns = materializeTurns([ + userMsg('t1', 100, 'q'), + toolCallStep('t1', 101, 'c1', 'a1'), + ]); + const timeline = turns[0]!.timeline; + assert.deepEqual(timeline.map((i) => i.kind), ['tools']); + assert.equal((timeline[0] as { items: { status: string }[] }).items[0]?.status, 'interrupted'); + }); + + it('appends live-only in-flight tools to the timeline tail', () => { + const turns = materializeTurns( + [userMsg('t1', 100, 'q'), assistantStep('t1', 103, 'a1', 'hi')], + [{ toolUseId: 'live-1', toolName: 'Bash', status: 'running', args: {} }], + ); + const timeline = turns[0]!.timeline; + assert.deepEqual(timeline.map((i) => i.kind), ['text', 'tools']); + assert.equal((timeline[1] as { items: { toolUseId: string }[] }).items[0]?.toolUseId, 'live-1'); + }); + + it('merges adjacent thinking blocks and adjacent tool groups', () => { + const thinkingOnly = materializeTurns([ + userMsg('t1', 100, 'q'), + assistantStep('t1', 101, 'a1', '', 'first'), + assistantStep('t1', 102, 'a2', '', 'second'), + ]); + const tl1 = thinkingOnly[0]!.timeline; + assert.deepEqual(tl1.map((i) => i.kind), ['thinking']); + assert.equal((tl1[0] as { text: string }).text, 'first\n\nsecond'); + + const toolsOnly = materializeTurns([ + userMsg('t1', 100, 'q'), + toolCallStep('t1', 101, 'c1', 'a1'), + assistantStep('t1', 102, 'a1', ''), + toolCallStep('t1', 103, 'c2', 'a2'), + assistantStep('t1', 104, 'a2', ''), + ]); + const tl2 = toolsOnly[0]!.timeline; + assert.deepEqual(tl2.map((i) => i.kind), ['tools']); + assert.equal((tl2[0] as { items: unknown[] }).items.length, 2); + }); +}); + describe('deriveTurnLineageMap', () => { it('derives reverse links without mutating old turns', () => { const map = deriveTurnLineageMap([ diff --git a/packages/ui/src/materialize.ts b/packages/ui/src/materialize.ts index f07eef281a..eb608f8732 100644 --- a/packages/ui/src/materialize.ts +++ b/packages/ui/src/materialize.ts @@ -31,6 +31,14 @@ export interface ToolActivityItem { toolName: string; displayName?: string; intent?: string; + /** + * Assistant step this tool belongs to (equals the step's AssistantMessage + * id). Populated from the persisted `tool_call.stepId`, or from the live + * `ToolStartEvent.stepId` for in-flight tools. The turn timeline uses it to + * place a step's tools after that step's thinking/text; absent means a + * legacy call with no step association. + */ + stepId?: string; status: 'pending' | 'waiting_permission' | 'running' | 'completed' | 'errored' | 'interrupted'; args: unknown; result?: ToolResultContent; @@ -111,6 +119,7 @@ export function materializeTools(messages: StoredMessage[]): ToolActivityItem[] toolName: call.toolName, displayName: call.displayName, intent: call.intent, + ...(call.stepId !== undefined ? { stepId: call.stepId } : {}), status: result ? materializeToolResultStatus(result) : 'interrupted', args: call.args, result: result?.content, @@ -166,6 +175,25 @@ function mergeLiveOverPersisted(persisted: ToolActivityItem, live: ToolActivityI return merged; } +/** + * One entry on a turn's render timeline — the interleaved thinking / answer / + * tool sequence in the order the model actually produced it. This is the + * rendering source of truth (see `TurnViewModel.timeline`); the aggregate + * `assistant` / `assistantThinking` fields are kept only for older consumers + * (copy, export, prompt rail). + * + * - `thinking`: one reasoning block (a step's thinking; adjacent blocks are + * pre-merged with `\n\n`). Rendered as a collapsed "深度思考" disclosure. + * - `text`: one assistant answer segment (a step's text). `ts` is the source + * step's wall-clock for hover meta. + * - `tools`: one contiguous group of tool activity, rendered as a single + * Codex-style trow. Adjacent groups are pre-merged. + */ +export type TurnTimelineItem = + | { kind: 'thinking'; text: string; messageId: string } + | { kind: 'text'; text: string; messageId: string; ts?: number } + | { kind: 'tools'; items: ToolActivityItem[] }; + /** * A single conversational turn — typically one user message, the assistant's * tool calls (if any), and the assistant's final answer. Derived as a @@ -198,6 +226,12 @@ export interface TurnViewModel { * user wants to verify the chain of reasoning. */ assistantThinking?: string; + /** + * Interleaved thinking / answer / tool sequence in production order — the + * rendering source of truth for the turn body. Built from the per-step + * assistant rows and each step's paired tools (see buildTurnTimeline). + */ + timeline: TurnTimelineItem[]; /** System notes inside this turn that survive the VISIBLE_SYSTEM_NOTES gate. */ notes: ChatItem[]; /** Wall-clock ts of the earliest message in this turn — used for sorting. */ @@ -233,6 +267,9 @@ export function materializeTurns( const order: string[] = []; const byId = new Map(); const looseTurnId = '__loose'; + // Storage-ordered messages per turn — the raw sequence the timeline pass + // replays to interleave a step's thinking/text with its paired tools. + const messagesByTurn = new Map(); function ensureTurn(turnId: string, startedAt: number): TurnViewModel { let turn = byId.get(turnId); @@ -252,6 +289,7 @@ export function materializeTurns( partialOutputRetained: record?.partialOutputRetained ?? false, tools: [], notes: [], + timeline: [], startedAt, }; byId.set(turnId, turn); @@ -268,6 +306,9 @@ export function materializeTurns( const turnId = (message as { turnId?: string }).turnId ?? looseTurnId; const ts = (message as { ts?: number }).ts ?? 0; const turn = ensureTurn(turnId, ts); + const turnMessageList = messagesByTurn.get(turnId); + if (turnMessageList) turnMessageList.push(message); + else messagesByTurn.set(turnId, [message]); if (message.type === 'user') { turn.user = { id: message.id, @@ -335,23 +376,118 @@ export function materializeTurns( // active turn so they still surface in the right turn. const persistedTools = materializeTools(messages); const liveById = new Map(liveTools.map((tool) => [tool.toolUseId, tool])); + // toolItemByUseId feeds the timeline pass: the fully merged (persisted+live) + // item keyed by toolUseId, so replaying tool_call rows in storage order + // yields the same ToolActivityItem the tools list holds. liveOnlyByTurn + // collects in-flight tools with no persisted call yet — appended to the + // owning turn's timeline tail. + const toolItemByUseId = new Map(); + const liveOnlyByTurn = new Map(); for (const tool of persistedTools) { const live = liveById.get(tool.toolUseId); const merged = live ? mergeLiveOverPersisted(tool, live) : tool; const turnId = turnsByMsg.get(tool.toolUseId) ?? order[order.length - 1] ?? looseTurnId; const turn = ensureTurn(turnId, Date.now()); turn.tools.push(merged); + toolItemByUseId.set(merged.toolUseId, merged); liveById.delete(tool.toolUseId); } for (const liveOnly of liveById.values()) { const turnId = order[order.length - 1] ?? looseTurnId; const turn = ensureTurn(turnId, Date.now()); turn.tools.push(liveOnly); + toolItemByUseId.set(liveOnly.toolUseId, liveOnly); + const bucket = liveOnlyByTurn.get(turnId); + if (bucket) bucket.push(liveOnly); + else liveOnlyByTurn.set(turnId, [liveOnly]); + } + + // Third pass: rebuild each turn's render timeline from its storage-ordered + // messages, interleaving a step's thinking/text with its paired tools. + for (const turnId of order) { + const turn = byId.get(turnId)!; + turn.timeline = buildTurnTimeline( + messagesByTurn.get(turnId) ?? [], + toolItemByUseId, + liveOnlyByTurn.get(turnId) ?? [], + ); } return order.map((turnId) => byId.get(turnId)!); } +/** + * Rebuild a turn's render timeline from its storage-ordered messages. + * + * Ledger order within a turn is tool_call(s) -> tool_result(s) -> + * assistant(step) -> next step's tools -> ... . Walking that sequence: + * + * - tool_call rows buffer their (merged) ToolActivityItem into `pending`, + * tagged by the item's stepId. + * - an assistant row (id === a step's messageId) flushes the buffer around + * its own thinking/text: thinking -> legacy tools (no stepId) -> text -> + * this step's tools. Step order is think->say->call, so thinking and text + * precede the tools whose stepId matches this row; legacy tools (no + * stepId, pre-per-step persistence) keep the old tools-then-summary + * reading and sit before the text. + * - leftover buffered tools (abort / pure-tool turn with no assistant row) + * flush as a trailing tools group, then any live-only in-flight tools. + * + * Empty text/thinking produce no item. Adjacent thinking blocks merge with + * a blank line; adjacent tools groups merge into one trow. + */ +function buildTurnTimeline( + turnMessages: readonly StoredMessage[], + toolItemByUseId: ReadonlyMap, + liveOnly: readonly ToolActivityItem[], +): TurnTimelineItem[] { + const raw: TurnTimelineItem[] = []; + let pending: ToolActivityItem[] = []; + const flushTools = (items: ToolActivityItem[]): void => { + if (items.length > 0) raw.push({ kind: 'tools', items }); + }; + for (const message of turnMessages) { + if (message.type === 'tool_call') { + const item = toolItemByUseId.get(message.id); + if (item) pending.push(item); + } else if (message.type === 'assistant') { + const rowId = message.id; + const legacy = pending.filter((tool) => tool.stepId === undefined); + const matched = pending.filter((tool) => tool.stepId === rowId); + // Tools bound to a later step we haven't reached yet stay buffered. + pending = pending.filter((tool) => tool.stepId !== undefined && tool.stepId !== rowId); + if (message.thinking?.text) { + raw.push({ kind: 'thinking', text: message.thinking.text, messageId: rowId }); + } + flushTools(legacy); + if (message.text.length > 0) { + raw.push({ kind: 'text', text: message.text, messageId: rowId, ts: message.ts }); + } + flushTools(matched); + } + } + flushTools(pending); + flushTools([...liveOnly]); + return mergeAdjacentTimeline(raw); +} + +function mergeAdjacentTimeline(items: readonly TurnTimelineItem[]): TurnTimelineItem[] { + const out: TurnTimelineItem[] = []; + for (const item of items) { + const last = out[out.length - 1]; + if (item.kind === 'thinking' && last?.kind === 'thinking') { + last.text = `${last.text}\n\n${item.text}`; + } else if (item.kind === 'tools' && last?.kind === 'tools') { + last.items = [...last.items, ...item.items]; + } else if (item.kind === 'tools') { + out.push({ kind: 'tools', items: [...item.items] }); + } else { + out.push({ ...item }); + } + } + return out; +} + export interface TurnLineageTarget { retriedToTurnId?: string; regeneratedToTurnId?: string; From 2a27e7cd687573a12879fe88ea2386dc78386b50 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 8 Jul 2026 17:45:39 +0800 Subject: [PATCH 03/18] =?UTF-8?q?feat(ui):=20timeline-rendered=20turn=20bo?= =?UTF-8?q?dy=20with=20=E6=B7=B1=E5=BA=A6=E6=80=9D=E8=80=83=20disclosure?= =?UTF-8?q?=20+=20Codex=20tool=20trow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render the turn body from turn.timeline (materialize.ts) instead of the tools-section + single-answer layout: each step's reasoning, answer, and tools appear in production order. Commits 2 and 3 of the plan land together because both restructure TurnView and can't be judged apart. - DeepThinking: one controlled Collapsible (collapsed by default; no defaultOpen) replacing ReasoningPanel and the .maka-turn-thinking
. Live: shimmering '深度思考' title (new TextShimmer primitive + governance keyframe maka-text-shimmer) and smooth plain-text body that follows the tail; settled: Markdown + '复制思考过程'. '已截断' pill preserved. - ToolTrow: a contiguous tool run as one flat, borderless disclosure — single-tool groups render the tool's own row (no double nesting); multi-tool groups add a summary line (shimmering active-tool description while running, bucketed Chinese counts once settled via the pure summarizeTrowTools) that expands to flat-stacked tool rows. waiting_permission auto-expands the group. ToolActivityCard body extracted as ToolCardBody, shared by card and trow. - Delete reasoning-panel.css + import, the .maka-turn-thinking / .maka-turn-tools token blocks; update chat-marker + 406-motion governance contracts. Full desktop suite (2224) + root typecheck green; trow-summary unit-tested. --- .../chat-marker-cascade-contract.test.ts | 12 +- ...ign-system-governance-406-contract.test.ts | 5 +- .../src/main/__tests__/trow-summary.test.ts | 89 ++++++++ apps/desktop/src/renderer/maka-tokens.css | 99 ++------- apps/desktop/src/renderer/styles.css | 1 - .../src/renderer/styles/reasoning-panel.css | 109 --------- packages/ui/src/chat-view.tsx | 206 +++++++++--------- packages/ui/src/index.ts | 9 + packages/ui/src/primitives/chat.tsx | 52 +++++ packages/ui/src/tool-activity.tsx | 199 ++++++++++++++--- packages/ui/src/tool-activity/trow-summary.ts | 124 +++++++++++ 11 files changed, 576 insertions(+), 329 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/trow-summary.test.ts delete mode 100644 apps/desktop/src/renderer/styles/reasoning-panel.css create mode 100644 packages/ui/src/tool-activity/trow-summary.ts diff --git a/apps/desktop/src/main/__tests__/chat-marker-cascade-contract.test.ts b/apps/desktop/src/main/__tests__/chat-marker-cascade-contract.test.ts index e9bca17278..3757bed336 100644 --- a/apps/desktop/src/main/__tests__/chat-marker-cascade-contract.test.ts +++ b/apps/desktop/src/main/__tests__/chat-marker-cascade-contract.test.ts @@ -40,18 +40,18 @@ describe('chat Marker shell migration contract (#332 PR2)', () => { } }); - it('keeps the turn container + deferred reasoning chrome (out of scope)', async () => { + it('keeps the turn container (out of scope)', async () => { const css = await readAllRendererCss(); for (const selector of [ // The `.maka-turn` flex/measure container is NOT a marker — it stays. '.maka-turn {', - '.maka-turn-tools', '.maka-turn-streaming', '.maka-turn[data-search-highlight="true"]', - // `.maka-turn-thinking` is explicitly deferred (pseudo-element chevron + - // @starting-style fade don't reduce to leaf utilities); it stays authored. - '.maka-turn-thinking', - '.maka-turn-thinking [data-slot="collapsible-trigger"]', + // NOTE: `.maka-turn-thinking` and `.maka-turn-tools` were retired by the + // streaming UI rework — reasoning now renders through the `DeepThinking` + // disclosure (Tailwind-literal chrome + the `maka-text-shimmer` primitive) + // and tools through the flat `ToolTrow`, so the hand-authored committed- + // turn thinking `
` chrome and the tools-section wrapper are gone. ]) { assert.ok(css.includes(selector), `out-of-scope turn rule "${selector}" must be preserved`); } diff --git a/apps/desktop/src/main/__tests__/design-system-governance-406-contract.test.ts b/apps/desktop/src/main/__tests__/design-system-governance-406-contract.test.ts index 1bdc93a560..f3a006615a 100644 --- a/apps/desktop/src/main/__tests__/design-system-governance-406-contract.test.ts +++ b/apps/desktop/src/main/__tests__/design-system-governance-406-contract.test.ts @@ -114,7 +114,10 @@ describe('issue #406 design-system governance contract', () => { 'maka-cursor', 'maka-list-row-streaming-pulse', 'maka-pulse', - 'maka-reasoning-panel-pulse', + // Streaming UI rework: the "深度思考" disclosure title + a working trow's + // active-tool summary sweep light across the label (functional "still + // working" signal), driven by the TextShimmer primitive. + 'maka-text-shimmer', 'maka-shimmer', 'maka-status-spin', 'maka-tool-pulse', diff --git a/apps/desktop/src/main/__tests__/trow-summary.test.ts b/apps/desktop/src/main/__tests__/trow-summary.test.ts new file mode 100644 index 0000000000..8e568d7273 --- /dev/null +++ b/apps/desktop/src/main/__tests__/trow-summary.test.ts @@ -0,0 +1,89 @@ +/** + * Tests for the pure trow-summary helpers (streaming UI rework). The subject + * lives in `@maka/ui`; the test rides in the desktop workspace where node:test + * is wired, like materialize-turns.test.ts. + */ + +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { + activeTrowTool, + isTrowRunning, + summarizeTrowTools, + trowActivityKind, + type ToolActivityItem, +} from '@maka/ui'; + +function tool( + toolName: string, + status: ToolActivityItem['status'] = 'completed', + toolUseId = toolName + Math.random(), +): ToolActivityItem { + return { toolUseId, toolName, status, args: {} }; +} + +describe('trowActivityKind', () => { + it('buckets canonical maka tool names case-insensitively', () => { + assert.equal(trowActivityKind('Read'), 'read'); + assert.equal(trowActivityKind('Glob'), 'search'); + assert.equal(trowActivityKind('Grep'), 'search'); + assert.equal(trowActivityKind('WebSearch'), 'websearch'); + assert.equal(trowActivityKind('WebFetch'), 'webfetch'); + assert.equal(trowActivityKind('Write'), 'edit'); + assert.equal(trowActivityKind('Edit'), 'edit'); + assert.equal(trowActivityKind('Bash'), 'command'); + assert.equal(trowActivityKind('ExploreAgent'), 'explore'); + assert.equal(trowActivityKind('browser_click'), 'browser'); + assert.equal(trowActivityKind('OfficeDocument'), 'tool'); + }); +}); + +describe('summarizeTrowTools', () => { + it('buckets by type in first-seen order joined with 「,」', () => { + const summary = summarizeTrowTools([ + tool('Read'), + tool('Read'), + tool('Read'), + tool('Grep'), + tool('Grep'), + ]); + assert.equal(summary, '读取 3 个文件,搜索 2 次'); + }); + + it('preserves first-seen order even when kinds interleave', () => { + const summary = summarizeTrowTools([tool('Grep'), tool('Read'), tool('Grep')]); + assert.equal(summary, '搜索 2 次,读取 1 个文件'); + }); + + it('appends 「N 个失败」 while still counting failed tools in their type bucket', () => { + const summary = summarizeTrowTools([ + tool('Read'), + tool('Read', 'errored'), + tool('Bash', 'errored'), + ]); + assert.equal(summary, '读取 2 个文件,运行 1 条命令,2 个失败'); + }); + + it('falls back to the generic bucket for unknown tools', () => { + assert.equal(summarizeTrowTools([tool('OfficeDocument'), tool('RiveWorkflow')]), '调用 2 个工具'); + }); +}); + +describe('activeTrowTool + isTrowRunning', () => { + it('reports running while any tool is in flight and picks the last in-flight tool', () => { + 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', () => { + const items = [tool('Read'), tool('Grep')]; + assert.equal(isTrowRunning(items), false); + assert.equal(activeTrowTool(items)?.toolName, 'Grep'); + }); + + it('prefers waiting_permission as active', () => { + const items = [tool('Read', 'completed'), tool('Write', 'waiting_permission')]; + assert.equal(activeTrowTool(items)?.status, 'waiting_permission'); + }); +}); diff --git a/apps/desktop/src/renderer/maka-tokens.css b/apps/desktop/src/renderer/maka-tokens.css index ea53e3e7ac..283a9e528d 100644 --- a/apps/desktop/src/renderer/maka-tokens.css +++ b/apps/desktop/src/renderer/maka-tokens.css @@ -1217,86 +1217,11 @@ content-visibility: visible; } - .maka-turn-tools { - padding: 0 var(--space-2) 0 0; - } - - /* Optional reasoning block — collapsed by default, expandable to inspect - * the model's chain of thought without cluttering the answer. - * - * PR-REASONING-PANEL-SMOOTH-EXPAND-0 (WAWQAQ msg `145f15d5`, - * skills round task #116): native `
` had no toggle - * feedback — clicking flicked content in/out without any handle. - * Polishes the summary into a real affordance: - * - Replace browser-default disclosure triangle (varies per UA) - * with a custom chevron that rotates 90deg on `[open]` using - * a spring-ish cubic-bezier from design-taste-frontend §5 - * (var(--duration-emphasized) `cubic-bezier(0.16, 1, 0.3, 1)`). - * `cursor: pointer` deliberately NOT applied to the summary — - * macOS reserves the hand for links; details summaries use the - * default arrow. Enforced by `cursor-convention-contract.test.ts`. - * Native height animation requires `interpolate-size` + per- - * element JS state and isn't a single-element CSS fix; deferred - * until we route reasoning through Base UI's Accordion. */ - .maka-turn-thinking { - margin: 0 0 var(--space-2); - padding: var(--space-2) var(--space-2-5); - border: var(--border-width-hairline) dashed oklch(from var(--focus-ring) l c h / 0.25); - border-radius: var(--radius-surface); - background: oklch(from var(--focus-ring) l c h / 0.03); - font-size: var(--font-size-ui); - color: var(--foreground-secondary); - } - - .maka-turn-thinking [data-slot="collapsible-trigger"] { - display: flex; - align-items: center; - gap: var(--space-2); - font-weight: var(--font-weight-semibold); - color: var(--focus-ring); - letter-spacing: var(--tracking-wide); - /* `cursor: pointer` intentionally omitted — native macOS reserves - the hand cursor for links; the Collapsible trigger is a button-like - control and uses the default arrow. Enforced by - `cursor-convention-contract.test.ts`. */ - } - - /* Replace the default disclosure triangle with a custom chevron - that rotates smoothly on toggle. */ - .maka-turn-thinking [data-slot="collapsible-trigger"]::before { - content: ''; - flex-shrink: 0; - width: 0; - height: 0; - border-style: solid; - border-width: 4px 0 4px 5px; - border-color: transparent transparent transparent currentColor; - transition: transform var(--duration-emphasized) var(--ease-out-strong); - } - .maka-turn-thinking [data-slot="collapsible-trigger"][data-panel-open]::before { - transform: rotate(90deg); - } - - .maka-turn-thinking-note { - font-size: var(--font-size-caption); - font-weight: var(--font-weight-medium); - letter-spacing: var(--tracking-wider); - color: var(--muted-foreground); - text-transform: uppercase; - } - - .maka-turn-thinking-body { - margin-top: var(--space-1-5); - line-height: var(--leading-normal); - font-style: italic; - opacity: var(--opacity-pending); - } - - .maka-turn-thinking-actions { - display: flex; - justify-content: flex-end; - margin-top: var(--space-1-5); - } + /* The streaming UI rework retired `.maka-turn-tools` (the tools-section + wrapper) and the `.maka-turn-thinking` reasoning `
` chrome. Tools + now render inline via the flat `ToolTrow` and reasoning via the + `DeepThinking` disclosure, both Tailwind-literal + the `TextShimmer` + primitive — see packages/ui/src/tool-activity.tsx and chat-view.tsx. */ /* The streaming bubble (out-of-band, before the in-progress turn is * fully persisted) sits at the bottom of the chat surface; give it the @@ -1519,6 +1444,20 @@ 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } } + +/* Governance-level keyframe for the `TextShimmer` primitive (streaming UI + rework). A named `@keyframes` is a global rule, not an element property, so + it can't be a Tailwind leaf-literal — it lives here (the shared motion home) + like `maka-pulse` / `maka-tool-pulse`, with the shimmer's other declarations + as literal utilities in the primitive. Sweeps a clipped light band left→right + across the glyph shape by translating `background-position`; the two-layer + primitive keeps an opaque base underneath so the text stays readable. Frozen + by the global reduced-motion / visual-smoke rules in base.css and the + primitive's own `motion-reduce:` guards. */ +@keyframes maka-text-shimmer { + from { background-position: 150% 0; } + to { background-position: -150% 0; } +} .maka-shimmer { background-image: linear-gradient( 90deg, diff --git a/apps/desktop/src/renderer/styles.css b/apps/desktop/src/renderer/styles.css index 1fbe13cb9e..c7ea985735 100644 --- a/apps/desktop/src/renderer/styles.css +++ b/apps/desktop/src/renderer/styles.css @@ -34,7 +34,6 @@ @import "./styles/composer.css"; @import "./styles/chat-detail.css"; @import "./styles/settings.css"; -@import "./styles/reasoning-panel.css"; @import "./styles/markdown-link.css" layer(components); @import "./styles/permission-dialog.css"; @import "./styles/daily-review.css"; diff --git a/apps/desktop/src/renderer/styles/reasoning-panel.css b/apps/desktop/src/renderer/styles/reasoning-panel.css deleted file mode 100644 index e412eafb47..0000000000 --- a/apps/desktop/src/renderer/styles/reasoning-panel.css +++ /dev/null @@ -1,109 +0,0 @@ - - /* ============================================================================= - PR-UI-LAYOUT-42 — Reasoning panel (Anthropic extended thinking) - - Renders live `ThinkingDeltaEvent.text` accumulated per session in - `thinkingBySession`. Visible above the streaming answer bubble while - the model is reasoning. Base UI Collapsible wrapper for keyboard - a11y. Default-open so users see the live reasoning; clicking the - header collapses. - ============================================================================= */ -.maka-reasoning-panel { - margin: var(--space-1) 0 var(--space-2); - border: var(--border-width-hairline) solid oklch(from var(--info) l c h / 0.22); - border-radius: var(--radius-surface); - background: oklch(from var(--info) l c h / 0.04); - overflow: hidden; - } -.maka-reasoning-panel[data-live="true"] { - border-color: oklch(from var(--info) l c h / 0.32); - } -.maka-reasoning-panel-header { - display: flex; - align-items: center; - gap: var(--space-1-5); - padding: var(--space-1) var(--space-2); - font-size: var(--font-size-caption); - font-weight: var(--font-weight-semibold); - letter-spacing: var(--tracking-wide); - color: var(--info-text); - user-select: none; - transition: background var(--duration-base) var(--ease-out-strong); - } -.maka-reasoning-panel-header:hover { - background: oklch(from var(--info) l c h / 0.08); - } -.maka-reasoning-panel-header:focus-visible { - outline: none; - background: oklch(from var(--info) l c h / 0.10); - box-shadow: 0 0 0 3px oklch(from var(--info) l c h / 0.18); - } -.maka-reasoning-panel-dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--info); - /* Live pulse — collapses under prefers-reduced-motion via the rule below. */ - animation: maka-reasoning-panel-pulse 1.4s var(--ease-in-out-strong) infinite; - } -@keyframes maka-reasoning-panel-pulse { - 0%, 100% { opacity: 0.65; transform: scale(1); } - /* PR-FE-BUG-HUNT-6 (kenji aesthetic audit reminder 9, finding #5): - standardize live-dot pulse amplitude across the three streaming - indicators. The semantic is identical ("live work happening") - but the amplitudes drifted: list-row dot 10%, reasoning-panel - dot 15%, tool-output dot 18%. Settled on scale(1.1) — the - calmest of the three — because the dots run continuously while - a turn is streaming and the larger pulses read as agitated. */ - 50% { opacity: 1; transform: scale(1.1); } - } -@media (prefers-reduced-motion: reduce) { - .maka-reasoning-panel-dot { - animation: none; - opacity: var(--opacity-pending); - } - } -.maka-reasoning-panel-label { - flex: 1; - min-width: 0; - } -/* PR-UI-C0 review fixup (@kenji msg 7885a347) — "已截断" pill - fires when `applyThinkingDelta` / `applyThinkingComplete` - dropped content. Same family as the A3 tool-output truncated - pill: warning tone, rounded rect, cursor:help. */ -.maka-reasoning-panel-truncated[data-truncated="true"] { - font-size: var(--font-size-caption); - color: var(--warning-text, var(--info-text)); - border: var(--border-width-hairline) solid oklch(from var(--warning) l c h / 0.24); - background: oklch(from var(--warning) l c h / 0.05); - border-radius: var(--radius-control); - padding: 0 var(--space-1); - cursor: help; - } -/* The streaming "已截断" pill (PR-UI-Cx, @kenji msg cd09bcac) moved onto the - `Bubble variant="assistant"` chat primitive as inline utilities (issue - #332 PR1); its sibling `.maka-reasoning-panel-truncated` pill below keeps - the same visual family. */ -.maka-reasoning-panel-chevron { - font-size: var(--font-size-ui); - line-height: var(--leading-none); - color: var(--muted-foreground); - transition: transform var(--duration-base) var(--ease-out-strong); - } -.maka-reasoning-panel [data-slot="collapsible-trigger"][data-panel-open] .maka-reasoning-panel-chevron { - transform: rotate(90deg); - } -.maka-reasoning-panel-body { - margin: 0; - padding: var(--space-1-5) var(--space-2-5) var(--space-2); - border-top: var(--border-width-hairline) solid oklch(from var(--info) l c h / 0.14); - background: transparent; - color: var(--foreground-secondary); - font-family: var(--font-mono); - font-size: var(--font-size-caption); - line-height: var(--leading-normal); - max-height: 280px; - overflow-y: auto; - white-space: pre-wrap; - word-break: break-word; - } diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index d4e083d906..ad031dad38 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -7,6 +7,7 @@ import { BookOpen, CalendarDays, Check, + ChevronRight, Copy, GitBranch, Info, @@ -25,12 +26,12 @@ import { DialogContent, DialogRoot } from './ui.js'; import { PromptAnchorRail } from './prompt-anchor-rail.js'; import type { AttachmentRef, PlanReminder, ProviderType, SessionSummary, StoredMessage } from '@maka/core'; import { deriveCapabilityAuditReport, isDeepResearchSession } from '@maka/core'; -import { materializeChat, materializeTools, materializeTurns, type ToolActivityItem, type TurnViewModel } from './materialize.js'; +import { materializeChat, materializeTools, materializeTurns, type ToolActivityItem, type TurnTimelineItem, type TurnViewModel } from './materialize.js'; import { Button as UiButton } from './ui.js'; import { AttachmentFileCard } from './attachment-file-card.js'; import { Alert, AlertDescription } from './primitives/alert.js'; import { Collapsible, CollapsibleTrigger, CollapsiblePanel } from './primitives/collapsible.js'; -import { Bubble, Marker, markerVariants, Message } from './primitives/chat.js'; +import { Bubble, Marker, markerVariants, Message, TextShimmer } from './primitives/chat.js'; import { Tooltip, TooltipTrigger, TooltipContent } from './primitives/tooltip.js'; import type { NavSelection } from './nav-selection.js'; import { EmptyState } from './empty-state.js'; @@ -70,7 +71,7 @@ function ModulePanelFallback(props: { message: string }) { ); } import { RelativeTime } from './relative-time.js'; -import { ToolActivity } from './tool-activity.js'; +import { ToolTrow } from './tool-activity.js'; /** * Lifecycle status badge in the chat header (PR109b §9.8). Visual @@ -654,7 +655,7 @@ export function ChatView(props: { * text_complete / abort / error (parent clears the * thinkingBySession entry). */} {props.thinkingText && ( - ))} - {turn.tools.length > 0 && ( -
- -
- )} - {turn.assistant && ( + {turn.timeline.length > 0 && ( -
- {turn.assistantThinking && ( - - - 查看思考过程 - 模型推理草稿,不是最终答案 - - -
- -
- -
-
-
-
- )} - {/* PR109d-c: aborted turn body gets a muted "(已中断)" prefix - + Ban icon so the user can see this turn was cancelled - without it looking like a fault state (which is reserved - for `failed`). Lives in the message body wrapper so the - Copy button below still copies the assistant text without - the prefix. */} +
+ {/* 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 + per-segment Copy buttons still yank clean answer text. */} {turn.status === 'aborted' && ( )} - + {/* The turn timeline is the rendering source of truth + (materialize.ts): each step's 深度思考 disclosure, answer bubble, + and Codex-style tool trow in the order the model produced them. */} + {turn.timeline.map((item, index) => ( + + ))}
{reverseBadges.length > 0 && ( @@ -1126,7 +1109,7 @@ const TurnView = memo(function TurnView(props: { props.onFooterAction?.(turn.turnId, actionId) : undefined} - assistantText={turn.assistant.text} + assistantText={turn.assistant?.text ?? ''} /> )} @@ -1338,25 +1321,6 @@ const STATUS_FOOTER_ICON: Record = { info: