diff --git a/apps/desktop/src/main/__tests__/imported-reply-fold.test.ts b/apps/desktop/src/main/__tests__/imported-reply-fold.test.ts index d9cc2f7e0e..e04b4af591 100644 --- a/apps/desktop/src/main/__tests__/imported-reply-fold.test.ts +++ b/apps/desktop/src/main/__tests__/imported-reply-fold.test.ts @@ -56,7 +56,7 @@ test('completed OpenCode text followed by reasoning stays outside the process di const turn = turns[0]!; assert.equal(turn.status, 'completed'); assert.deepEqual(turn.timeline.map((item) => item.kind), ['text', 'thinking']); - const folded = foldTimeline(turn.timeline); + const folded = foldTimeline(turn.timeline).entries; assert.deepEqual(folded.filter((item) => item.kind === 'text').map((item) => item.text), ['Visible final answer']); const process = folded.find((item) => item.kind === 'processing'); assert.ok(process); diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 043ac3f489..75df23c912 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -23,16 +23,20 @@ import { useEffect, useReducer, useState, type CSSProperties, type ReactNode } f import type { ComponentProps } from 'react'; import type { ProjectRecord } from '@maka/core/project'; import type { SessionSummary, StoredMessage } from '@maka/core/session'; +import type { SessionEvent } from '@maka/core/events'; import { ChatSurfaceLayout, ChatView, + applyLiveTurnBufferEvent, + reconcileLiveTurnBuffer, + settleLiveTurnBufferStep, Composer, createTranscriptViewportNavigation, deriveTitlebarProjectName, TitlebarSessionIdentity, ToastProvider, } from '@maka/ui'; -import type { ChatModelChoice, SessionViewMode, TurnViewModel } from '@maka/ui'; +import type { ChatModelChoice, SessionViewMode, TurnViewModel, LiveTurnBuffer } from '@maka/ui'; import { SessionRail, type SessionRailStoryProps } from '../../../packages/ui/stories/session-rail-harness.js'; import { AppShellTopbarActions } from '../src/renderer/app-shell-chrome-actions'; import { SettingsOverlay } from '../src/renderer/app-shell-overlays'; @@ -58,6 +62,7 @@ import { deriveSessionRevisionNavigation, } from '../src/renderer/features/session-navigation/testing'; import { AppShell as AstryxAppShell } from '@astryxdesign/core/AppShell'; +import { Button } from '@astryxdesign/core'; import { GoalDialog } from '../src/renderer/features/goals/testing'; const NOW = Date.UTC(2026, 6, 1, 9, 30, 0); @@ -600,10 +605,8 @@ export const StreamingTurn: Story = { }; // Real path: ask for something long-running → a tool has been going for -// minutes and the model has produced nothing to look at. The cue this replaced -// was hidden exactly here — it only covered the gap before the first content -// event — so this state used to offer no evidence the harness was still -// working. +// minutes and the model has produced no commentary. The current process +// summary owns the working phrase; the tool owns the visible spinner. // // What renders is the frozen form: the shell frame carries the e2e-fixture // attribute, and the elapsed clock is dropped rather than pinned under it, @@ -635,6 +638,24 @@ export const RunningStatusDuringToolRun: Story = { }} /> ), + play: async ({ canvasElement }) => { + const process = canvasElement.querySelector('.maka-processing-sequence')!; + const activity = process.querySelector('.maka-turn-processing')!; + await expect(process.open).toBe(true); + await expect(activity).toHaveTextContent('正在琢磨…'); + await expect(canvasElement.querySelectorAll('.maka-turn-processing')).toHaveLength(1); + await expect(canvasElement.querySelector('.maka-turn-footer .maka-turn-processing')).toBeNull(); + // Live work is not a disclosure action. Even pointer activation cannot + // hide it; the tool keeps ownership of its visible spinner. + const summary = process.querySelector('summary')!; + await expect(summary).toHaveAttribute('aria-disabled', 'true'); + await expect(summary).toHaveAttribute('tabindex', '-1'); + await expect(summary.querySelector(':scope > svg')).toBeNull(); + await expect(activity.querySelector('.astryx-spinner')).toBeNull(); + await userEvent.click(summary); + await waitFor(() => expect(process.open).toBe(true)); + await expect(activity.querySelector('.astryx-spinner')).toBeNull(); + }, }; // A real prefix of `npm test` stdout, copied verbatim from an actual run killed @@ -3697,19 +3718,138 @@ export const ContextCompactionFailed: Story = { const processDisclosureMessages: StoredMessage[] = [ { type: 'user', id: 'process-ask', turnId: 'process-turn', ts: NOW - 213_000, text: '修复刷新页面后登录状态丢失的问题。' }, { type: 'turn_state', id: 'process-running', turnId: 'process-turn', ts: NOW - 213_000, status: 'running' }, - { type: 'assistant', id: 'process-check', turnId: 'process-turn', ts: NOW - 200_000, text: '我先检查登录状态的存储和恢复逻辑。', thinking: { text: '检查初始化时机与会话恢复顺序。' }, modelId: 'claude-sonnet-4-5' }, { type: 'tool_call', id: 'process-read', turnId: 'process-turn', ts: NOW - 190_000, toolName: 'Read', activityKind: 'read', stepId: 'process-check', args: { path: 'src/auth-store.ts' } }, { type: 'tool_result', id: 'process-read-result', turnId: 'process-turn', ts: NOW - 185_000, toolUseId: 'process-read', isError: false, content: { kind: 'text', text: 'export function restoreSession() { return storage.getItem("session"); }' } }, - { type: 'assistant', id: 'process-fix', turnId: 'process-turn', ts: NOW - 170_000, text: '恢复时机有问题,接下来补上初始化。', modelId: 'claude-sonnet-4-5' }, + { type: 'assistant', id: 'process-check', turnId: 'process-turn', ts: NOW - 184_000, text: '我先检查登录状态的存储和恢复逻辑。', thinking: { text: '检查初始化时机与会话恢复顺序。' }, modelId: 'claude-sonnet-4-5' }, { type: 'tool_call', id: 'process-edit', turnId: 'process-turn', ts: NOW - 160_000, toolName: 'Edit', activityKind: 'edit', stepId: 'process-fix', args: { path: 'src/auth-store.ts', old_string: 'const session = null;', new_string: 'const session = restoreSession();' } }, { type: 'tool_result', id: 'process-edit-result', turnId: 'process-turn', ts: NOW - 150_000, toolUseId: 'process-edit', isError: false, content: { kind: 'text', text: 'Updated src/auth-store.ts' } }, - { type: 'assistant', id: 'process-verify', turnId: 'process-turn', ts: NOW - 100_000, text: '初始化已补齐,现在运行登录状态的回归测试。', modelId: 'claude-sonnet-4-5' }, + { type: 'assistant', id: 'process-fix', turnId: 'process-turn', ts: NOW - 149_000, text: '恢复时机有问题,接下来补上初始化。', modelId: 'claude-sonnet-4-5' }, { type: 'tool_call', id: 'process-test', turnId: 'process-turn', ts: NOW - 90_000, toolName: 'Bash', activityKind: 'command', stepId: 'process-verify', args: { command: 'npm test -- auth-store.test.ts' } }, { type: 'tool_result', id: 'process-test-result', turnId: 'process-turn', ts: NOW - 5_000, toolUseId: 'process-test', isError: false, content: { kind: 'text', text: 'Tests passed: 4' } }, + { type: 'assistant', id: 'process-verify', turnId: 'process-turn', ts: NOW - 4_000, text: '初始化已补齐,现在运行登录状态的回归测试。', modelId: 'claude-sonnet-4-5' }, { type: 'assistant', id: 'process-answer', turnId: 'process-turn', ts: NOW, text: '已修复登录状态恢复。\n\n刷新页面后会恢复已有会话;相关测试通过。', modelId: 'claude-sonnet-4-5' }, { type: 'turn_state', id: 'process-completed', turnId: 'process-turn', ts: NOW, status: 'completed' }, ]; +// The review controls schedule real stream events and durable ledger receipts. +// Text arrives live before tools; its assistant row lands after tool results. +const processPlaybackFrames: Array<{ events: SessionEvent[]; messages: StoredMessage[] }> = []; +for (const reply of processDisclosureMessages) { + if (reply.type !== 'assistant') continue; + const call = processDisclosureMessages.find((message) => message.type === 'tool_call' && message.stepId === reply.id); + const result = call && processDisclosureMessages.find((message) => message.type === 'tool_result' && message.toolUseId === call.id); + const textEvent = { turnId: reply.turnId!, messageId: reply.id, ts: reply.ts }; + if (call?.type === 'tool_call' && result?.type === 'tool_result') { + processPlaybackFrames.push({ + events: [ + ...(reply.thinking ? [{ ...textEvent, type: 'thinking_delta' as const, id: `${reply.id}-thinking`, text: reply.thinking.text }] : []), + { ...textEvent, type: 'text_delta', id: `${reply.id}-delta`, text: reply.text }, + { type: 'tool_start', id: `${call.id}-start`, turnId: reply.turnId!, ts: call.ts, stepId: reply.id, toolUseId: call.id, toolName: call.toolName, args: call.args, activityKind: call.activityKind }, + ], messages: [call], + }, { + events: [result, + ...(reply.thinking ? [{ ...textEvent, type: 'thinking_complete' as const, id: `${reply.id}-thinking-done`, text: reply.thinking.text }] : []), + { ...textEvent, type: 'text_complete', id: `${reply.id}-text-done`, text: reply.text }, + ], messages: [result, reply], + }); + } else { + const split = reply.text.indexOf('\n\n'); + processPlaybackFrames.push( + { events: [{ ...textEvent, type: 'text_delta', id: `${reply.id}-delta-1`, text: reply.text.slice(0, split) }], messages: [] }, + { events: [ + { ...textEvent, type: 'text_delta', id: `${reply.id}-delta-2`, text: reply.text.slice(split) }, + { ...textEvent, type: 'text_complete', id: `${reply.id}-text-done`, text: reply.text }, + ], messages: [reply] }, + ); + } +} +processPlaybackFrames.push({ + events: [{ type: 'complete', id: 'process-terminal', turnId: 'process-turn', ts: NOW, stopReason: 'end_turn' }], + messages: [processDisclosureMessages.at(-1)!], +}); + +type ProcessPlayback = { index: number; startedAt: number; messages: StoredMessage[]; liveTurns: LiveTurnBuffer | undefined }; +function advanceProcessPlayback(previous: ProcessPlayback): ProcessPlayback { + const index = previous.index + 1; + const frame = processPlaybackFrames[index]; + if (!frame) return previous; + const ts = previous.startedAt + index * 1000; + let liveTurns = previous.liveTurns; + for (const event of frame.events) liveTurns = applyLiveTurnBufferEvent(liveTurns, { ...event, ts }, 'zh-CN'); + const messages = [...previous.messages, ...frame.messages.map((message) => ({ ...message, ts }))]; + return { ...previous, index, messages, liveTurns: liveTurns ? reconcileLiveTurnBuffer(liveTurns, messages) : undefined }; +} +function startProcessPlayback(): ProcessPlayback { + const startedAt = Date.now(); + return advanceProcessPlayback({ index: -1, startedAt, liveTurns: undefined, + messages: processDisclosureMessages.slice(0, 2).map((message) => ({ ...message, ts: startedAt })), + }); +} +function ProcessReplyLifecycle() { + const [playback, dispatch] = useReducer((state: ProcessPlayback, action: { type: 'advance' } | { type: 'replay' } | { type: 'settled'; messageId?: string }): ProcessPlayback => { + if (action.type === 'replay') return startProcessPlayback(); + if (action.type === 'advance') return advanceProcessPlayback(state); + if (!action.messageId || !state.liveTurns) return state; + const liveTurns = settleLiveTurnBufferStep(state.liveTurns, action.messageId); + return liveTurns === state.liveTurns ? state : { ...state, liveTurns: liveTurns ? reconcileLiveTurnBuffer(liveTurns, state.messages) : undefined }; + }, undefined, startProcessPlayback); + const [playing, setPlaying] = useState(false); + const completed = playback.index === processPlaybackFrames.length - 1; + useEffect(() => { + if (!playing || completed) return; + const timer = window.setTimeout(() => dispatch({ type: 'advance' }), 1000); + return () => window.clearTimeout(timer); + }, [playback.index, playing, completed]); + return
+
+ 演示控制 · 每秒推进一组真实事件 +
+ dispatch({ type: 'settled', messageId }), + }} /> +
; +} + +// Real path: a running turn receives tool results, then its final assistant +// message, then a completed event. The same mounted turn automatically folds. +export const ProcessReplyLifecycleComplete: Story = { + name: '完整过程:展开 → 最终回答 → 自动折叠', + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const process = canvasElement.querySelector('.maka-processing-sequence')!; + await expect(process.open).toBe(true); + await expect(process.querySelector('summary')).toHaveAttribute('aria-disabled', 'true'); + await userEvent.click(canvas.getByRole('button', { name: '播放完整过程' })); + const answer = await canvas.findByText('已修复登录状态恢复。', {}, { timeout: 12_000 }); + await expect(process.open).toBe(true); + await expect(process.contains(answer)).toBe(false); + const answerBubble = answer.closest('.maka-chat-message-bubble-assistant')!; + await expect(answerBubble).toHaveAttribute('data-live-streaming', 'true'); + await waitFor(() => expect(process.open).toBe(false), { timeout: 3000 }); + await waitFor(() => expect(process.getBoundingClientRect().height).toBeLessThanOrEqual(process.querySelector('summary')!.getBoundingClientRect().height + 1)); + await expect(canvasElement.querySelector('.maka-processing-sequence')).toBe(process); + // Streaming Markdown can replace its temporary text spans. The answer + // surface itself must survive the live-to-durable handoff and folding. + const finalAnswer = canvas.getByText('已修复登录状态恢复。'); + await expect(finalAnswer.closest('.maka-chat-message-bubble-assistant')).toBe(answerBubble); + await expect(answerBubble).not.toHaveAttribute('data-live-streaming'); + await expect(finalAnswer).toBeVisible(); + await expect(finalAnswer.getBoundingClientRect().top).toBeGreaterThanOrEqual(process.getBoundingClientRect().bottom); + await expect(await canvas.findByText('我先检查登录状态的存储和恢复逻辑。')).not.toBeVisible(); + // The completed scene is the landing state; reviewers can replay it. + await expect(canvas.getByRole('button', { name: '重新播放' })).toBeVisible(); + }, +}; + // Real path: session → a completed multi-step reply. Intermediate commentary, // reasoning and tools are collapsed above the answer in the real chat frame. export const CompletedProcessCollapsed: Story = { @@ -3729,18 +3869,33 @@ export const CompletedProcessCollapsed: Story = { }; // Real path: the same completed reply → open the elapsed-time disclosure. -// Browser coverage checks its focus and that collapsing the process preserves +// Browser coverage checks bidirectional motion, focus, and that collapsing preserves // a selection in the final answer. Native summary keyboard activation remains // browser-owned; userEvent does not emulate its Enter or focus behavior. export const CompletedProcessExpanded: Story = { - render: CompletedProcessCollapsed.render, + render: () => , play: async ({ canvasElement }) => { const process = canvasElement.querySelector('.maka-processing-sequence')!; const summary = process.querySelector('summary')!; await within(canvasElement).findByText('已修复登录状态恢复。'); + // Check the browser-applied motion contract without assuming a frame will + // run during the transition. A busy runner may paint only the endpoint; + // ::details-content does not reliably expose Animation objects/events. + const motion = getComputedStyle(process, '::details-content'); + const properties = motion.transitionProperty.split(',').map((value) => value.trim()); + const durations = motion.transitionDuration.split(',').map((value) => parseFloat(value)); + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + await expect(durations.every((duration) => duration === 0)).toBe(true); + } else { + const gridIndex = properties.indexOf('grid-template-rows'); + await expect(gridIndex).toBeGreaterThanOrEqual(0); + await expect(durations[gridIndex % durations.length]).toBeGreaterThan(0); + await expect(motion.transitionBehavior).toContain('allow-discrete'); + } summary.focus(); summary.click(); await waitFor(() => expect(process.open).toBe(true)); + await waitFor(() => expect(process.getBoundingClientRect().height).toBeGreaterThanOrEqual(summary.getBoundingClientRect().height + process.querySelector('.maka-processing-content')!.offsetHeight - 1)); await expect(summary).toHaveFocus(); await expect(await within(canvasElement).findByText('我先检查登录状态的存储和恢复逻辑。')).toBeVisible(); const answer = await within(canvasElement).findByText('已修复登录状态恢复。'); @@ -3756,6 +3911,7 @@ export const CompletedProcessExpanded: Story = { // this checks React/layout identity, rather than browser mouse semantics. summary.click(); await waitFor(() => expect(process.open).toBe(false)); + await waitFor(() => expect(process.getBoundingClientRect().height).toBeLessThanOrEqual(summary.getBoundingClientRect().height + 1)); await expect(selection.toString()).toBe(selected); await expect(answer.isConnected).toBe(true); summary.click(); diff --git a/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx b/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx index b92f856f9e..94788f9300 100644 --- a/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx +++ b/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx @@ -24,7 +24,7 @@ import { afterEach, test } from 'node:test'; import { act, StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { parseHTML } from 'linkedom'; -import { LocalizedChatMessage, TurnView } from '../chat-turn.js'; +import { LocalizedChatMessage, TurnRunningStatus, TurnView } from '../chat-turn.js'; import { LocaleProvider } from '../locale-context.js'; import type { TurnTimelineItem, TurnViewModel } from '../materialize.js'; @@ -453,6 +453,47 @@ test('keeps Astryx auto formatting live for user-message timestamps', async (con assert.match(timestamp.textContent ?? '', /3 hours ago/); }); +test('rotates working phrases on the elapsed clock without announcing each phrase', async (context) => { + const now = Date.UTC(2026, 8, 14, 12); + context.mock.timers.enable({ apis: ['Date', 'setInterval'], now }); + const { container, root } = domRoot(); + await act(() => root.render()); + const status = container.querySelector('[role="status"]')!; + assert.match(status.textContent, /Pondering/); + assert.equal(status.getAttribute('aria-label'), 'Working…'); + await act(() => context.mock.timers.tick(20_000)); + assert.match(status.textContent, /Tinkering/); + assert.match(status.textContent, /20s/); + assert.equal(status.getAttribute('aria-label'), 'Working…'); + // Concrete activity takes precedence over the playful phrase. + await act(() => root.render()); + assert.match(status.textContent, /Clicking Save/); + assert.doesNotMatch(status.textContent, /Tinkering/); +}); + +test('keeps elapsed time while system motion preference changes the working phrase', async (context) => { + const now = Date.UTC(2026, 8, 14, 12); + context.mock.timers.enable({ apis: ['Date', 'setInterval'], now }); + const { container, root } = domRoot(); + let reduced = true; + const listeners = new Set<() => void>(); + Object.assign(globalThis, { matchMedia: () => ({ + get matches() { return reduced; }, + addEventListener(_type: string, listener: () => void) { listeners.add(listener); }, + removeEventListener(_type: string, listener: () => void) { listeners.delete(listener); }, + }) }); + await act(() => root.render()); + await act(() => context.mock.timers.tick(20_000)); + assert.equal(container.querySelector('.maka-turn-status-label')?.textContent, 'Pondering…'); + assert.equal(container.querySelector('.maka-turn-elapsed')?.textContent, '20s'); + await act(() => { reduced = false; listeners.forEach((listener) => listener()); }); + assert.equal(container.querySelector('.maka-turn-status-label')?.textContent, 'Tinkering…'); + await act(() => { reduced = true; listeners.forEach((listener) => listener()); }); + await act(() => context.mock.timers.tick(20_000)); + assert.equal(container.querySelector('.maka-turn-status-label')?.textContent, 'Pondering…'); + assert.equal(container.querySelector('.maka-turn-elapsed')?.textContent, '40s'); +}); + /** * The live handoff announces itself exactly once, when the answer enters its * settled phase. A bubble replayed from history mounts already past the @@ -641,18 +682,49 @@ test('automatically folds a running process on completion without remounting the assert.equal(container.querySelectorAll('.maka-chat-message-bubble-assistant')[1]?.isSameNode(answer!), true); }); -test('respects a manual expansion through appended events and completion', async () => { +test('copy uses the visible final reply after completion and disclosure toggles', async () => { + const { container, root } = domRoot(); + const copied: string[] = []; + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { + writeText: async (text: string) => { copied.push(text); }, + } }); + const timeline = [PROCESS_TEXT, COMPLETED_TOOL, { ...ANSWER, live: false }]; + await renderTurn(root, turnWith(timeline)); + await act(() => root.render()); + const process = container.querySelector('details.maka-processing-sequence'); + const summary = process?.querySelector('summary'); + const copy = container.querySelector('[data-action="copy"]'); + assert.ok(process && summary && copy); + assert.equal(process.hasAttribute('open'), false); + assert.doesNotMatch(process.textContent ?? '', /the answer/); + for (let index = 0; index < 3; index += 1) { + await act(async () => { copy.dispatchEvent(new window.Event('click', { bubbles: true })); }); + await act(() => { summary.dispatchEvent(new window.Event('click', { bubbles: true, cancelable: true })); }); + } + assert.deepEqual(copied, [ANSWER.text, ANSWER.text, ANSWER.text]); +}); + +test('keeps running work expanded and allows manual disclosure after settlement', async () => { const { container, root } = domRoot(); await renderTurn(root, turnWith([PROCESS_TEXT, COMPLETED_TOOL])); const process = container.querySelector('details.maka-processing-sequence'); const summary = process?.querySelector('summary'); assert.ok(process && summary); const click = () => act(() => { summary.dispatchEvent(new window.Event('click', { bubbles: true, cancelable: true })); }); - await click(); // explicitly hide the running process + assert.equal(summary.getAttribute('aria-disabled'), 'true'); + assert.equal(summary.getAttribute('tabindex'), '-1'); + await click(); // pointer activation cannot hide live work await renderTurn(root, turnWith([PROCESS_TEXT, COMPLETED_TOOL, ANSWER])); - assert.equal(process.hasAttribute('open'), false); - await click(); // explicitly reopen it + assert.equal(process.hasAttribute('open'), true); await renderTurn(root, { ...turnWith([PROCESS_TEXT, COMPLETED_TOOL, { ...ANSWER, live: false }]), status: 'completed' }); + assert.equal(process.hasAttribute('open'), false); + assert.equal(summary.hasAttribute('aria-disabled'), false); + assert.equal(summary.getAttribute('tabindex'), '0'); + await click(); + await renderTurn(root, { ...turnWith([PROCESS_TEXT, COMPLETED_TOOL, { ...ANSWER, live: false }]), status: 'completed', durationMs: 2000 }); assert.equal(process.hasAttribute('open'), true); }); @@ -663,7 +735,7 @@ test('a newly failed tool reveals the process while turn recovery stays outside' const summary = process?.querySelector('summary'); assert.ok(process && summary); await act(() => { summary.dispatchEvent(new window.Event('click', { bubbles: true, cancelable: true })); }); - assert.equal(process.hasAttribute('open'), false); + assert.equal(process.hasAttribute('open'), true); await act(() => root.render( { summary.dispatchEvent(new window.Event('click', { bubbles: true, cancelable: true })); }); + assert.equal(process.hasAttribute('open'), false); + assert.match(container.textContent ?? '', /Continue this turn/); }); test('uses a generic process label when no duration is recorded, and localizes known duration', async () => { diff --git a/packages/ui/src/__tests__/chat-view-empty-compaction.test.tsx b/packages/ui/src/__tests__/chat-view-empty-compaction.test.tsx index 05780cf479..dfa6588bd4 100644 --- a/packages/ui/src/__tests__/chat-view-empty-compaction.test.tsx +++ b/packages/ui/src/__tests__/chat-view-empty-compaction.test.tsx @@ -89,7 +89,7 @@ test('shows one waiting indicator before a named live Turn reaches the transcrip for (const messages of [[], [{ type: 'user' as const, id: 'old-user', turnId: 'old-turn', text: 'Earlier request', ts: 1 }]]) { const markup = renderChat(liveTurn, { messages, transientMessages: [pending], activeTurn: { turnId: liveTurn.turnId! } }); assert.equal((markup.match(/class="maka-turn-processing"/g) ?? []).length, 1); - assert.match(markup, /Waiting for model output/); + assert.match(markup, /Pondering/); assert.match(markup, /Please help/); assert.doesNotMatch(markup, /data-transcript-turn-id="pending-turn"/); } diff --git a/packages/ui/src/__tests__/materialize.test.ts b/packages/ui/src/__tests__/materialize.test.ts index 15075a98af..fc3728cfd8 100644 --- a/packages/ui/src/__tests__/materialize.test.ts +++ b/packages/ui/src/__tests__/materialize.test.ts @@ -51,7 +51,7 @@ test('keeps interrupted and replacement responses distinct live and after reload })!; } for (const turn of [materializeTurns(reloaded, 'en')[0], overlayLiveTurn(materializeTurns([originalUser], 'en'), live, 'en')[0]]) { - const responses = foldTimeline(turn!.timeline).filter((entry) => entry.kind === 'text'); + const responses = foldTimeline(turn!.timeline).entries.filter((entry) => entry.kind === 'text'); assert.deepEqual(responses.map((entry) => [entry.text, entry.interrupted === true]), [ ['Partial answer', true], ['Recovered answer', false], ]); diff --git a/packages/ui/src/__tests__/timeline-fold.test.ts b/packages/ui/src/__tests__/timeline-fold.test.ts index 809db60135..b3d040a1dc 100644 --- a/packages/ui/src/__tests__/timeline-fold.test.ts +++ b/packages/ui/src/__tests__/timeline-fold.test.ts @@ -21,50 +21,73 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { foldTimeline } from '../timeline-fold.js'; import type { TurnTimelineItem } from '../materialize.js'; +import { finalAssistantReplyText, type TurnViewModel } from '../materialize.js'; const commentary: TurnTimelineItem = { kind: 'text', messageId: 'c', text: 'Checking files' }; const thinking: TurnTimelineItem = { kind: 'thinking', messageId: 'r', text: 'Reasoning' }; const tools: TurnTimelineItem = { kind: 'tools', items: [{ toolUseId: 'read', toolName: 'read', args: {}, status: 'completed' }] }; const answer: TurnTimelineItem = { kind: 'text', messageId: 'a', text: 'Fixed' }; +test('display and copy share reply identity across process, steering and interrupted boundaries', () => { + const steering: TurnTimelineItem = { kind: 'user', messageId: 'steer', message: { id: 'steer', role: 'user', text: 'Continue', ts: 2 } }; + const partial: TurnTimelineItem = { ...answer, messageId: 'partial', interrupted: true, text: 'Partial' }; + const cases: Array<[TurnTimelineItem[], TurnTimelineItem | undefined]> = [ + [[commentary, tools], undefined], + [[commentary, tools, answer, thinking], answer], + [[answer, steering], undefined], + [[answer, steering, commentary, tools], undefined], + [[partial], partial], + [[partial, tools], undefined], + [[partial, tools, answer], answer], + [[], undefined], + ]; + for (const [timeline, expected] of cases) { + const projection = foldTimeline(timeline); + assert.equal(projection.finalReply, expected, 'preserves the source message identity'); + if (expected) assert.ok(projection.entries.includes(expected)); + const turn = { timeline, assistant: { text: 'obsolete aggregate' } } as TurnViewModel; + assert.equal(finalAssistantReplyText(turn), expected?.kind === 'text' ? expected.text : ''); + } +}); + test('folds interleaved commentary and tools together without changing their order', () => { const input = [commentary, thinking, tools, { ...commentary, messageId: 'c2' }, tools, answer]; - const result = foldTimeline(input); + const result = foldTimeline(input).entries; assert.deepEqual(result, [{ kind: 'processing', id: 'start', children: input.slice(0, -1) }, answer]); assert.equal(input.length, 6, 'does not mutate the source projection'); }); test('keeps inserted user instructions and each segment reply outside disclosures', () => { const steering: TurnTimelineItem = { kind: 'user', messageId: 'steer', message: { id: 'steer', role: 'user', text: 'Also add tests', ts: 2 } }; - assert.deepEqual(foldTimeline([commentary, tools, answer, steering, thinking, tools, answer]), [ + assert.deepEqual(foldTimeline([commentary, tools, answer, steering, thinking, tools, answer]).entries, [ { kind: 'processing', id: 'start', children: [commentary, tools] }, answer, steering, { kind: 'processing', id: 'steer', children: [thinking, tools] }, answer, ]); }); test('does not promote text followed by tools to the final answer', () => { - assert.deepEqual(foldTimeline([commentary, tools]), [{ kind: 'processing', id: 'start', children: [commentary, tools] }]); + assert.deepEqual(foldTimeline([commentary, tools]).entries, [{ kind: 'processing', id: 'start', children: [commentary, tools] }]); }); test('leaves plain replies alone and includes reasoning in the process', () => { - assert.deepEqual(foldTimeline([answer]), [answer]); - assert.deepEqual(foldTimeline([thinking, answer]), [{ kind: 'processing', id: 'start', children: [thinking] }, answer]); - assert.deepEqual(foldTimeline([]), []); + assert.deepEqual(foldTimeline([answer]).entries, [answer]); + assert.deepEqual(foldTimeline([thinking, answer]).entries, [{ kind: 'processing', id: 'start', children: [thinking] }, answer]); + assert.deepEqual(foldTimeline([]).entries, []); }); test('process identity survives tool projection and a new commentary step', () => { - const before = foldTimeline([commentary, tools]); - const after = foldTimeline([commentary, thinking, answer]); + const before = foldTimeline([commentary, tools]).entries; + const after = foldTimeline([commentary, thinking, answer]).entries; assert.equal(before[0]?.kind === 'processing' && before[0].id, 'start'); assert.equal(after[0]?.kind === 'processing' && after[0].id, 'start'); }); test('keeps a reply visible when only reasoning follows it', () => { - assert.deepEqual(foldTimeline([commentary, tools, answer, thinking]), [ + assert.deepEqual(foldTimeline([commentary, tools, answer, thinking]).entries, [ { kind: 'processing', id: 'start', children: [commentary, tools, thinking] }, answer, ]); - assert.deepEqual(foldTimeline([commentary, tools, thinking]), [ + assert.deepEqual(foldTimeline([commentary, tools, thinking]).entries, [ { kind: 'processing', id: 'start', children: [commentary, tools, thinking] }, ]); }); diff --git a/packages/ui/src/__tests__/turn-running-spinner.test.tsx b/packages/ui/src/__tests__/turn-running-spinner.test.tsx index 5cdbe3a20b..6ac16ce382 100644 --- a/packages/ui/src/__tests__/turn-running-spinner.test.tsx +++ b/packages/ui/src/__tests__/turn-running-spinner.test.tsx @@ -46,6 +46,10 @@ function statusHasSpinner(toolStatuses: readonly ('running' | 'completed')[]): b , ); const { document } = parseHTML(markup); + assert.equal(document.querySelectorAll('.maka-turn-processing').length, 1); + assert.ok(document.querySelector('.maka-processing-summary .maka-turn-processing')); + assert.equal(document.querySelector('.maka-turn-footer .maka-turn-processing'), null); + assert.doesNotMatch(markup, /Waiting for model output/); return document.querySelector('.maka-turn-processing .astryx-spinner') !== null; } @@ -66,16 +70,58 @@ function runningStatusText(locale: 'en' | 'zh-CN'): string { return parseHTML(markup).document.querySelector('.maka-turn-processing')?.textContent ?? ''; } -test('hands the spinner to the turn status after the tool settles', () => { +test('keeps the process header spinner-free across tool settlement and grouping', () => { assert.equal(statusHasSpinner(['running']), false); - assert.equal(statusHasSpinner(['completed']), true); + assert.equal(statusHasSpinner(['completed']), false); + assert.equal(statusHasSpinner(['running', 'completed']), false); }); -test('keeps the turn spinner when a collapsed group hides the running tool', () => { - assert.equal(statusHasSpinner(['running', 'completed']), true); +test('keeps a working cue before any process content arrives', () => { + assert.equal(runningStatusText('zh-CN'), '正在琢磨…'); + assert.equal(runningStatusText('en'), 'Pondering…'); }); -test('describes provider silence without inventing semantic progress', () => { - assert.equal(runningStatusText('zh-CN'), '等待模型输出…'); - assert.equal(runningStatusText('en'), 'Waiting for model output…'); +test('user input and provider retry suppress playful process activity', () => { + const turn: TurnViewModel = { + turnId: 'turn-1', status: 'running', tools: [], notes: [], startedAt: 1, + timeline: [{ kind: 'thinking', text: 'reasoning', messageId: 'thought' }], + }; + for (const runningStatus of [false, true]) { + const markup = renderToStaticMarkup( + + + , + ); + const { document } = parseHTML(markup); + assert.equal(document.querySelector('.maka-turn-processing'), null); + assert.equal(document.querySelector('.maka-processing-summary')?.textContent, 'Execution process'); + assert.equal(document.querySelectorAll('.maka-turn-provider-retry').length, runningStatus ? 1 : 0); + } +}); + +test('only the latest assistant segment owns live activity after a user instruction', () => { + const tool = { toolUseId: 'read', toolName: 'Read', status: 'completed' as const, args: {} }; + const instruction = { id: 'steer', role: 'user' as const, text: 'Also check the keyboard', ts: 2 }; + const turn: TurnViewModel = { + turnId: 'turn-1', status: 'running', tools: [tool], notes: [], startedAt: 1, + timeline: [ + { kind: 'tools', items: [tool] }, + { kind: 'user', messageId: instruction.id, message: instruction }, + { kind: 'thinking', text: 'checking keyboard behavior', messageId: 'thought' }, + ], + }; + const { document } = parseHTML(renderToStaticMarkup( + , + )); + const summaries = document.querySelectorAll('.maka-processing-summary'); + assert.equal(summaries.length, 2); + assert.equal(summaries[0]?.textContent, 'Execution process'); + assert.match(summaries[1]?.textContent ?? '', /Pondering/); + assert.equal(document.querySelectorAll('.maka-turn-processing').length, 1); }); diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index 5f7699ba0f..70908cb677 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -41,6 +41,7 @@ import { Timestamp, Token, useLightbox, + useMediaQuery, } from '@astryxdesign/core'; import { ChatReasoning } from './astryx-chat-reasoning.js'; import { Tooltip } from '@astryxdesign/core/Tooltip'; @@ -56,7 +57,6 @@ import type { TransientUserMessageProjection } from './chat-view.js'; import { type LiveProviderRetry } from './live-turn-projection.js'; import { providerRetryDisplaySeconds } from '@maka/core/provider-retry-countdown'; import { - finalAssistantReplyText, type TurnTimelineItem, type TurnViewModel, } from './materialize.js'; @@ -458,12 +458,9 @@ export const TurnView = memo(function TurnView(props: { liveStreaming?: { onStreamingSettled?: (messageId?: string) => void; /** - * Whether to show the running status line at the tail of the live turn. - * - * It stays up for the WHOLE turn, not just the wait before the first token. - * The cue it replaces was gated on the turn having no live content yet, so - * it vanished the moment a tool started — exactly the stretch where a turn - * looks abandoned and the user most needs to see it is still working. + * Whether to show activity for the live turn. The current process summary + * owns it when present; the footer is the fallback before a process exists. + * False while waiting for user input, whose prompt owns the next action. */ runningStatus?: boolean; providerRetry?: LiveProviderRetry; @@ -480,10 +477,13 @@ export const TurnView = memo(function TurnView(props: { const locale = useUiLocale(); const copy = getConversationCopy(locale).messages; const { turn } = props; + // Derive disclosure entries and reply identity together, only when this + // turn's timeline changes. Rendering and copy share the original reply item. + const { entries: foldedTimeline, finalReply } = useMemo(() => foldTimeline(turn.timeline), [turn.timeline]); const forwardBadges = props.lineageBadges?.filter((b) => b.direction === 'forward') ?? []; const reverseBadges = props.lineageBadges?.filter((b) => b.direction === 'reverse') ?? []; const answerContext = accessibleActionContext( - turn.user?.text ?? finalAssistantReplyText(turn) ?? '', + turn.user?.text ?? finalReply?.text ?? '', turn.startedAt, locale, ); @@ -495,10 +495,6 @@ export const TurnView = memo(function TurnView(props: { turn.timeline.length > 0 || !!props.liveStreaming || (turn.user !== undefined && turn.statusSource === 'recorded' && turn.status !== 'running'); - // #1307: the collapsed "Processing" fold is derived at render time from the - // flat timeline. Settled turn identities are stable (memoized projections), - // so this only recomputes for the turn whose timeline actually changed. - const foldedTimeline = useMemo(() => foldTimeline(turn.timeline), [turn.timeline]); const runningToolLabel = computerRunningLabel(turn.tools, locale); const conversationSegments = useMemo( () => splitTimelineAtUserMessages(foldedTimeline, showAssistantMessage), @@ -673,6 +669,9 @@ export const TurnView = memo(function TurnView(props: { ); } const ownsTurnChrome = segmentIndex === conversationSegments.length - 1; + const activityProcessIndex = ownsTurnChrome + ? segment.items.findLastIndex((item) => item.kind === 'processing') + : -1; // Disjoint namespaces: a steering id is any string, so a bare // sentinel could collide with a real one. const assistantKey = @@ -703,6 +702,11 @@ export const TurnView = memo(function TurnView(props: { entries={item.children} running={!!props.liveStreaming || turn.status === 'running'} durationMs={ownsTurnChrome ? turn.durationMs : undefined} + activity={index === activityProcessIndex + && props.liveStreaming?.runningStatus + && !props.liveStreaming.providerRetry + ? { startedAt: turn.startedAt, label: runningToolLabel } + : undefined} onStreamingSettled={props.liveStreaming?.onStreamingSettled} onOpenLinkedSession={props.onOpenLinkedSession} onSwitchToBypassAndRetry={ @@ -793,7 +797,7 @@ export const TurnView = memo(function TurnView(props: { live={!!props.liveStreaming} activity={props.liveStreaming?.providerRetry ? ( - ) : props.liveStreaming?.runningStatus ? ( + ) : props.liveStreaming?.runningStatus && activityProcessIndex === -1 ? ( props.onFooterAction?.(turn.turnId, actionId) : undefined } - assistantText={finalAssistantReplyText(turn)} + assistantText={finalReply?.text ?? ''} /> ) : null} @@ -1007,16 +1011,13 @@ const STATUS_FOOTER_ICON: Record = { }; const ELAPSED_TICK_MS = 1_000; +const WORKING_PHRASE_INTERVAL_MS = 20_000; /** - * The live turn's running status line: a truthful activity label and the - * elapsed clock beside it. - * - * A quiet provider request does not prove that the model is actively making - * semantic progress. The default therefore says only that Maka is waiting for - * model output. A concrete tool label can replace it when Runtime has direct - * evidence of work in flight. The clock is local presentation state so ticking - * it does not repaint the whole transcript. + * One live activity cue, inside the current process summary or, before any + * process exists, in the footer. Working phrases express liveness, not stages + * or completed progress. Concrete activity labels take precedence. Rotation + * shares the elapsed clock and never changes the accessible status name. * * `startedAt` is the turn's own first-message timestamp, so the clock measures * the wait the user actually experienced — from pressing send, not from @@ -1030,12 +1031,20 @@ export function TurnRunningStatus(props: { activityLabel?: string; }) { const copy = getConversationCopy(useUiLocale()).messages; + const rootRef = useRef(null); + const elapsedMs = useTurnElapsedTime(props.startedAt); + const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)'); + const phrase = copy.workingPhrases[ + reducedMotion || !isTimeDrivenMotionEnabled(rootRef.current) ? 0 + : Math.floor((elapsedMs ?? 0) / WORKING_PHRASE_INTERVAL_MS) % copy.workingPhrases.length + ]; return ( -
{props.showSpinner !== false && (
+ ); } -function TurnElapsedTime(props: { startedAt?: number; separator?: boolean }) { - const { startedAt } = props; - const rootRef = useRef(null); +function useTurnElapsedTime(startedAt: number | undefined) { // Undefined until an effect measures it, which is also what keeps a static // render deterministic: the clock is a client-only value, so server markup // and the first paint carry the phrase alone. const [elapsedMs, setElapsedMs] = useState(undefined); useEffect(() => { - // Frozen (fixture / reduced motion) the clock is dropped rather than - // pinned: any value it could show is a real wall-clock difference, so a - // capture taken a second later would differ from this one. The gate needs - // this node because the freeze can be declared on any ancestor. - if (startedAt === undefined || !isTimeDrivenMotionEnabled(rootRef.current)) return; + // Elapsed time is task information, independent of motion preferences. + // Screenshot fixtures can pin browser time without hiding this information. + if (startedAt === undefined) { + setElapsedMs(undefined); + return; + } setElapsedMs(Math.max(0, Date.now() - startedAt)); const tick = window.setInterval(() => { setElapsedMs(Math.max(0, Date.now() - startedAt)); @@ -1072,12 +1083,15 @@ function TurnElapsedTime(props: { startedAt?: number; separator?: boolean }) { return () => window.clearInterval(tick); }, [startedAt]); + return elapsedMs; +} + +function TurnElapsedTime(props: { startedAt?: number }) { + const elapsedMs = useTurnElapsedTime(props.startedAt); + return ( -