Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
176 changes: 166 additions & 10 deletions apps/desktop/stories/app-shell.stories.tsx

Large diffs are not rendered by default.

87 changes: 81 additions & 6 deletions packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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(<LocaleProvider locale="en"><TurnRunningStatus startedAt={now} /></LocaleProvider>));
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(<LocaleProvider locale="en"><TurnRunningStatus startedAt={now} activityLabel="Clicking Save" /></LocaleProvider>));
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(<LocaleProvider locale="en"><TurnRunningStatus startedAt={now} /></LocaleProvider>));
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
Expand Down Expand Up @@ -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(<LocaleProvider locale="en"><TurnView
turn={{ ...turnWith(timeline), status: 'completed' }}
footerActions={[{ id: 'copy', label: 'Copy', enabled: true }]}
/></LocaleProvider>));
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);
});

Expand All @@ -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(<LocaleProvider locale="en"><TurnView
turn={{ ...turnWith([PROCESS_TEXT, { kind: 'tools', items: [{ toolUseId: 'tool-1', toolName: 'read', args: {}, status: 'errored' }] }]), status: 'failed' }}
failedReasonLabel="Read failed"
Expand All @@ -673,6 +745,9 @@ test('a newly failed tool reveals the process while turn recovery stays outside'
assert.match(summary.textContent ?? '', /Needs attention/);
assert.doesNotMatch(process.textContent ?? '', /Continue this turn/);
assert.match(container.textContent ?? '', /Continue this turn/);
await act(() => { 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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"/);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/__tests__/materialize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
]);
Expand Down
43 changes: 33 additions & 10 deletions packages/ui/src/__tests__/timeline-fold.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] },
]);
});
60 changes: 53 additions & 7 deletions packages/ui/src/__tests__/turn-running-spinner.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ function statusHasSpinner(toolStatuses: readonly ('running' | 'completed')[]): b
</LocaleProvider>,
);
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;
}

Expand All @@ -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(
<LocaleProvider locale="en">
<TurnView turn={turn} liveStreaming={{ runningStatus, ...(runningStatus ? {
providerRetry: { receivedAtMs: 1, event: {
id: 'retry', type: 'provider_retry', turnId: 'turn-1', ts: 1,
phase: 'scheduled', reason: 'network', attempt: 1, maxAttempts: 3,
delayMs: 1000,
} },
} : {}) }} />
</LocaleProvider>,
);
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(
<LocaleProvider locale="en"><TurnView turn={turn} liveStreaming={{ runningStatus: true }} /></LocaleProvider>,
));
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);
});
Loading