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
7 changes: 4 additions & 3 deletions apps/desktop/src/main/__tests__/streaming-handoff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ function renderLiveTurn(liveTurn: LiveTurnProjection): string {
}

describe('single live-turn handoff', () => {
it('keeps activity in the answer footer before the session or Turn arrives', () => {
it('keeps activity in the process row before the session or Turn arrives', () => {
const session: NonNullable<Parameters<typeof ChatView>[0]['activeSession']> = {
id: 'session-1', name: 'pending', status: 'running' as const, backend: 'ai-sdk',
labels: [], isFlagged: false, isArchived: false, hasUnread: false,
Expand All @@ -107,8 +107,9 @@ describe('single live-turn handoff', () => {
onNew() {},
} satisfies Parameters<typeof ChatView>[0]));
const { document } = parseHTML(markup);
const status = document.querySelector('.maka-assistant-answer [role="status"]');
assert.ok(status?.closest('.maka-turn-footer'), 'activity must occupy the shared footer');
const status = document.querySelector('.maka-processing-summary [role="status"]');
assert.ok(status, 'activity must occupy the process row');
assert.equal(document.querySelector('.maka-turn-footer'), null);
assert.equal(document.querySelector('.maka-assistant-answer [role="toolbar"]'), null);
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,10 @@ test('keeps a Host-bound current Turn when a later IPC result has no Turn identi
hostTurnId: 'host-turn',
});
});

test('keeps a transient message send time when a later update carries a new timestamp', () => {
const first = { ...transient, ts: 2 };
const later = { ...transient, ts: 9, text: 'edited text' };

assert.deepEqual(mergeTransientMessageProjection(first, later), { ...later, ts: 2 });
});
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ export function mergeTransientMessageProjection(
): TransientUserMessage {
update = {
...update,
// A Message's send time is written once; an update's `ts` must not move it.
ts: current.ts,
...(update.pendingSteering === undefined && current.pendingSteering !== undefined ? { pendingSteering: current.pendingSteering } : {}),
...(!Object.hasOwn(update, 'deliveryStatus') && current.deliveryStatus !== undefined ? { deliveryStatus: current.deliveryStatus } : {}),
...(!Object.hasOwn(update, 'deliveryDetail') && current.deliveryDetail !== undefined ? { deliveryDetail: current.deliveryDetail } : {}),
Expand Down
9 changes: 6 additions & 3 deletions apps/desktop/src/renderer/styles/native-cursor.css
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@
* group label (InputGroup: `isGroupLabel`), which names a group via
* `aria-labelledby` and forwards no click — the `label` selector misses it,
* so it kept StyleX's hand cursor. Match the stable themeProps class to catch
* that span too (real <label> field labels are already covered above). */
* that span too (real <label> field labels are already covered above).
* Disclosure <summary> controls set cursor:pointer in their own stylesheets. */
:where(
button,
label,
Expand All @@ -69,7 +70,8 @@
[role="combobox"],
[role="radio"],
[role="checkbox"],
[role="treeitem"]
[role="treeitem"],
summary
):not(:disabled):not([aria-disabled="true"]),
:where(
button,
Expand All @@ -82,7 +84,8 @@
[role="option"],
[role="switch"],
[role="tab"],
[role="treeitem"]
[role="treeitem"],
summary
):not(:disabled):not([aria-disabled="true"])
:where(svg, span, div, p, i, img, path) {
cursor: default !important;
Expand Down
26 changes: 26 additions & 0 deletions apps/desktop/stories/app-shell.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,32 @@ const NPM_TEST_STDOUT_AT_CANCEL = "\n> maka@0.2.0 test\n> npm run build:test &&
// This is the interrupted counterpart to RunningStatusDuringToolRun, and the only
// story that reaches the interrupted tool row. It goes through the real
// ChatView → materializeTurns → ToolTrow path, so the row renders inside the
// Real path: the prompt is admitted, its Turn has not reached the transcript
// yet. The cue carries no clock until the Turn's own start arrives.
export const PromptSentBeforeTurnLands: Story = {
render: () => (
<ComposedShell
session={{ status: 'running', streaming: true, lastMessageAt: NOW - 3_000 }}
chat={{
activeTurn: { turnId: 'turn-sent' },
messages: [],
transientMessages: [{
id: 'msg-sent',
text: '刚发出的问题:这一轮的耗时是怎么算出来的?',
ts: NOW,
transientPlacement: 'current_turn',
hostTurnId: 'turn-sent',
}],
}}
/>
),
play: async ({ canvasElement }) => {
await expect(canvasElement.querySelector('.maka-turn-processing')).not.toBeNull();
// No clock before the Turn's own start arrives.
await expect(canvasElement.querySelector('.maka-turn-elapsed')).toBeNull();
},
};

// production `.maka-turn` frame. The session is `aborted` too, so the sidebar row
// and composer agree with the transcript instead of still reading as active.
export const InterruptedToolAfterTurnAbort: Story = {
Expand Down
36 changes: 25 additions & 11 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, TurnRunningStatus, TurnView } from '../chat-turn.js';
import { LocalizedChatMessage, TurnView } from '../chat-turn.js';
import { LocaleProvider } from '../locale-context.js';
import type { TurnTimelineItem, TurnViewModel } from '../materialize.js';

Expand Down Expand Up @@ -457,7 +457,13 @@ test('rotates working phrases on the elapsed clock without announcing each phras
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 turn: TurnViewModel = {
turnId: 'turn-1', status: 'running', tools: [], notes: [], startedAt: now, timeline: [],
};
const render = (next: TurnViewModel) => act(() => root.render(
<LocaleProvider locale="en"><TurnView turn={next} liveStreaming={{ runningStatus: true }} /></LocaleProvider>,
));
await render(turn);
const status = container.querySelector('[role="status"]')!;
assert.match(status.textContent, /Pondering/);
assert.equal(status.getAttribute('aria-label'), 'Working…');
Expand All @@ -466,9 +472,11 @@ test('rotates working phrases on the elapsed clock without announcing each phras
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/);
await render({ ...turn, tools: [{
toolUseId: 'cu-1', toolName: 'maka_computer', activityKind: 'computer', status: 'running', args: { app: 'Safari' },
}] });
assert.doesNotMatch(status.textContent ?? '', /Pondering|Tinkering/);
assert.notEqual(status.getAttribute('aria-label'), 'Working…');
});

test('keeps elapsed time while system motion preference changes the working phrase', async (context) => {
Expand All @@ -482,7 +490,12 @@ test('keeps elapsed time while system motion preference changes the working phra
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>));
const turn: TurnViewModel = {
turnId: 'turn-1', status: 'running', tools: [], notes: [], startedAt: now, timeline: [],
};
await act(() => root.render(
<LocaleProvider locale="en"><TurnView turn={turn} liveStreaming={{ runningStatus: true }} /></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');
Expand Down Expand Up @@ -734,20 +747,21 @@ test('a newly failed tool reveals the process while turn recovery stays outside'
const process = container.querySelector('details.maka-processing-sequence');
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'), 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"
safeResumeAction={{ pending: false, onResume() {} }}
/></LocaleProvider>));
assert.equal(process.hasAttribute('open'), true);
assert.match(summary.textContent ?? '', /Needs attention/);
// A failed tool is an ordinary row: no label, no reveal.
assert.doesNotMatch(summary.textContent ?? '', /Needs attention/);
assert.equal(summary.textContent, 'Execution process');
assert.equal(process.hasAttribute('open'), false);
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/);
assert.equal(process.hasAttribute('open'), true);
assert.equal(container.querySelectorAll('.maka-processing-summary').length, 1);
});

test('uses a generic process label when no duration is recorded, and localizes known duration', async () => {
Expand Down
8 changes: 5 additions & 3 deletions packages/ui/src/__tests__/chat-view-empty-compaction.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ test('renders the empty hero when an empty session has no live compaction row',
assert.doesNotMatch(markup, /Compacting context/);
});

test('the pending Turn clock ticks from send time and hands over without a duplicate status', async (t) => {
test('the pending Turn waits without a clock until the Turn start time reaches the client', async (t) => {
const now = 1_700_000_000_000;
t.mock.timers.enable({ apis: ['Date', 'setInterval'], now });
const original = {
Expand Down Expand Up @@ -164,14 +164,16 @@ test('the pending Turn clock ticks from send time and hands over without a dupli
};
await render({});
assert.equal(container.querySelectorAll('.maka-turn-processing').length, 1);
assert.match(container.querySelector('.maka-turn-elapsed')?.textContent ?? '', /0s/);
// No Turn start yet, so no clock.
assert.equal(container.querySelector('.maka-turn-elapsed'), null);
await act(() => t.mock.timers.tick(2_000));
assert.match(container.querySelector('.maka-turn-elapsed')?.textContent ?? '', /2s/);
assert.equal(container.querySelector('.maka-turn-elapsed'), null);
await render({
transientMessages: [],
messages: [{ type: 'user', id: 'durable-user', turnId: liveTurn.turnId, text: pending.text, ts: pending.ts }],
});
assert.equal(container.querySelectorAll('.maka-turn-processing').length, 1);
// The Turn's own start drives the clock.
assert.match(container.querySelector('.maka-turn-elapsed')?.textContent ?? '', /2s/);
await render({ liveTurns: undefined, activeTurn: undefined, transientMessages: [] });
assert.equal(container.querySelectorAll('.maka-turn-processing').length, 0);
Expand Down
16 changes: 16 additions & 0 deletions packages/ui/src/__tests__/turn-running-spinner.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,19 @@ test('only the latest assistant segment owns live activity after a user instruct
assert.match(summaries[1]?.textContent ?? '', /Pondering/);
assert.equal(document.querySelectorAll('.maka-turn-processing').length, 1);
});

test('states the elapsed once, in the process header rather than the footer meta', () => {
const tool = { toolUseId: 'read', toolName: 'Read', status: 'completed' as const, args: {} };
const turn: TurnViewModel = {
turnId: 'turn-1', status: 'completed', modelId: 'fixture-model', tools: [tool], notes: [], startedAt: 1,
durationMs: 213_000,
timeline: [{ kind: 'tools', items: [tool] }, { kind: 'text', messageId: 'answer', text: 'the answer' }],
};
const { document } = parseHTML(renderToStaticMarkup(
<LocaleProvider locale="en">
<TurnView turn={turn} footerActions={[{ id: 'copy', label: 'Copy', enabled: true }]} />
</LocaleProvider>,
));
assert.match(document.querySelector('.maka-processing-summary')?.textContent ?? '', /Worked for 3m 33s/);
assert.equal(document.querySelector('.maka-turn-footer-meta')?.textContent, 'fixture-model');
});
55 changes: 23 additions & 32 deletions packages/ui/src/chat-turn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ import { foldTimeline, type FoldedTimelineChild, type FoldedTimelineEntry } from
import { AttachmentKindIcon } from './attachment-kinds.js';
import { QuoteRefChip } from './quote-ref-chip.js';
import { Marker, markerVariants } from './primitives/chat.js';
import { ToolTrow, toolTrowHasVisibleSpinner } from './tool-activity.js';
import { ToolTrow } from './tool-activity.js';
import { formatBytes } from './tool-activity/preview-utils.js';
import { useUiLocale } from './locale-context.js';
import type { UiLocale } from '@maka/core/ui-locale';
Expand Down Expand Up @@ -500,9 +500,6 @@ export const TurnView = memo(function TurnView(props: {
() => splitTimelineAtUserMessages(foldedTimeline, showAssistantMessage),
[foldedTimeline, showAssistantMessage],
);
const toolSurfaceOwnsSpinner = turn.timeline.some(
(item) => item.kind === 'tools' && toolTrowHasVisibleSpinner(item.items),
);
return (
<section
className="maka-turn"
Expand Down Expand Up @@ -672,6 +669,9 @@ export const TurnView = memo(function TurnView(props: {
const activityProcessIndex = ownsTurnChrome
? segment.items.findLastIndex((item) => item.kind === 'processing')
: -1;
// Every Turn owns this row, empty or not, so the cue never moves and
// settlement does not shift the transcript.
const liveWorkOwnsDisclosure = ownsTurnChrome && activityProcessIndex === -1;
// Disjoint namespaces: a steering id is any string, so a bare
// sentinel could collide with a real one.
const assistantKey =
Expand All @@ -694,6 +694,18 @@ export const TurnView = memo(function TurnView(props: {
and Astryx tool group in the order the model produced them.
Intermediate text, reasoning and tools share a disclosure;
the final reply and inserted user instructions stay outside. */}
{liveWorkOwnsDisclosure && (
<ProcessingBlock
key="processing-status"
activityObserved={props.activityObserved}
entries={[]}
running={!!props.liveStreaming || turn.status === 'running'}
durationMs={turn.durationMs}
activity={props.liveStreaming?.runningStatus && !props.liveStreaming.providerRetry
? { startedAt: turn.startedAt, label: runningToolLabel }
: undefined}
/>
)}
{segment.items.map((item, index) =>
item.kind === 'processing' ? (
<ProcessingBlock
Expand Down Expand Up @@ -797,12 +809,6 @@ export const TurnView = memo(function TurnView(props: {
live={!!props.liveStreaming}
activity={props.liveStreaming?.providerRetry ? (
<ModelProviderRetryIndicator retry={props.liveStreaming.providerRetry} />
) : props.liveStreaming?.runningStatus && activityProcessIndex === -1 ? (
<TurnRunningStatus
startedAt={turn.startedAt}
showSpinner={!toolSurfaceOwnsSpinner}
activityLabel={runningToolLabel}
/>
) : undefined}
context={answerContext}
onAction={
Expand Down Expand Up @@ -994,12 +1000,10 @@ export function TurnFooter(props: {
);
}

/** "model · duration · cost" for a settled turn; undefined when there is nothing to say. */
/** "model · cost" for a settled turn; the elapsed lives in the process row. */
function turnMetaSummary(turn: TurnViewModel): string | undefined {
const parts: string[] = [];
if (turn.modelId) parts.push(turn.modelId);
// Duration counts whole seconds, so anything under one would read「0s」.
if (turn.durationMs && turn.durationMs >= 1_000) parts.push(formatTurnDuration(turn.durationMs));
if (turn.tokens?.costUsd && turn.tokens.costUsd > 0) parts.push(`$${turn.tokens.costUsd.toFixed(4)}`);
return parts.length > 0 ? parts.join(' · ') : undefined;
}
Expand All @@ -1025,9 +1029,8 @@ const WORKING_PHRASE_INTERVAL_MS = 20_000;
* rare fallback path where streaming beat the user turn into the transcript;
* the phrase then stands alone.
*/
export function TurnRunningStatus(props: {
function TurnRunningStatus(props: {
startedAt?: number;
showSpinner?: boolean;
activityLabel?: string;
}) {
const copy = getConversationCopy(useUiLocale()).messages;
Expand All @@ -1046,9 +1049,6 @@ export function TurnRunningStatus(props: {
aria-label={props.activityLabel ?? copy.processing}
ref={rootRef}
>
{props.showSpinner !== false && (
<Spinner size="md" shade="subtle" aria-hidden="true" />
)}
{/* Name the activity once; the clock must not announce each second. */}
<span className="maka-turn-indicator-text" aria-hidden="true">
<span className="maka-turn-status-label">
Expand Down Expand Up @@ -1325,7 +1325,7 @@ function TurnTimelineEntry(props: {
);
}

function ProcessingBlock(props: {
export function ProcessingBlock(props: {
activityObserved?: boolean;
entries: FoldedTimelineChild[];
running: boolean;
Expand All @@ -1339,21 +1339,13 @@ function ProcessingBlock(props: {
const copy = getConversationCopy(useUiLocale()).messages;
// null follows the lifecycle: open while running, collapsed on completion.
// Settled reader choices survive appended events. Live work stays expanded.
// A failed tool is an ordinary row: no label and no reveal of its own.
const [manualOpen, setManualOpen] = useState<boolean | null>(null);
const needsAttention = props.entries.some((entry) => entry.kind === 'tools'
&& entry.items.some((tool) => tool.status === 'errored' || tool.status === 'interrupted'));
// Reveal a new failure even if a prior settled process was collapsed.
// They can close it again once settled; its attention label remains visible. Permission
// requests and turn recovery banners are owned outside the timeline.
useEffect(() => {
if (needsAttention) setManualOpen(null);
}, [needsAttention]);
const open = props.running || (manualOpen ?? needsAttention);
const open = props.running || manualOpen === true;
const seconds = props.durationMs !== undefined && Number.isFinite(props.durationMs)
? Math.floor(Math.max(0, props.durationMs) / 1000)
: undefined;
const label = needsAttention ? copy.processNeedsAttention
: props.running || seconds === undefined ? copy.processDetails
const label = props.running || seconds === undefined ? copy.processDetails
: copy.processDuration(Math.floor(seconds / 60), seconds % 60);
return (
<details
Expand All @@ -1372,11 +1364,10 @@ function ProcessingBlock(props: {
if (!props.running) setManualOpen(!open);
}}
>
{props.activity && !needsAttention ? (
{props.activity ? (
<TurnRunningStatus
startedAt={props.activity.startedAt}
activityLabel={props.activity.label}
showSpinner={false}
/>
) : <span>{label}</span>}
{!props.running && <ChevronRight size={ICON_SIZE.meta} aria-hidden="true" />}
Expand Down
Loading