Skip to content

Commit 3ab7bc2

Browse files
committed
fix(desktop): show side-conversation message + progress immediately
The side conversation (quote-companion side chat) gave no immediate feedback on send: it rendered the user's message and the running-status line only after the turn settled, and — because it forks lazily — nothing at all during the first send's fork round trip. - Render the user's message optimistically on send (transientMessages), matching the main conversation, instead of only after the turn completes. The bubble is armed before the fork exists, so a cold first send is not blank. - Render that optimistic content even before a session exists: ChatView's no-activeSession branch now shows transientMessages + the running-status line, so the first question and progress cue appear during the lazy fork creation (they were previously dropped until the fork committed). - Drive the running-status line from `streaming || transientMessages.length > 0` with the same rising-edge delay as the main chat. The admission (and the Composer's Stop button) is armed only in onBeforeSend, once the fork exists and stop() can act on it — so Stop never appears while it would be a no-op. - Wait for the just-sent user message to be durable before the settled read so the turn materializes promptly. The Host mints the message id from the turn id, so one identity gates every path. Per-session workbar collapse (item 3 of #4654) is deferred to its own issue (#4693): it needs a decision on whether the collapse preference persists across restart, and should hold the session key in the layout reducer. Refs #4654 Generated-by: Claude Code
1 parent cd4aa3d commit 3ab7bc2

6 files changed

Lines changed: 338 additions & 24 deletions

File tree

apps/desktop/e2e/slash-command-menu.spec.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,12 @@ test('dispatches /side instead of steering it into a running turn', async ({
233233
await composer.press('Enter');
234234

235235
await expect(page.locator('.maka-quote-workbar-panel')).toHaveCount(1);
236-
await page.getByRole('button', { name: '停止' }).click();
236+
// The side conversation now shows its own running/停止 state the instant it is
237+
// dispatched, so a bare getByRole('停止') matches two buttons (the held-open
238+
// main turn AND the side turn). Scope to the main column — the side panel
239+
// lives in the sibling WorkbarHost — and stop the reliably-open main turn (the
240+
// side turn's short prompt settles too fast to click deterministically).
241+
await page.locator('.mainColumn').getByRole('button', { name: '停止' }).click();
237242
});
238243

239244
test('an open menu keeps its container and skills group across projection refreshes', async ({
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
import assert from 'node:assert/strict';
21+
import { test } from 'node:test';
22+
import { type ComponentProps, createElement } from 'react';
23+
import { renderToStaticMarkup } from 'react-dom/server';
24+
import {
25+
AstryxLocaleProvider,
26+
ChatSurfaceLayout,
27+
ChatView,
28+
LocaleProvider,
29+
type TransientUserMessageProjection,
30+
} from '@maka/ui';
31+
32+
// A side conversation forks lazily: its first send arms the optimistic bubble
33+
// (and, after the delay, the running-status line) BEFORE the fork commits, so
34+
// `activeSession` is still undefined. These tests pin that `ChatView` renders
35+
// that optimistic content in its no-session branch — the render-layer half of
36+
// #4654 that the hook-only tests could not prove. The panel wires it up:
37+
// `activeSession={companion.companionSession}` (undefined pre-fork) and
38+
// `transientMessages`/`runningStatus` from the same hook.
39+
function renderNoSessionChatView(
40+
props: Partial<ComponentProps<typeof ChatView>>,
41+
): string {
42+
const view = createElement(ChatView, {
43+
messages: [],
44+
activeSession: undefined,
45+
onNew: () => {},
46+
...props,
47+
} as ComponentProps<typeof ChatView>);
48+
const layout = createElement(ChatSurfaceLayout, {
49+
scrollOwner: 'host',
50+
composer: null,
51+
children: view,
52+
});
53+
const astryx = createElement(AstryxLocaleProvider, { children: layout });
54+
return renderToStaticMarkup(
55+
createElement(LocaleProvider, { locale: 'en', children: astryx }),
56+
);
57+
}
58+
59+
const OPTIMISTIC_BUBBLE: TransientUserMessageProjection = {
60+
id: 'turn-1',
61+
text: 'why does this fail?',
62+
ts: 1,
63+
transientPlacement: 'current_turn',
64+
};
65+
66+
test('ChatView renders the optimistic bubble and running status before a session exists', () => {
67+
const markup = renderNoSessionChatView({
68+
transientMessages: [OPTIMISTIC_BUBBLE],
69+
runningStatus: true,
70+
});
71+
// The user's question is on screen immediately, before the fork/session lands.
72+
assert.match(markup, /why does this fail\?/);
73+
// The running-status line rides alongside it (the no-turn bare-turn fallback).
74+
assert.match(markup, /data-live-streaming="true"/);
75+
});
76+
77+
test('ChatView shows no optimistic content when there is neither a bubble nor a running turn', () => {
78+
const markup = renderNoSessionChatView({
79+
transientMessages: [],
80+
runningStatus: false,
81+
});
82+
assert.doesNotMatch(markup, /why does this fail\?/);
83+
assert.doesNotMatch(markup, /data-live-streaming="true"/);
84+
});

apps/desktop/src/main/__tests__/quote-companion-retry.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,83 @@ test('first send after a completed turn forks through the settled turn', async (
376376
assert.equal(probe.getAttribute('data-error'), '');
377377
});
378378

379+
test('a first send shows the question bubble immediately but arms Stop only once the fork exists', async () => {
380+
// `branchFromTurn` is the Host round trip a first send waits on. Holding it
381+
// open lets us observe the panel while the fork is still being created.
382+
const branch = deferred<{ ok: true; session: SessionSummary }>();
383+
const rendered = await renderOwnershipProbe({
384+
listTurns: async () => [settledTurn('done-turn')],
385+
branchFromTurn: () => branch.promise,
386+
send: async () => ({ ok: true as const, turnId: 'first-turn' }),
387+
});
388+
const probe = rendered.container.firstElementChild;
389+
assert.ok(probe);
390+
// Nothing sent yet: no fork, no bubble, not streaming.
391+
assert.equal(probe.getAttribute('data-companion-id'), '');
392+
assert.equal(probe.getAttribute('data-transient-count'), '0');
393+
assert.equal(probe.getAttribute('data-streaming'), 'false');
394+
395+
// Kick off the send but leave fork creation pending (branch unresolved).
396+
let sendResult!: Promise<boolean>;
397+
await act(async () => {
398+
sendResult = rendered.send('why does this fail?');
399+
await Promise.resolve();
400+
});
401+
await waitUntil(() => probe.getAttribute('data-transient-count') === '1');
402+
403+
// The fork has NOT committed yet, but the question bubble is already on screen
404+
// — the instant feedback #4654 asked for, and what the panel's running-status
405+
// line rides on (`streaming || transientMessages.length > 0`) before a turn
406+
// exists. Crucially `streaming` is still false, so the Composer does NOT render
407+
// a Stop button during the window where `stop()` is a no-op (companionIdRef is
408+
// only set at commitFork). Arming the admission early would show a dead Stop.
409+
assert.equal(probe.getAttribute('data-companion-id'), '');
410+
assert.equal(probe.getAttribute('data-transient-text'), 'why does this fail?');
411+
assert.equal(probe.getAttribute('data-streaming'), 'false');
412+
413+
// Once the fork commits and the send goes in flight, the admission arms:
414+
// streaming turns true, so Stop appears exactly when it can act on the turn.
415+
await act(async () => {
416+
branch.resolve({ ok: true as const, session: session('side-conversation') });
417+
assert.equal(await sendResult, true);
418+
await Promise.resolve();
419+
});
420+
await awaitCompanion(rendered.container);
421+
await waitUntil(() => probe.getAttribute('data-streaming') === 'true');
422+
assert.equal(probe.getAttribute('data-error'), '');
423+
assert.equal(probe.getAttribute('data-transient-count'), '1');
424+
425+
// The running state rides the whole turn and only retires on completion.
426+
await act(async () => {
427+
rendered.emit(completeEvent('c1', 'first-turn', 2));
428+
await Promise.resolve();
429+
});
430+
await waitUntil(() => probe.getAttribute('data-streaming') === 'false');
431+
});
432+
433+
test('a failed first send retires the optimistic bubble without ever arming Stop', async () => {
434+
// The fork never materializes: `branchFromTurn` throws. The optimistic bubble
435+
// must be unwound so nothing is stranded with no turn to reconcile it away, and
436+
// Stop must never have appeared (the admission is armed only in onBeforeSend).
437+
const rendered = await renderOwnershipProbe({
438+
listTurns: async () => [settledTurn('done-turn')],
439+
branchFromTurn: async () => {
440+
throw new Error('fork setup exploded');
441+
},
442+
});
443+
const probe = rendered.container.firstElementChild;
444+
assert.ok(probe);
445+
446+
await act(async () => {
447+
assert.equal(await rendered.send('why does this fail?'), false);
448+
await Promise.resolve();
449+
});
450+
assert.equal(probe.getAttribute('data-companion-id'), '');
451+
assert.equal(probe.getAttribute('data-transient-count'), '0');
452+
assert.equal(probe.getAttribute('data-streaming'), 'false');
453+
assert.equal(probe.getAttribute('data-live-turn-id'), '');
454+
});
455+
379456
test('dispatches /compact to the committed companion fork without sending model input', async () => {
380457
const compactCalls: string[] = [];
381458
let sendCalls = 0;
@@ -1968,6 +2045,8 @@ function QuoteCompanionOwnershipProbe(props: {
19682045
'data-processing': String(companion.processing),
19692046
'data-model-ready': String(companion.modelReady),
19702047
'data-permission-mode': companion.permissionMode ?? '',
2048+
'data-transient-count': String(companion.transientMessages.length),
2049+
'data-transient-text': companion.transientMessages[0]?.text ?? '',
19712050
});
19722051
}
19732052

apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
* under the License.
1818
*/
1919

20-
import { useCallback, useEffect, useRef, type ComponentProps } from 'react';
20+
import { useCallback, useEffect, useRef, useState, type ComponentProps } from 'react';
2121
import { Banner } from '@astryxdesign/core/Banner';
2222
import {
2323
ChatView,
@@ -57,6 +57,28 @@ import type { CompanionForkVisibilityEvent } from './quote-companion-visibility'
5757
import { readScrollMotionBehavior } from '../../../../scroll-motion-policy';
5858
import { useWorkbarServices } from '../../services-context.js';
5959

60+
const RUNNING_STATUS_DELAY_MS = 200;
61+
62+
/**
63+
* A boolean that turns true only after `condition` has held for `delayMs`, and
64+
* false the moment it drops — the rising-edge delay that keeps a fast turn from
65+
* flashing the running-status line. A feature-local copy of the shell's
66+
* useDelayedFlag: the renderer-legacy original is walled off from feature code
67+
* by the architecture budget, and this is only a few lines of timer plumbing.
68+
*/
69+
function useDelayedFlag(condition: boolean, delayMs: number): boolean {
70+
const [visible, setVisible] = useState(false);
71+
useEffect(() => {
72+
if (!condition) {
73+
setVisible(false);
74+
return;
75+
}
76+
const handle = window.setTimeout(() => setVisible(true), delayMs);
77+
return () => window.clearTimeout(handle);
78+
}, [condition, delayMs]);
79+
return visible;
80+
}
81+
6082
/**
6183
* The side-conversation workbar tab: a transient read-only fork of the main session.
6284
* It renders with the SAME surface as the main conversation — the real
@@ -169,6 +191,19 @@ export function QuoteCompanionPanel(props: {
169191
useEffect(() => {
170192
props.onContentStateChange?.(props.panelId, companion.hasContent);
171193
}, [companion.hasContent, props.onContentStateChange, props.panelId]);
194+
// The transcript's running-status line ("正在琢磨… · Ns"). Like the main chat
195+
// (useShellLiveTurn → showRunningStatus) it rides the whole active turn, not
196+
// just the pre-first-token wait, with the same rising-edge delay so a fast
197+
// turn never flashes it. The companion's `processing` only covers the wait
198+
// window, which is why the side panel used to show almost no progress cue.
199+
// `transientMessages` covers the first-send window BEFORE the fork commits and
200+
// the admission is armed: the optimistic bubble is on screen but `streaming`
201+
// is still false, and the cue must already be up (the admission is deliberately
202+
// armed late so the Stop button never appears before `stop()` can act on it).
203+
const showRunningStatus = useDelayedFlag(
204+
companion.streaming || companion.transientMessages.length > 0,
205+
RUNNING_STATUS_DELAY_MS,
206+
);
172207
useEffect(() => {
173208
props.onActivityStateChange?.(
174209
props.panelId,
@@ -371,9 +406,10 @@ export function QuoteCompanionPanel(props: {
371406
>
372407
<ChatView
373408
messages={companion.messages}
409+
transientMessages={companion.transientMessages}
374410
scrollBehavior={readScrollMotionBehavior()}
375411
liveTurn={companion.liveTurn}
376-
runningStatus={companion.processing}
412+
runningStatus={showRunningStatus}
377413
activeSession={companion.companionSession}
378414
onReadAttachmentBytes={attachments.readBytes}
379415
deriveTurnPresentation={deriveTurnPresentation}

0 commit comments

Comments
 (0)