Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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. The
summary button remains live while collapsed; expanding reconstructs the current
tool and thought rows from props. Collapse is immediate so the hidden subtree
stops work without waiting for an exit animation.

## 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.
1 change: 1 addition & 0 deletions packages/web-shell/client/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,7 @@ vi.mock('@qwen-code/sdk/daemon', () => {
});

vi.mock('./hooks/useMessages', () => ({
projectStreamingTailMessages: () => undefined,
useMessages: () => testState.messages,
useMessagesFromBlocks: () => testState.messages,
}));
Expand Down
158 changes: 120 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,9 @@ export function App({

const messages = useMessagesFromBlocks(t, blocks, blockChangeSummary);
const messagesRef = useRef(messages);
messagesRef.current = messages;
useLayoutEffect(() => {
messagesRef.current = messages;
}, [messages]);
const [failedPrompt, setFailedPrompt] = useState<FailedPrompt | null>(null);
const failedPromptRef = useRef<FailedPrompt | null>(failedPrompt);
const [failedPromptRetry, setFailedPromptRetry] =
Expand Down Expand Up @@ -2689,31 +2791,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 +6901,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 +6953,6 @@ export function App({
}, [
connection.sessionId,
connection.workspaceCwd,
messages,
requireActiveSessionForLocalCommand,
sessionWriteBlocked,
sessionActions,
Expand Down Expand Up @@ -7830,10 +7911,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 +12871,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 +12895,6 @@ export function App({
historyPaginationError={
transcriptHistory.paginationError}
onLoadOlderHistory={transcriptHistory.loadMore}
transcriptBlockCount={blocks.length}
transcriptActivity={store}
onReloadTranscript={
transcriptReloadSupported
Expand Down
25 changes: 19 additions & 6 deletions packages/web-shell/client/components/messages/ToolGroup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,7 @@ describe('tool group summary logic', () => {
args: { file_path: 'README.md' },
}),
]);
act(() => container.querySelector('button')?.click());

expect(container.textContent).toContain('Shell');
expect(container.textContent).toContain('查询用户工作空间列表');
Expand Down Expand Up @@ -998,6 +999,7 @@ describe('tool row rendering', () => {
},
}),
]);
act(() => container.querySelector('button')?.click());

const titleRow = container.querySelector('[class*="expandedCardTitleRow"]');
expect(titleRow).not.toBeNull();
Expand All @@ -1012,6 +1014,7 @@ describe('tool row rendering', () => {
content: [{ type: 'content', content: { text: 'Permission denied' } }],
}),
]);
act(() => container.querySelector('button')?.click());

const titleRow = container.querySelector('[class*="expandedCardTitleRow"]');
expect(titleRow).not.toBeNull();
Expand All @@ -1022,6 +1025,7 @@ describe('tool row rendering', () => {
const container = renderToolGroup([
makeTool({ toolName: 'glob', status: 'failed' }),
]);
act(() => container.querySelector('button')?.click());

const titleRow = container.querySelector('[class*="expandedCardTitleRow"]');
expect(titleRow).not.toBeNull();
Expand Down Expand Up @@ -1805,19 +1809,19 @@ describe('thinking rows in the compact summary', () => {
true,
);
const outerSummary = container.querySelector('button')!;
const parallelSummary = Array.from(
container.querySelectorAll('button'),
).find((button) => button.textContent?.includes('Parallel agents'))!;

expect(outerSummary.textContent).toContain('Ran 2 agents');
expect(
container.querySelector('[data-testid="compact-parallel-agents"]'),
).not.toBeNull();
).toBeNull();
expect(outerSummary.getAttribute('aria-expanded')).toBe('false');
expect(parallelSummary.getAttribute('aria-expanded')).toBe('false');

act(() => outerSummary.click());
const parallelSummary = Array.from(
container.querySelectorAll('button'),
).find((button) => button.textContent?.includes('Parallel agents'))!;
expect(outerSummary.getAttribute('aria-expanded')).toBe('true');
expect(parallelSummary.getAttribute('aria-expanded')).toBe('false');
expect(parallelSummary.textContent).toContain('2/2 done');

act(() => parallelSummary.click());
Expand All @@ -1841,6 +1845,15 @@ describe('thinking rows in the compact summary', () => {
expect(container.querySelector('button')?.textContent).toContain(
'Thinking',
);
expect(
container.querySelector('[class*="chatSummaryThoughtHeader"]'),
).toBeNull();

act(() => container.querySelector('button')?.click());

expect(
container.querySelector('[class*="chatSummaryThoughtHeader"]'),
).not.toBeNull();
});

it('renders a completed thought line that expands its content on click', () => {
Expand Down Expand Up @@ -2032,8 +2045,8 @@ describe('thinking rows in the compact summary', () => {
],
);

act(() => container.querySelector('button')?.click());
act(() => {
container.querySelector('button')?.click();
for (const header of container.querySelectorAll(
'[data-testid="compact-thinking-summary"]',
)) {
Expand Down
Loading
Loading