Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
43 changes: 43 additions & 0 deletions docs/design/web-shell-collapsed-thinking-performance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Web Shell collapsed thinking performance

## Problem

Pure assistant and thought tail appends wake the top-level `App`, even though
only the transcript row needs the new text. Compact activity summaries also
keep their complete tool and thought subtree mounted while collapsed, so hidden
rows continue reconciling streamed thought props.

## Design

The top-level app consumes a structural transcript snapshot. A store change
summary proves when an update is only an append to the active assistant or
thought block; those app-level notifications are ignored. Stores without a
change summary retain the existing behavior.

The message list separately consumes the live throttled snapshot and applies
the existing streaming-tail projector against the app's latest structural
messages. This updates only the visible tail without starting a second
background-agent reconciliation loop. Structural changes still flow through
the app and replace the baseline immediately. Insight protocol markers use a
full projection while retaining the unchanged message prefix.

Compact tool summaries mount their detail subtree only while expanded, except
for MCP Apps whose iframe state must survive a collapse. The summary button
remains live while collapsed; expanding reconstructs the current tool and
thought rows from props. Collapse and expansion are immediate and unanimated.

## Compatibility

Tool, permission, terminal, reset, history, and session changes remain
structural. Transcript callbacks continue receiving live snapshots from the
message-list boundary. Collapsing a compact group no longer preserves local
expanded state inside its hidden detail rows.

## Verification

- Prove structural snapshots ignore pure tail appends and resume on the next
structural change.
- Prove a collapsed compact group has no detail subtree and restores current
details when expanded.
- Run the deterministic folded-thought performance scenario and targeted unit
tests.
44 changes: 43 additions & 1 deletion packages/web-shell/client/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,9 @@ const {
streamingState: 'idle' as StreamingState,
sessionHasActivePrompt: false,
blocks: [] as unknown[],
liveBlocks: undefined as unknown[] | undefined,
messages: [] as unknown[],
streamingTailMessages: undefined as unknown[] | undefined,
queuedPromptHoldHistory: [] as boolean[],
queuedPromptStreamingState: 'idle',
queuedPromptSessionHasActivePrompt: false,
Expand Down Expand Up @@ -401,6 +403,7 @@ const {
isResponding?: boolean;
transcriptReloadPaused?: boolean;
activeTurnStartedAt?: number;
transcriptBlockCount?: number;
terminalBackgroundShellTaskIds?: ReadonlySet<string>;
} | null,
latestBtwMessageProps: null as {
Expand Down Expand Up @@ -586,12 +589,20 @@ vi.mock('@qwen-code/sdk/daemon', () => {
});

vi.mock('./hooks/useMessages', () => ({
projectStreamingTailMessages: () => testState.streamingTailMessages,
useMessages: () => testState.messages,
useMessagesFromBlocks: () => testState.messages,
}));

vi.mock('./hooks/useAnimationFrameTranscriptBlocks', () => ({
useAnimationFrameTranscriptSnapshot: () => ({ blocks: testState.blocks }),
useAnimationFrameTranscriptSnapshot: (options?: {
structuralOnly?: boolean;
}) => ({
blocks:
options?.structuralOnly === true
? testState.blocks
: (testState.liveBlocks ?? testState.blocks),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The snapshot mock now branches on options?.structuralOnly, but no assertion depends on which option the App-level caller passes — every top-level blocks consumer is mocked to ignore its argument or sees no difference between blocks and liveBlocks. That leaves the App-level { structuralOnly: true } opt-in (App.tsx:2290), the wiring this PR exists to deliver, unpinned: if a future change drops that option, the top-level render path re-subscribes to every streamed tail append — exactly the jank this PR fixes — and the whole suite stays green. An A/B probe at this commit confirms it: dropping { structuralOnly: true } from App.tsx leaves all 684 tests of the four changed suites passing on both arms (Tests 684 passed (684)).

Record the options each call site passes into the mock and assert on them in the boundary test, e.g.:

// in the mock
useAnimationFrameTranscriptSnapshot: (options?: { structuralOnly?: boolean }) => {
  testState.snapshotCallOptions.push(options);
  return {
    blocks:
      options?.structuralOnly === true
        ? testState.blocks
        : (testState.liveBlocks ?? testState.blocks),
  };
},
// in the boundary test (declare/reset snapshotCallOptions in beforeEach)
expect(testState.snapshotCallOptions).toContainEqual({ structuralOnly: true }); // App-level call
expect(testState.snapshotCallOptions).toContainEqual(undefined); // LiveMessageList call
中文说明

快照 mock 现在会根据 options?.structuralOnly 分支,但没有任何断言依赖 App 层调用者传入的选项——所有顶层 blocks 消费者要么被 mock 成忽略参数,要么对 blocksliveBlocks 看不出差别。因此 App 层的 { structuralOnly: true } 订阅(App.tsx:2290)——本 PR 的核心接线——没有被任何测试锁定:未来若有改动去掉该选项,顶层渲染路径会重新订阅每一次流式尾部追加——正是本 PR 要修复的卡顿——而整个测试套件仍全绿。在本提交上的 A/B 探针证实了这一点:去掉 App.tsx 中的 { structuralOnly: true } 后,四个改动套件的 684 个测试在两臂上均全部通过(Tests 684 passed (684))。

建议将各调用点传入的选项记录到 mock(如 push 进 testState.snapshotCallOptions,并在 beforeEach 中声明/重置),并在边界测试中分别断言 App 层调用带 { structuralOnly: true }LiveMessageList 调用不带(见上方英文部分的 ts 代码块)。

— qwen3.8-max via Qwen Code /review (v0.22.0)

}),
}));

vi.mock('./hooks/useBackgroundTasks', () => ({
Expand Down Expand Up @@ -5018,7 +5029,9 @@ beforeEach(() => {
testState.streamingState = 'idle';
testState.sessionHasActivePrompt = false;
testState.blocks = [];
testState.liveBlocks = undefined;
testState.messages = [];
testState.streamingTailMessages = undefined;
testState.queuedPromptHoldHistory = [];
testState.queuedPromptStreamingState = 'idle';
testState.queuedPromptSessionHasActivePrompt = false;
Expand Down Expand Up @@ -5210,6 +5223,35 @@ afterEach(() => {
vi.unstubAllGlobals();
});

describe('App live transcript boundary', () => {
it('renders and copies the projected live tail over the structural baseline', async () => {
const writeText = vi
.spyOn(navigator.clipboard, 'writeText')
.mockResolvedValue();
testState.prompt = '/copy';
testState.blocks = [{ id: 'assistant', text: 'a' }];
testState.liveBlocks = [
{ id: 'assistant', text: 'ab' },
{ id: 'tool', kind: 'tool' },
];
testState.messages = [{ id: 'assistant', role: 'assistant', content: 'a' }];
testState.streamingTailMessages = [
{ id: 'assistant', role: 'assistant', content: 'ab' },
];

renderApp();
await flush();

expect(testState.latestMessageListProps?.messages).toMatchObject([
{ id: 'assistant', role: 'assistant', content: 'ab' },
]);
expect(testState.latestMessageListProps?.transcriptBlockCount).toBe(2);

await clickSubmit(document.body);
await vi.waitFor(() => expect(writeText).toHaveBeenCalledWith('ab'));
});
});

describe('App compact mode', () => {
async function toggleCompactMode() {
await act(async () => {
Expand Down
155 changes: 117 additions & 38 deletions packages/web-shell/client/App.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import './styles/globals.css';
import {
createContext,
forwardRef,
memo,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type ComponentPropsWithoutRef,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent,
type ReactNode,
Expand Down Expand Up @@ -42,6 +45,7 @@ import type {
DaemonInputAnnotation,
DaemonSessionAgentTaskStatus,
DaemonSkillToggleMutation,
DaemonTranscriptBlockChangeSummary,
DaemonTranscriptBlock,
DaemonSessionMonitorTaskStatus,
DaemonSessionShellTaskStatus,
Expand Down Expand Up @@ -212,7 +216,10 @@ import { mergeCommands } from './hooks/daemonSessionMappers';
import { useAnimationFrameTranscriptSnapshot } from './hooks/useAnimationFrameTranscriptBlocks';
import { useBackgroundTasks } from './hooks/useBackgroundTasks';
import { isSessionDisconnectedError } from './utils/sessionErrors';
import { useMessagesFromBlocks } from './hooks/useMessages';
import {
projectStreamingTailMessages,
useMessagesFromBlocks,
} from './hooks/useMessages';
import { useSessionArtifacts } from './hooks/useSessionArtifacts';
import { useShallowMemo, useStableArray } from './hooks/useShallowMemo';
import {
Expand Down Expand Up @@ -837,6 +844,97 @@ interface LocalAnchoredMessage {
message: Message;
}

function buildDisplayMessages(
messages: Message[],
recapMessage: LocalAnchoredMessage | null,
): Message[] {
if (!recapMessage) return filterModelSwitchMessages(messages);
const result = [...messages];
const anchorIndex = recapMessage.anchorAfterId
? result.findIndex((message) => message.id === recapMessage.anchorAfterId)
: -1;
result.splice(
anchorIndex >= 0
? anchorIndex + 1
: Math.min(recapMessage.anchorIndex, result.length),
0,
recapMessage.message,
);
return filterModelSwitchMessages(result);
}

type LiveMessageListProps = Omit<
ComponentPropsWithoutRef<typeof MessageList>,
'messages' | 'transcriptBlockCount'
> & {
baselineBlocks: readonly DaemonTranscriptBlock[];
baselineSummary?: DaemonTranscriptBlockChangeSummary;
baselineMessages: Message[];
recapMessage: LocalAnchoredMessage | null;
messagesRef: { current: Message[] };
onTranscriptChange?: (blocks: readonly DaemonTranscriptBlock[]) => void;
t: ReturnType<typeof getTranslator>;
};

const LiveMessageList = memo(
forwardRef<MessageListHandle, LiveMessageListProps>(function LiveMessageList(
{
baselineBlocks,
baselineSummary,
baselineMessages,
recapMessage,
messagesRef,
onTranscriptChange,
t,
...props
},
ref,
) {
const live = useAnimationFrameTranscriptSnapshot();
const messages = useMemo(
() =>
projectStreamingTailMessages(
{
blocks: baselineBlocks,
messages: baselineMessages,
t,
blockChangeSummary: baselineSummary,
},
live.blocks,
t,
live.blockChangeSummary,
) ?? baselineMessages,
[
baselineBlocks,
baselineMessages,
baselineSummary,
live.blockChangeSummary,
live.blocks,
t,
],
);
const displayMessages = useMemo(
() => buildDisplayMessages(messages, recapMessage),
[messages, recapMessage],
);
useLayoutEffect(() => {
messagesRef.current = messages;
}, [messages, messagesRef]);
Comment thread
ytahdn marked this conversation as resolved.
useEffect(() => {
onTranscriptChange?.(live.blocks);
}, [live.blocks, onTranscriptChange]);

return (
<MessageList
{...props}
ref={ref}
messages={displayMessages}
transcriptBlockCount={live.blocks.length}
/>
);
}),
);

interface ModelSwitchSummary {
authType: string;
modelId: string;
Expand Down Expand Up @@ -2189,7 +2287,9 @@ export function App({
const CustomComposerHeader = renderComposerHeader;
const CustomComposerFooter = renderComposerFooter;
const store = useTranscriptStore();
const { blocks, blockChangeSummary } = useAnimationFrameTranscriptSnapshot();
const { blocks, blockChangeSummary } = useAnimationFrameTranscriptSnapshot({
structuralOnly: true,
});
const connection = useConnection();
const logicalSessionKey = getLogicalSessionKey(
connection.sessionId,
Expand Down Expand Up @@ -2617,7 +2717,6 @@ export function App({

const messages = useMessagesFromBlocks(t, blocks, blockChangeSummary);
const messagesRef = useRef(messages);
messagesRef.current = messages;
const [failedPrompt, setFailedPrompt] = useState<FailedPrompt | null>(null);
const failedPromptRef = useRef<FailedPrompt | null>(failedPrompt);
const [failedPromptRetry, setFailedPromptRetry] =
Expand Down Expand Up @@ -2689,31 +2788,10 @@ export function App({
const lastNotifiedSessionIdRef = useRef<string | undefined>(undefined);
const lastNotifiedWorkspaceIdRef = useRef<string | undefined>(undefined);
const lastNotifiedWorkspaceCwdRef = useRef<string | undefined>(undefined);
const displayMessages = useMemo(() => {
const localMessages = [recapMessage].filter(
(message): message is LocalAnchoredMessage => message !== null,
);
if (localMessages.length === 0) {
return filterModelSwitchMessages(messages);
}

const result = [...messages];
for (const localMessage of localMessages.sort(
(a, b) => a.anchorIndex - b.anchorIndex,
)) {
const anchorIndex = localMessage.anchorAfterId
? result.findIndex(
(message) => message.id === localMessage.anchorAfterId,
)
: -1;
const index =
anchorIndex >= 0
? anchorIndex + 1
: Math.min(localMessage.anchorIndex, result.length);
result.splice(index, 0, localMessage.message);
}
return filterModelSwitchMessages(result);
}, [messages, recapMessage]);
const displayMessages = useMemo(
() => buildDisplayMessages(messages, recapMessage),
[messages, recapMessage],
);
useEffect(() => {
const failed = failedPromptRef.current;
if (!failed) return;
Expand Down Expand Up @@ -6820,8 +6898,9 @@ export function App({
if (sessionWriteBlocked) return;
if (!requireActiveSessionForLocalCommand()) return;
const messageId = `local-recap-${nextRecapMessageIdRef.current++}`;
const anchorIndex = messages.length;
const anchorAfterId = messages.at(-1)?.id;
const currentMessages = messagesRef.current;
const anchorIndex = currentMessages.length;
const anchorAfterId = currentMessages.at(-1)?.id;
const sessionId = connection.sessionId;
const workspaceCwd = connection.workspaceCwd;
setRecapMessage({
Expand Down Expand Up @@ -6871,7 +6950,6 @@ export function App({
}, [
connection.sessionId,
connection.workspaceCwd,
messages,
requireActiveSessionForLocalCommand,
sessionWriteBlocked,
sessionActions,
Expand Down Expand Up @@ -7830,10 +7908,6 @@ export function App({
onConnectionChange?.(connection.status);
}, [connection.status, onConnectionChange]);

useEffect(() => {
onTranscriptChange?.(blocks);
}, [blocks, onTranscriptChange]);

useEffect(() => {
if (connection.error) {
const error = new Error(connection.error);
Expand Down Expand Up @@ -12794,9 +12868,15 @@ export function App({
.join(' ');

const messageListContent = (
<MessageList
<LiveMessageList
ref={messageListRef}
messages={displayMessages}
baselineBlocks={blocks}
baselineSummary={blockChangeSummary}
baselineMessages={messages}
recapMessage={recapMessage}
messagesRef={messagesRef}
onTranscriptChange={onTranscriptChange}
t={t}
terminalBackgroundShellTaskIds={
terminalBackgroundShellTaskIds
}
Expand All @@ -12812,7 +12892,6 @@ export function App({
historyPaginationError={
transcriptHistory.paginationError}
onLoadOlderHistory={transcriptHistory.loadMore}
transcriptBlockCount={blocks.length}
transcriptActivity={store}
onReloadTranscript={
transcriptReloadSupported
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,12 @@ describe('WebShellTranscript DOM integration', () => {
<WebShellTranscript blocks={blocks} collapseCompletedTurns={false} />,
);

const summary = container.querySelector('button')!;
expect(summary.textContent).toContain('Asked 1 question');
expect(summary.getAttribute('aria-expanded')).toBe('false');

act(() => summary.click());

expect(container.textContent).toContain('Ask user 1 question');
expect(container.textContent).toContain('User answer: Staging');
expect(container.querySelector('button[type="submit"]')).toBeNull();
Expand Down
Loading
Loading