Skip to content

Commit 2bc8a54

Browse files
authored
fix(ui): unify live process activity and settled disclosures (#5301)
* fix(ui): unify process activity and restore rotating working phrases Generated-by: Codex * fix(ui): keep live work open and animate settled process details Generated-by: Codex * fix(ui): share reply identity across disclosure and copy Generated-by: Codex * test(storybook): show complete process and reply lifecycle Generated-by: Codex * fix(ui): animate process disclosure with Astryx motion tokens Generated-by: Codex * fix(ui): remove spinner from process header Generated-by: Codex * test(storybook): avoid timing-dependent disclosure motion assertions Validate browser-applied motion and final geometry without requiring an intermediate animation frame on busy CI runners. Generated-by: Codex * fix(ui): decouple elapsed time from motion preferences Generated-by: Codex * test(storybook): replay production live-to-durable reply lifecycle Generated-by: Codex
1 parent 5f4614b commit 2bc8a54

13 files changed

Lines changed: 456 additions & 111 deletions

‎apps/desktop/src/main/__tests__/imported-reply-fold.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ test('completed OpenCode text followed by reasoning stays outside the process di
5656
const turn = turns[0]!;
5757
assert.equal(turn.status, 'completed');
5858
assert.deepEqual(turn.timeline.map((item) => item.kind), ['text', 'thinking']);
59-
const folded = foldTimeline(turn.timeline);
59+
const folded = foldTimeline(turn.timeline).entries;
6060
assert.deepEqual(folded.filter((item) => item.kind === 'text').map((item) => item.text), ['Visible final answer']);
6161
const process = folded.find((item) => item.kind === 'processing');
6262
assert.ok(process);

‎apps/desktop/stories/app-shell.stories.tsx‎

Lines changed: 166 additions & 10 deletions
Large diffs are not rendered by default.

‎packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx‎

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { afterEach, test } from 'node:test';
2424
import { act, StrictMode } from 'react';
2525
import { createRoot } from 'react-dom/client';
2626
import { parseHTML } from 'linkedom';
27-
import { LocalizedChatMessage, TurnView } from '../chat-turn.js';
27+
import { LocalizedChatMessage, TurnRunningStatus, TurnView } from '../chat-turn.js';
2828
import { LocaleProvider } from '../locale-context.js';
2929
import type { TurnTimelineItem, TurnViewModel } from '../materialize.js';
3030

@@ -453,6 +453,47 @@ test('keeps Astryx auto formatting live for user-message timestamps', async (con
453453
assert.match(timestamp.textContent ?? '', /3 hours ago/);
454454
});
455455

456+
test('rotates working phrases on the elapsed clock without announcing each phrase', async (context) => {
457+
const now = Date.UTC(2026, 8, 14, 12);
458+
context.mock.timers.enable({ apis: ['Date', 'setInterval'], now });
459+
const { container, root } = domRoot();
460+
await act(() => root.render(<LocaleProvider locale="en"><TurnRunningStatus startedAt={now} /></LocaleProvider>));
461+
const status = container.querySelector('[role="status"]')!;
462+
assert.match(status.textContent, /Pondering/);
463+
assert.equal(status.getAttribute('aria-label'), 'Working…');
464+
await act(() => context.mock.timers.tick(20_000));
465+
assert.match(status.textContent, /Tinkering/);
466+
assert.match(status.textContent, /20s/);
467+
assert.equal(status.getAttribute('aria-label'), 'Working…');
468+
// Concrete activity takes precedence over the playful phrase.
469+
await act(() => root.render(<LocaleProvider locale="en"><TurnRunningStatus startedAt={now} activityLabel="Clicking Save" /></LocaleProvider>));
470+
assert.match(status.textContent, /Clicking Save/);
471+
assert.doesNotMatch(status.textContent, /Tinkering/);
472+
});
473+
474+
test('keeps elapsed time while system motion preference changes the working phrase', async (context) => {
475+
const now = Date.UTC(2026, 8, 14, 12);
476+
context.mock.timers.enable({ apis: ['Date', 'setInterval'], now });
477+
const { container, root } = domRoot();
478+
let reduced = true;
479+
const listeners = new Set<() => void>();
480+
Object.assign(globalThis, { matchMedia: () => ({
481+
get matches() { return reduced; },
482+
addEventListener(_type: string, listener: () => void) { listeners.add(listener); },
483+
removeEventListener(_type: string, listener: () => void) { listeners.delete(listener); },
484+
}) });
485+
await act(() => root.render(<LocaleProvider locale="en"><TurnRunningStatus startedAt={now} /></LocaleProvider>));
486+
await act(() => context.mock.timers.tick(20_000));
487+
assert.equal(container.querySelector('.maka-turn-status-label')?.textContent, 'Pondering…');
488+
assert.equal(container.querySelector('.maka-turn-elapsed')?.textContent, '20s');
489+
await act(() => { reduced = false; listeners.forEach((listener) => listener()); });
490+
assert.equal(container.querySelector('.maka-turn-status-label')?.textContent, 'Tinkering…');
491+
await act(() => { reduced = true; listeners.forEach((listener) => listener()); });
492+
await act(() => context.mock.timers.tick(20_000));
493+
assert.equal(container.querySelector('.maka-turn-status-label')?.textContent, 'Pondering…');
494+
assert.equal(container.querySelector('.maka-turn-elapsed')?.textContent, '40s');
495+
});
496+
456497
/**
457498
* The live handoff announces itself exactly once, when the answer enters its
458499
* 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
641682
assert.equal(container.querySelectorAll('.maka-chat-message-bubble-assistant')[1]?.isSameNode(answer!), true);
642683
});
643684

644-
test('respects a manual expansion through appended events and completion', async () => {
685+
test('copy uses the visible final reply after completion and disclosure toggles', async () => {
686+
const { container, root } = domRoot();
687+
const copied: string[] = [];
688+
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: {
689+
writeText: async (text: string) => { copied.push(text); },
690+
} });
691+
const timeline = [PROCESS_TEXT, COMPLETED_TOOL, { ...ANSWER, live: false }];
692+
await renderTurn(root, turnWith(timeline));
693+
await act(() => root.render(<LocaleProvider locale="en"><TurnView
694+
turn={{ ...turnWith(timeline), status: 'completed' }}
695+
footerActions={[{ id: 'copy', label: 'Copy', enabled: true }]}
696+
/></LocaleProvider>));
697+
const process = container.querySelector('details.maka-processing-sequence');
698+
const summary = process?.querySelector('summary');
699+
const copy = container.querySelector('[data-action="copy"]');
700+
assert.ok(process && summary && copy);
701+
assert.equal(process.hasAttribute('open'), false);
702+
assert.doesNotMatch(process.textContent ?? '', /the answer/);
703+
for (let index = 0; index < 3; index += 1) {
704+
await act(async () => { copy.dispatchEvent(new window.Event('click', { bubbles: true })); });
705+
await act(() => { summary.dispatchEvent(new window.Event('click', { bubbles: true, cancelable: true })); });
706+
}
707+
assert.deepEqual(copied, [ANSWER.text, ANSWER.text, ANSWER.text]);
708+
});
709+
710+
test('keeps running work expanded and allows manual disclosure after settlement', async () => {
645711
const { container, root } = domRoot();
646712
await renderTurn(root, turnWith([PROCESS_TEXT, COMPLETED_TOOL]));
647713
const process = container.querySelector('details.maka-processing-sequence');
648714
const summary = process?.querySelector('summary');
649715
assert.ok(process && summary);
650716
const click = () => act(() => { summary.dispatchEvent(new window.Event('click', { bubbles: true, cancelable: true })); });
651-
await click(); // explicitly hide the running process
717+
assert.equal(summary.getAttribute('aria-disabled'), 'true');
718+
assert.equal(summary.getAttribute('tabindex'), '-1');
719+
await click(); // pointer activation cannot hide live work
652720
await renderTurn(root, turnWith([PROCESS_TEXT, COMPLETED_TOOL, ANSWER]));
653-
assert.equal(process.hasAttribute('open'), false);
654-
await click(); // explicitly reopen it
721+
assert.equal(process.hasAttribute('open'), true);
655722
await renderTurn(root, { ...turnWith([PROCESS_TEXT, COMPLETED_TOOL, { ...ANSWER, live: false }]), status: 'completed' });
723+
assert.equal(process.hasAttribute('open'), false);
724+
assert.equal(summary.hasAttribute('aria-disabled'), false);
725+
assert.equal(summary.getAttribute('tabindex'), '0');
726+
await click();
727+
await renderTurn(root, { ...turnWith([PROCESS_TEXT, COMPLETED_TOOL, { ...ANSWER, live: false }]), status: 'completed', durationMs: 2000 });
656728
assert.equal(process.hasAttribute('open'), true);
657729
});
658730

@@ -663,7 +735,7 @@ test('a newly failed tool reveals the process while turn recovery stays outside'
663735
const summary = process?.querySelector('summary');
664736
assert.ok(process && summary);
665737
await act(() => { summary.dispatchEvent(new window.Event('click', { bubbles: true, cancelable: true })); });
666-
assert.equal(process.hasAttribute('open'), false);
738+
assert.equal(process.hasAttribute('open'), true);
667739
await act(() => root.render(<LocaleProvider locale="en"><TurnView
668740
turn={{ ...turnWith([PROCESS_TEXT, { kind: 'tools', items: [{ toolUseId: 'tool-1', toolName: 'read', args: {}, status: 'errored' }] }]), status: 'failed' }}
669741
failedReasonLabel="Read failed"
@@ -673,6 +745,9 @@ test('a newly failed tool reveals the process while turn recovery stays outside'
673745
assert.match(summary.textContent ?? '', /Needs attention/);
674746
assert.doesNotMatch(process.textContent ?? '', /Continue this turn/);
675747
assert.match(container.textContent ?? '', /Continue this turn/);
748+
await act(() => { summary.dispatchEvent(new window.Event('click', { bubbles: true, cancelable: true })); });
749+
assert.equal(process.hasAttribute('open'), false);
750+
assert.match(container.textContent ?? '', /Continue this turn/);
676751
});
677752

678753
test('uses a generic process label when no duration is recorded, and localizes known duration', async () => {

‎packages/ui/src/__tests__/chat-view-empty-compaction.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ test('shows one waiting indicator before a named live Turn reaches the transcrip
8989
for (const messages of [[], [{ type: 'user' as const, id: 'old-user', turnId: 'old-turn', text: 'Earlier request', ts: 1 }]]) {
9090
const markup = renderChat(liveTurn, { messages, transientMessages: [pending], activeTurn: { turnId: liveTurn.turnId! } });
9191
assert.equal((markup.match(/class="maka-turn-processing"/g) ?? []).length, 1);
92-
assert.match(markup, /Waiting for model output/);
92+
assert.match(markup, /Pondering/);
9393
assert.match(markup, /Please help/);
9494
assert.doesNotMatch(markup, /data-transcript-turn-id="pending-turn"/);
9595
}

‎packages/ui/src/__tests__/materialize.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ test('keeps interrupted and replacement responses distinct live and after reload
5151
})!;
5252
}
5353
for (const turn of [materializeTurns(reloaded, 'en')[0], overlayLiveTurn(materializeTurns([originalUser], 'en'), live, 'en')[0]]) {
54-
const responses = foldTimeline(turn!.timeline).filter((entry) => entry.kind === 'text');
54+
const responses = foldTimeline(turn!.timeline).entries.filter((entry) => entry.kind === 'text');
5555
assert.deepEqual(responses.map((entry) => [entry.text, entry.interrupted === true]), [
5656
['Partial answer', true], ['Recovered answer', false],
5757
]);

‎packages/ui/src/__tests__/timeline-fold.test.ts‎

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,50 +21,73 @@ import assert from 'node:assert/strict';
2121
import { test } from 'node:test';
2222
import { foldTimeline } from '../timeline-fold.js';
2323
import type { TurnTimelineItem } from '../materialize.js';
24+
import { finalAssistantReplyText, type TurnViewModel } from '../materialize.js';
2425

2526
const commentary: TurnTimelineItem = { kind: 'text', messageId: 'c', text: 'Checking files' };
2627
const thinking: TurnTimelineItem = { kind: 'thinking', messageId: 'r', text: 'Reasoning' };
2728
const tools: TurnTimelineItem = { kind: 'tools', items: [{ toolUseId: 'read', toolName: 'read', args: {}, status: 'completed' }] };
2829
const answer: TurnTimelineItem = { kind: 'text', messageId: 'a', text: 'Fixed' };
2930

31+
test('display and copy share reply identity across process, steering and interrupted boundaries', () => {
32+
const steering: TurnTimelineItem = { kind: 'user', messageId: 'steer', message: { id: 'steer', role: 'user', text: 'Continue', ts: 2 } };
33+
const partial: TurnTimelineItem = { ...answer, messageId: 'partial', interrupted: true, text: 'Partial' };
34+
const cases: Array<[TurnTimelineItem[], TurnTimelineItem | undefined]> = [
35+
[[commentary, tools], undefined],
36+
[[commentary, tools, answer, thinking], answer],
37+
[[answer, steering], undefined],
38+
[[answer, steering, commentary, tools], undefined],
39+
[[partial], partial],
40+
[[partial, tools], undefined],
41+
[[partial, tools, answer], answer],
42+
[[], undefined],
43+
];
44+
for (const [timeline, expected] of cases) {
45+
const projection = foldTimeline(timeline);
46+
assert.equal(projection.finalReply, expected, 'preserves the source message identity');
47+
if (expected) assert.ok(projection.entries.includes(expected));
48+
const turn = { timeline, assistant: { text: 'obsolete aggregate' } } as TurnViewModel;
49+
assert.equal(finalAssistantReplyText(turn), expected?.kind === 'text' ? expected.text : '');
50+
}
51+
});
52+
3053
test('folds interleaved commentary and tools together without changing their order', () => {
3154
const input = [commentary, thinking, tools, { ...commentary, messageId: 'c2' }, tools, answer];
32-
const result = foldTimeline(input);
55+
const result = foldTimeline(input).entries;
3356
assert.deepEqual(result, [{ kind: 'processing', id: 'start', children: input.slice(0, -1) }, answer]);
3457
assert.equal(input.length, 6, 'does not mutate the source projection');
3558
});
3659

3760
test('keeps inserted user instructions and each segment reply outside disclosures', () => {
3861
const steering: TurnTimelineItem = { kind: 'user', messageId: 'steer', message: { id: 'steer', role: 'user', text: 'Also add tests', ts: 2 } };
39-
assert.deepEqual(foldTimeline([commentary, tools, answer, steering, thinking, tools, answer]), [
62+
assert.deepEqual(foldTimeline([commentary, tools, answer, steering, thinking, tools, answer]).entries, [
4063
{ kind: 'processing', id: 'start', children: [commentary, tools] }, answer, steering,
4164
{ kind: 'processing', id: 'steer', children: [thinking, tools] }, answer,
4265
]);
4366
});
4467

4568
test('does not promote text followed by tools to the final answer', () => {
46-
assert.deepEqual(foldTimeline([commentary, tools]), [{ kind: 'processing', id: 'start', children: [commentary, tools] }]);
69+
assert.deepEqual(foldTimeline([commentary, tools]).entries, [{ kind: 'processing', id: 'start', children: [commentary, tools] }]);
4770
});
4871

4972
test('leaves plain replies alone and includes reasoning in the process', () => {
50-
assert.deepEqual(foldTimeline([answer]), [answer]);
51-
assert.deepEqual(foldTimeline([thinking, answer]), [{ kind: 'processing', id: 'start', children: [thinking] }, answer]);
52-
assert.deepEqual(foldTimeline([]), []);
73+
assert.deepEqual(foldTimeline([answer]).entries, [answer]);
74+
assert.deepEqual(foldTimeline([thinking, answer]).entries, [{ kind: 'processing', id: 'start', children: [thinking] }, answer]);
75+
assert.deepEqual(foldTimeline([]).entries, []);
5376
});
5477

5578
test('process identity survives tool projection and a new commentary step', () => {
56-
const before = foldTimeline([commentary, tools]);
57-
const after = foldTimeline([commentary, thinking, answer]);
79+
const before = foldTimeline([commentary, tools]).entries;
80+
const after = foldTimeline([commentary, thinking, answer]).entries;
5881
assert.equal(before[0]?.kind === 'processing' && before[0].id, 'start');
5982
assert.equal(after[0]?.kind === 'processing' && after[0].id, 'start');
6083
});
6184

6285

6386
test('keeps a reply visible when only reasoning follows it', () => {
64-
assert.deepEqual(foldTimeline([commentary, tools, answer, thinking]), [
87+
assert.deepEqual(foldTimeline([commentary, tools, answer, thinking]).entries, [
6588
{ kind: 'processing', id: 'start', children: [commentary, tools, thinking] }, answer,
6689
]);
67-
assert.deepEqual(foldTimeline([commentary, tools, thinking]), [
90+
assert.deepEqual(foldTimeline([commentary, tools, thinking]).entries, [
6891
{ kind: 'processing', id: 'start', children: [commentary, tools, thinking] },
6992
]);
7093
});

‎packages/ui/src/__tests__/turn-running-spinner.test.tsx‎

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ function statusHasSpinner(toolStatuses: readonly ('running' | 'completed')[]): b
4646
</LocaleProvider>,
4747
);
4848
const { document } = parseHTML(markup);
49+
assert.equal(document.querySelectorAll('.maka-turn-processing').length, 1);
50+
assert.ok(document.querySelector('.maka-processing-summary .maka-turn-processing'));
51+
assert.equal(document.querySelector('.maka-turn-footer .maka-turn-processing'), null);
52+
assert.doesNotMatch(markup, /Waiting for model output/);
4953
return document.querySelector('.maka-turn-processing .astryx-spinner') !== null;
5054
}
5155

@@ -66,16 +70,58 @@ function runningStatusText(locale: 'en' | 'zh-CN'): string {
6670
return parseHTML(markup).document.querySelector('.maka-turn-processing')?.textContent ?? '';
6771
}
6872

69-
test('hands the spinner to the turn status after the tool settles', () => {
73+
test('keeps the process header spinner-free across tool settlement and grouping', () => {
7074
assert.equal(statusHasSpinner(['running']), false);
71-
assert.equal(statusHasSpinner(['completed']), true);
75+
assert.equal(statusHasSpinner(['completed']), false);
76+
assert.equal(statusHasSpinner(['running', 'completed']), false);
7277
});
7378

74-
test('keeps the turn spinner when a collapsed group hides the running tool', () => {
75-
assert.equal(statusHasSpinner(['running', 'completed']), true);
79+
test('keeps a working cue before any process content arrives', () => {
80+
assert.equal(runningStatusText('zh-CN'), '正在琢磨…');
81+
assert.equal(runningStatusText('en'), 'Pondering…');
7682
});
7783

78-
test('describes provider silence without inventing semantic progress', () => {
79-
assert.equal(runningStatusText('zh-CN'), '等待模型输出…');
80-
assert.equal(runningStatusText('en'), 'Waiting for model output…');
84+
test('user input and provider retry suppress playful process activity', () => {
85+
const turn: TurnViewModel = {
86+
turnId: 'turn-1', status: 'running', tools: [], notes: [], startedAt: 1,
87+
timeline: [{ kind: 'thinking', text: 'reasoning', messageId: 'thought' }],
88+
};
89+
for (const runningStatus of [false, true]) {
90+
const markup = renderToStaticMarkup(
91+
<LocaleProvider locale="en">
92+
<TurnView turn={turn} liveStreaming={{ runningStatus, ...(runningStatus ? {
93+
providerRetry: { receivedAtMs: 1, event: {
94+
id: 'retry', type: 'provider_retry', turnId: 'turn-1', ts: 1,
95+
phase: 'scheduled', reason: 'network', attempt: 1, maxAttempts: 3,
96+
delayMs: 1000,
97+
} },
98+
} : {}) }} />
99+
</LocaleProvider>,
100+
);
101+
const { document } = parseHTML(markup);
102+
assert.equal(document.querySelector('.maka-turn-processing'), null);
103+
assert.equal(document.querySelector('.maka-processing-summary')?.textContent, 'Execution process');
104+
assert.equal(document.querySelectorAll('.maka-turn-provider-retry').length, runningStatus ? 1 : 0);
105+
}
106+
});
107+
108+
test('only the latest assistant segment owns live activity after a user instruction', () => {
109+
const tool = { toolUseId: 'read', toolName: 'Read', status: 'completed' as const, args: {} };
110+
const instruction = { id: 'steer', role: 'user' as const, text: 'Also check the keyboard', ts: 2 };
111+
const turn: TurnViewModel = {
112+
turnId: 'turn-1', status: 'running', tools: [tool], notes: [], startedAt: 1,
113+
timeline: [
114+
{ kind: 'tools', items: [tool] },
115+
{ kind: 'user', messageId: instruction.id, message: instruction },
116+
{ kind: 'thinking', text: 'checking keyboard behavior', messageId: 'thought' },
117+
],
118+
};
119+
const { document } = parseHTML(renderToStaticMarkup(
120+
<LocaleProvider locale="en"><TurnView turn={turn} liveStreaming={{ runningStatus: true }} /></LocaleProvider>,
121+
));
122+
const summaries = document.querySelectorAll('.maka-processing-summary');
123+
assert.equal(summaries.length, 2);
124+
assert.equal(summaries[0]?.textContent, 'Execution process');
125+
assert.match(summaries[1]?.textContent ?? '', /Pondering/);
126+
assert.equal(document.querySelectorAll('.maka-turn-processing').length, 1);
81127
});

0 commit comments

Comments
 (0)