feat(web-shell): fold thinking into the compact-mode tool summary (#9148)
Some checks are pending
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 1/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 2/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 3/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 1/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 2/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 3/3 (push) Waiting to run
E2E Tests / E2E Test - macOS - shard 1/2 (push) Waiting to run
E2E Tests / E2E Test - macOS - shard 2/2 (push) Waiting to run
E2E Tests / channel-plugin E2E (nightly) (push) Waiting to run
E2E Tests / cron-interactive E2E (nightly) (push) Waiting to run
E2E Tests / web-shell Browser Regression (push) Waiting to run
SDK Java / ubuntu-latest / Java 11 (push) Waiting to run
SDK Java / ubuntu-latest / Java 17 (push) Waiting to run
SDK Java / macos-latest / Java 21 (push) Waiting to run
SDK Java / ubuntu-latest / Java 21 (push) Waiting to run
SDK Java / windows-latest / Java 21 (push) Waiting to run
SDK Java / Real daemon E2E / Java 11 (push) Waiting to run
Security Checks / Dependency CVE audit (push) Waiting to run
Security Checks / Secret scan (TruffleHog) (push) Waiting to run

Compact mode used to drop thinking messages entirely, so a running turn
gave no indication of the thinking step. Keep the thoughts and aggregate
them with the adjacent tools into one summary: a streaming thought reads
"Thinking…" with the running shimmer, and a completed thought settles into
a click-to-expand row in its original interleaved position. The translate
action is preserved on both the thinking block and the folded thought
rows, and the merged group gets a synthetic id so its expanded state never
leaks into non-compact mode.

Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com>
This commit is contained in:
ytahdn 2026-08-14 11:01:09 +00:00 committed by GitHub
parent 4ade537a10
commit a669957f3d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 932 additions and 285 deletions

View file

@ -111,6 +111,19 @@ export interface DaemonToolGroupMessage extends DaemonMessageMeta {
id: string;
role: 'tool_group';
tools: DaemonMessageToolCall[];
/**
* Thinking folded into this group like a tool (compact mode). Streaming
* entries carry `isStreaming` so the summary can read "Thinking…" while
* the model works, then settle to a click-to-expand row when done.
* `beforeToolCallId` pins each thought to the tool that follows it so the
* group renders in the original interleaved order; thoughts without one
* trail the last tool.
*/
thoughts?: Array<{
content: string;
isStreaming?: boolean;
beforeToolCallId?: string;
}>;
}
export interface DaemonPlanMessage extends DaemonMessageMeta {

View file

@ -95,7 +95,6 @@ export const MessageItem = memo(function MessageItem({
case 'thinking':
return (
<ThinkingMessage
messageId={message.id}
content={message.content}
isStreaming={message.isStreaming}
timestamp={message.timestamp}
@ -107,9 +106,11 @@ export const MessageItem = memo(function MessageItem({
return (
<ToolGroup
tools={message.tools}
thoughts={message.thoughts}
pendingApproval={pendingApproval}
workspaceCwd={workspaceCwd}
isLocateFlashing={isLocateFlashing}
generateContent={generateContent}
/>
);
case 'plan':
@ -371,6 +372,7 @@ function areMessagesEqual(prev: Message, next: Message): boolean {
case 'tool_group':
return (
next.role === 'tool_group' &&
areToolGroupThoughtsEqual(prev.thoughts, next.thoughts) &&
prev.tools.length === next.tools.length &&
prev.tools.every((tool, index) =>
areToolCallsEqual(tool, next.tools[index]),
@ -419,6 +421,32 @@ function areToolCallsEqual(
);
}
function areToolGroupThoughtsEqual(
prev:
| Array<{
content: string;
isStreaming?: boolean;
beforeToolCallId?: string;
}>
| undefined,
next:
| Array<{
content: string;
isStreaming?: boolean;
beforeToolCallId?: string;
}>
| undefined,
): boolean {
if (prev === next) return true;
if (!prev || !next || prev.length !== next.length) return false;
return prev.every(
(thought, index) =>
thought.content === next[index]?.content &&
thought.isStreaming === next[index]?.isStreaming &&
thought.beforeToolCallId === next[index]?.beforeToolCallId,
);
}
function areToolListsEqual(
prev: ACPToolCall[] | undefined,
next: ACPToolCall[] | undefined,

View file

@ -506,7 +506,7 @@ describe('MessageList — failed prompt retry', () => {
});
describe('MessageList — compact mode', () => {
it('hides thinking rows without removing surrounding transcript content', () => {
it('keeps thinking without adjacent tools visible in compact mode', () => {
const container = mount(
[userMsg('u1'), thinkingMsg('t1'), asstMsg('a1')],
undefined,
@ -517,7 +517,7 @@ describe('MessageList — compact mode', () => {
);
expect(container.querySelector('[data-testid="msg-u1"]')).not.toBeNull();
expect(container.querySelector('[data-testid="msg-t1"]')).toBeNull();
expect(container.querySelector('[data-testid="msg-t1"]')).not.toBeNull();
expect(container.querySelector('[data-testid="msg-a1"]')).not.toBeNull();
rerenderMessages(container, [
@ -526,10 +526,14 @@ describe('MessageList — compact mode', () => {
thinkingMsg('t2'),
asstMsg('a1'),
]);
// With turn collapsing back on, the completed thinking folds behind the
// turn summary instead of hiding the surrounding transcript.
expect(container.querySelector('[data-testid="msg-t2"]')).toBeNull();
expect(container.querySelector('[data-testid="msg-u1"]')).not.toBeNull();
expect(container.querySelector('[data-testid="msg-a1"]')).not.toBeNull();
});
it('merges tool groups separated only by hidden thinking', () => {
it('merges tool groups separated by completed thinking', () => {
const container = mount(
[
userMsg('u1'),
@ -547,20 +551,24 @@ describe('MessageList — compact mode', () => {
},
);
expect(container.querySelector('[data-testid="msg-g1"]')).not.toBeNull();
expect(
container.querySelector('[data-testid="msg-summary-g1"]'),
).not.toBeNull();
expect(container.querySelector('[data-testid="msg-g2"]')).toBeNull();
expect(
container
.querySelector('[data-testid="msg-g1"]')
.querySelector('[data-testid="msg-summary-g1"]')
?.getAttribute('data-timestamp'),
).toBe('1000');
expect(
container
.querySelector('[data-testid="msg-g1"]')
.querySelector('[data-testid="msg-summary-g1"]')
?.getAttribute('data-tool-ids'),
).toBe('call-g1,call-g2');
expect(container.querySelector('[data-testid="msg-a1"]')).not.toBeNull();
expect(container.querySelector('[data-testid="msg-g3"]')).not.toBeNull();
expect(
container.querySelector('[data-testid="msg-summary-g3"]'),
).not.toBeNull();
});
it('keeps visible thinking and tool groups in transcript order', () => {
@ -621,7 +629,9 @@ describe('MessageList — compact mode', () => {
},
);
expect(container.querySelector('[data-testid="msg-g1"]')).not.toBeNull();
expect(
container.querySelector('[data-testid="msg-summary-g1"]'),
).not.toBeNull();
expect(
container.querySelector('[data-testid="msg-special"]'),
).not.toBeNull();
@ -645,11 +655,19 @@ describe('MessageList — compact mode', () => {
expect(
container.querySelector('[data-testid="msg-special"]'),
).not.toBeNull();
// The completed thinking folds into the adjacent tool group, which keeps
// the tool while the standalone group stays separate.
expect(
container
.querySelector('[data-testid="msg-g2"]')
.querySelector('[data-testid="msg-special"]')
?.getAttribute('data-tool-ids'),
).toBe('call-special');
expect(
container
.querySelector('[data-testid="msg-summary-t1"]')
?.getAttribute('data-tool-ids'),
).toBe('call-g2');
expect(container.querySelector('[data-testid="msg-g2"]')).toBeNull();
});
});
@ -2198,6 +2216,51 @@ describe('MessageList — turn collapse (DOM)', () => {
expect(c.textContent).toContain('Processing 3s');
});
it('folds streaming thinking into the tool summary while it runs', () => {
const c = mount(
[
userMsg('u1'),
{ ...thinkingMsg('t1'), isStreaming: true },
toolMsg('g1'),
],
undefined,
{ isResponding: true, compactMode: true },
);
// Streaming thinking merges into the group like a running tool.
expect(
c
.querySelector('[data-testid="msg-summary-t1"]')
?.getAttribute('data-tool-ids'),
).toBe('call-g1');
expect(c.querySelector('[data-testid="msg-g1"]')).toBeNull();
});
it('folds completed thinking into the merged tool summary in compact mode', () => {
const c = mount(
[userMsg('u1'), thinkingMsg('t1'), toolMsg('g1'), asstMsg('a1')],
undefined,
{ isResponding: true, compactMode: true },
);
// The thinking and the adjacent tool collapse into one group carrying
// the tool; the standalone thinking row is gone.
expect(
c
.querySelector('[data-testid="msg-summary-t1"]')
?.getAttribute('data-tool-ids'),
).toBe('call-g1');
expect(c.querySelector('[data-testid="msg-g1"]')).toBeNull();
});
it('does not fold completed thinking without adjacent tools', () => {
const c = mount(
[userMsg('u1'), thinkingMsg('t1'), asstMsg('a1')],
undefined,
{ isResponding: true, compactMode: true },
);
// No adjacent tool group: the thinking stays a standalone row.
expect(c.querySelector('[data-testid="msg-t1"]')).not.toBeNull();
});
it('toggle round-trip reveals then re-hides the step', () => {
const c = mount([userMsg('u1'), toolMsg('g1'), asstMsg('a1')]);
click(toggle(c, 'u1'));

View file

@ -19,7 +19,12 @@ import {
import { createPortal } from 'react-dom';
import { useVirtualizer } from '@tanstack/react-virtual';
import type { DaemonSessionArtifact } from '@qwen-code/sdk/daemon';
import type { Message, ACPToolCall, TurnCollapseHead } from '../adapters/types';
import type {
ToolGroupMessage as DaemonToolGroupMessage,
Message,
ACPToolCall,
TurnCollapseHead,
} from '../adapters/types';
import type { PermissionRequest } from '../adapters/types';
import {
isBackgroundSubAgentToolCall,
@ -267,10 +272,6 @@ function isForceExpandGroup(
return false;
}
function isHiddenInCompactMode(msg: Message): boolean {
return msg.role === 'thinking';
}
function isStandaloneToolGroup(msg: Message): boolean {
return (
msg.role === 'tool_group' &&
@ -290,63 +291,85 @@ function mergeCompactToolGroups(
const result: Message[] = [];
let i = 0;
const isMergedToolGroup = (m: Message): boolean =>
m.role === 'tool_group' &&
!isForceExpandGroup(m, pendingApproval) &&
!isStandaloneToolGroup(m);
while (i < messages.length) {
const msg = messages[i];
const isThinking = msg.role === 'thinking';
if (
msg.role !== 'tool_group' ||
isForceExpandGroup(msg, pendingApproval) ||
isStandaloneToolGroup(msg)
) {
if (!isHiddenInCompactMode(msg)) {
result.push(msg);
}
i++;
continue;
}
const mergeableGroups: Message[] = [msg];
let lastMergedIdx = i;
let j = i + 1;
while (j < messages.length) {
const next = messages[j];
if (isHiddenInCompactMode(next)) {
j++;
continue;
}
if (
next.role === 'tool_group' &&
!isForceExpandGroup(next, pendingApproval) &&
!isStandaloneToolGroup(next)
) {
mergeableGroups.push(next);
lastMergedIdx = j;
j++;
continue;
}
break;
}
if (mergeableGroups.length === 1) {
if (!isThinking && !isMergedToolGroup(msg)) {
result.push(msg);
i++;
continue;
}
const mergedTools = mergeableGroups.flatMap((g) =>
g.role === 'tool_group' ? g.tools : [],
// A run of thinking + adjacent tool groups aggregates into one summary,
// keeping the original interleaved order.
const run: Message[] = [];
let lastRunIdx = i - 1;
let j = i;
while (j < messages.length) {
const next = messages[j];
if (next.role === 'thinking' || isMergedToolGroup(next)) {
run.push(next);
lastRunIdx = j;
j++;
continue;
}
break;
}
const tools = run
.filter((m): m is DaemonToolGroupMessage => m.role === 'tool_group')
.flatMap((group) => group.tools);
const hasStreamingThought = run.some(
(m) => m.role === 'thinking' && m.isStreaming === true,
);
if (tools.length === 0 && !hasStreamingThought) {
// Completed thinking with no adjacent tools stays a standalone row.
for (const item of run) result.push(item);
i = lastRunIdx + 1;
continue;
}
// Each thought remembers the tool that follows it, so the group renders
// in the original order without the view reordering anything.
const thoughts: Array<{
content: string;
isStreaming?: boolean;
beforeToolCallId?: string;
}> = [];
const thoughtsAwaitingTool: Array<(typeof thoughts)[number]> = [];
for (const item of run) {
if (item.role === 'thinking') {
const thought = {
content: item.content,
...(item.isStreaming === true ? { isStreaming: true } : {}),
};
thoughts.push(thought);
thoughtsAwaitingTool.push(thought);
} else if (item.role === 'tool_group' && item.tools.length > 0) {
const firstToolCallId = item.tools[0]!.callId;
for (const thought of thoughtsAwaitingTool) {
thought.beforeToolCallId = firstToolCallId;
}
thoughtsAwaitingTool.length = 0;
}
}
result.push({
id: mergeableGroups[0].id,
// Synthetic id so the aggregated group never collides with an original
// message key: React then remounts instead of carrying the expanded
// summary state into non-compact mode.
id: `summary-${run[0]!.id}`,
role: 'tool_group',
tools: mergedTools,
timestamp: mergeableGroups[0].timestamp,
tools,
...(thoughts.length > 0 ? { thoughts } : {}),
timestamp: run[0]!.timestamp,
});
i = lastMergedIdx + 1;
i = lastRunIdx + 1;
}
return result;
@ -1714,6 +1737,13 @@ export function applyTurnCollapse(
toolCallCount += itemToolCallCount(item);
if (item.type === 'message' && item.message.role === 'thinking') {
thinkingCount++;
} else if (
item.type === 'message' &&
item.message.role === 'tool_group' &&
item.message.thoughts
) {
// Compact mode folds thinking into tool summaries; count it too.
thinkingCount += item.message.thoughts.length;
}
const terminalTimestamp = terminalTurnTimestamp(item);
if (terminalTimestamp !== undefined) {

View file

@ -136,13 +136,25 @@
.translateButton {
flex-shrink: 0;
margin-left: 7px;
color: var(--foreground);
padding: 0;
border: none;
border-radius: 0;
background: transparent;
color: var(--muted-foreground);
cursor: pointer;
font: inherit;
font-size: 12px;
line-height: inherit;
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease;
}
.translateButton:hover,
.translateButton:focus-visible {
color: var(--primary);
}
.thinkingHeader:hover .translateButton,
.thinkingHeaderExpanded .translateButton,
.translateButton:focus-visible,

View file

@ -55,7 +55,6 @@ function renderCompletedThinking(
const tree = (isStreaming: boolean) => (
<I18nProvider language={language}>
<ThinkingMessage
messageId={`completed-${durationMs}-${language}`}
content="private chain of thought"
isStreaming={isStreaming}
timestamp={0}
@ -95,11 +94,7 @@ describe('AssistantMessage thinking logic', () => {
it('keeps replayed completed thinking durationless', () => {
const container = render(
<ThinkingMessage
messageId="replayed"
content="private chain of thought"
timestamp={0}
/>,
<ThinkingMessage content="private chain of thought" timestamp={0} />,
);
expect(container.textContent).toContain('Done thinking');
@ -133,7 +128,6 @@ describe('AssistantMessage thinking logic', () => {
const container = render(
<ThinkingMessage
messageId="running"
content="private chain of thought"
isStreaming
timestamp={0}
@ -186,7 +180,6 @@ describe('AssistantMessage thinking logic', () => {
});
const container = render(
<ThinkingMessage
messageId="translated-thinking"
content="private chain of thought"
generateContent={generateContent}
/>,
@ -227,7 +220,6 @@ describe('AssistantMessage thinking logic', () => {
it('only offers translation when the UI language is Chinese', () => {
const container = render(
<ThinkingMessage
messageId="english-thinking"
content="private chain of thought"
generateContent={async function* () {}}
/>,
@ -253,8 +245,7 @@ describe('AssistantMessage thinking logic', () => {
};
const container = render(
<ThinkingMessage
messageId="empty-translation"
content="private chain of thought"
content="private chain of thought that fails"
generateContent={generateContent}
/>,
'zh-CN',
@ -292,8 +283,7 @@ describe('AssistantMessage thinking logic', () => {
});
const container = render(
<ThinkingMessage
messageId="cancel-translation"
content="private chain of thought"
content="private chain of thought to cancel"
generateContent={generateContent}
/>,
'zh-CN',
@ -350,8 +340,7 @@ describe('AssistantMessage thinking logic', () => {
};
const container = render(
<ThinkingMessage
messageId="thinking-translation"
content="private chain of thought"
content="private chain of thought with thinking status"
generateContent={generateContent}
/>,
'zh-CN',
@ -376,7 +365,6 @@ describe('AssistantMessage thinking logic', () => {
it('does not offer translation while thinking is streaming', () => {
const container = render(
<ThinkingMessage
messageId="still-running"
content="private chain of thought"
isStreaming
generateContent={async function* () {}}

View file

@ -1,14 +1,5 @@
import {
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Markdown } from './Markdown';
import { CompactModeContext } from '../../App';
import {
useWebShellCustomization,
type WebShellAssistantTurnFooterRenderInfo,
@ -185,7 +176,6 @@ function BranchIcon() {
}
interface ThinkingMessageProps {
messageId: string;
content: string;
isStreaming?: boolean;
timestamp?: number;
@ -221,7 +211,6 @@ function cacheThinkingTranslation(
}
export const ThinkingMessage = memo(function ThinkingMessage({
messageId,
content,
isStreaming,
timestamp,
@ -229,19 +218,12 @@ export const ThinkingMessage = memo(function ThinkingMessage({
generateContent,
}: ThinkingMessageProps) {
const { language, t } = useI18n();
const compactMode = useContext(CompactModeContext);
const [thinkingExpanded, setThinkingExpanded] = useState(false);
const thinkingActive = isStreaming === true;
const startTimeRef = useRef(timestamp ?? Date.now());
const sawActiveRef = useRef(thinkingActive);
const [now, setNow] = useState(() => Date.now());
const [finishedAt, setFinishedAt] = useState<number | null>(null);
const [translationOpen, setTranslationOpen] = useState(false);
const [translation, setTranslation] = useState<ThinkingTranslation>();
const [translationLoading, setTranslationLoading] = useState(false);
const [translationThinking, setTranslationThinking] = useState(false);
const [translationError, setTranslationError] = useState(false);
const translationAbortRef = useRef<AbortController | undefined>(undefined);
useEffect(() => {
if (!content || !thinkingActive) return;
@ -279,6 +261,106 @@ export const ThinkingMessage = memo(function ThinkingMessage({
setThinkingExpanded((v) => !v);
}, []);
return (
<div
className={`${styles.message}${
isLocateFlashing ? ` ${flashStyles.flash}` : ''
}`}
>
{content && (
<div className={styles.thinking}>
<div className={styles.thinkingBody}>
<div
className={`${styles.thinkingHeader}${
thinkingExpanded ? ` ${styles.thinkingHeaderExpanded}` : ''
}`}
onClick={(event) => {
if (event.currentTarget.contains(event.target as Node)) {
handleToggle();
}
}}
>
<button
type="button"
className={styles.thinkingSummary}
aria-expanded={thinkingExpanded}
title={
thinkingExpanded
? t('thinking.collapse')
: t('thinking.expand')
}
>
<span className={styles.thinkingSummaryIcon} aria-hidden="true">
<ThinkingDoneIcon />
</span>
<span
className={
thinkingActive
? `${styles.thinkingSummaryText} ${styles.thinkingSummaryTextActive}`
: styles.thinkingSummaryText
}
>
{t(
thinkingSummaryKey,
thinkingDuration ? { duration: thinkingDuration } : {},
)}
</span>
</button>
{language === 'zh-CN' && !thinkingActive && generateContent && (
<ThinkingTranslateButton
content={content}
generateContent={generateContent}
className={styles.translateButton}
/>
)}
<span
className={
thinkingExpanded
? styles.thinkingChevronDown
: styles.thinkingChevronRight
}
aria-hidden="true"
/>
</div>
{thinkingExpanded && (
<div className={styles.thinkingExpandedClip}>
<div className={styles.thinkingExpandedInner}>
<div className={styles.thinkingExpandedWrap}>
<Markdown
content={content}
source="thinking"
isStreaming={isStreaming}
/>
</div>
</div>
</div>
)}
</div>
</div>
)}
</div>
);
});
interface ThinkingTranslateButtonProps {
content: string;
generateContent?: SessionContentGenerator;
className?: string;
}
export function ThinkingTranslateButton({
content,
generateContent,
className,
}: ThinkingTranslateButtonProps) {
const { language, t } = useI18n();
const [translationOpen, setTranslationOpen] = useState(false);
const [translation, setTranslation] = useState<ThinkingTranslation>();
const [translationLoading, setTranslationLoading] = useState(false);
const [translationThinking, setTranslationThinking] = useState(false);
const [translationError, setTranslationError] = useState(false);
const translationAbortRef = useRef<AbortController | undefined>(undefined);
useEffect(
() => () => {
translationAbortRef.current?.abort();
@ -288,10 +370,8 @@ export const ThinkingMessage = memo(function ThinkingMessage({
const translate = useCallback(
async (force = false) => {
if (isStreaming || !generateContent || (translationLoading && !force)) {
return;
}
const cacheKey = `${language}:${messageId}:${content}`;
if (!generateContent || (translationLoading && !force)) return;
const cacheKey = `${language}:${content}`;
const cached = thinkingTranslationCache.get(cacheKey);
if (cached && !force) {
cacheThinkingTranslation(cacheKey, cached);
@ -348,14 +428,7 @@ export const ThinkingMessage = memo(function ThinkingMessage({
}
}
},
[
content,
generateContent,
isStreaming,
language,
messageId,
translationLoading,
],
[content, generateContent, language, translationLoading],
);
const handleTranslationOpenChange = useCallback(
@ -376,173 +449,91 @@ export const ThinkingMessage = memo(function ThinkingMessage({
}, []);
return (
<div
className={`${styles.message}${
isLocateFlashing ? ` ${flashStyles.flash}` : ''
}`}
>
{content && !compactMode && (
<div className={styles.thinking}>
<div className={styles.thinkingBody}>
<div
className={`${styles.thinkingHeader}${
thinkingExpanded ? ` ${styles.thinkingHeaderExpanded}` : ''
}`}
onClick={(event) => {
if (event.currentTarget.contains(event.target as Node)) {
handleToggle();
}
}}
>
<button
type="button"
className={styles.thinkingSummary}
aria-expanded={thinkingExpanded}
title={
thinkingExpanded
? t('thinking.collapse')
: t('thinking.expand')
}
>
<span className={styles.thinkingSummaryIcon} aria-hidden="true">
<ThinkingDoneIcon />
</span>
<span
className={
thinkingActive
? `${styles.thinkingSummaryText} ${styles.thinkingSummaryTextActive}`
: styles.thinkingSummaryText
}
>
{t(
thinkingSummaryKey,
thinkingDuration ? { duration: thinkingDuration } : {},
)}
</span>
</button>
{language === 'zh-CN' && !thinkingActive && generateContent && (
<Popover
open={translationOpen}
onOpenChange={handleTranslationOpenChange}
>
<PopoverTrigger asChild>
<Button
type="button"
variant="secondary"
size="xs"
className={styles.translateButton}
title={t('thinking.translate')}
onClick={(event) => event.stopPropagation()}
>
{t('thinking.translate')}
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className={styles.translationPopover}
>
<div className={styles.translationTitle}>
{t('thinking.translation')}
</div>
{translationError ? (
<div className={styles.translationError}>
{t('thinking.translationFailed')}
</div>
) : translation?.text ? (
<div
className={`${styles.thinkingExpandedWrap} ${styles.translationContent}`}
>
<Markdown
content={translation.text}
source="thinking"
isStreaming={translationLoading}
/>
</div>
) : (
<div className={styles.translationPending}>
{t(
translationThinking
? 'thinking.translationThinking'
: 'thinking.translating',
)}
</div>
)}
<div className={styles.translationFooter}>
<div className={styles.translationUsage}>
{!translationLoading && translation?.text && (
<>
<span>
{t('thinking.inputTokens', {
count: translation.inputTokens ?? '--',
})}
</span>
<span>
{t('thinking.outputTokens', {
count: translation.outputTokens ?? '--',
})}
</span>
</>
)}
</div>
<div className={styles.translationActions}>
<Button
type="button"
variant="outline"
size="xs"
onClick={() => void translate(true)}
>
{t('thinking.retranslate')}
</Button>
<Button
type="button"
variant="outline"
size="xs"
disabled={
!translationLoading &&
!translation?.text &&
!translationError
}
onClick={handleCancelOrCloseTranslation}
>
{t(
translationLoading
? 'thinking.cancelTranslation'
: 'thinking.closeTranslation',
)}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)}
<span
className={
thinkingExpanded
? styles.thinkingChevronDown
: styles.thinkingChevronRight
}
aria-hidden="true"
/>
</div>
{thinkingExpanded && (
<div className={styles.thinkingExpandedClip}>
<div className={styles.thinkingExpandedInner}>
<div className={styles.thinkingExpandedWrap}>
<Markdown
content={content}
source="thinking"
isStreaming={isStreaming}
/>
</div>
</div>
</div>
<Popover open={translationOpen} onOpenChange={handleTranslationOpenChange}>
<PopoverTrigger asChild>
<button
type="button"
className={className}
title={t('thinking.translate')}
onClick={(event) => event.stopPropagation()}
>
{t('thinking.translate')}
</button>
</PopoverTrigger>
<PopoverContent align="start" className={styles.translationPopover}>
<div className={styles.translationTitle}>
{t('thinking.translation')}
</div>
{translationError ? (
<div className={styles.translationError}>
{t('thinking.translationFailed')}
</div>
) : translation?.text ? (
<div
className={`${styles.thinkingExpandedWrap} ${styles.translationContent}`}
>
<Markdown
content={translation.text}
source="thinking"
isStreaming={translationLoading}
/>
</div>
) : (
<div className={styles.translationPending}>
{t(
translationThinking
? 'thinking.translationThinking'
: 'thinking.translating',
)}
</div>
)}
<div className={styles.translationFooter}>
<div className={styles.translationUsage}>
{!translationLoading && translation?.text && (
<>
<span>
{t('thinking.inputTokens', {
count: translation.inputTokens ?? '--',
})}
</span>
<span>
{t('thinking.outputTokens', {
count: translation.outputTokens ?? '--',
})}
</span>
</>
)}
</div>
<div className={styles.translationActions}>
<Button
type="button"
variant="outline"
size="xs"
onClick={() => void translate(true)}
>
{t('thinking.retranslate')}
</Button>
<Button
type="button"
variant="outline"
size="xs"
disabled={
!translationLoading && !translation?.text && !translationError
}
onClick={handleCancelOrCloseTranslation}
>
{t(
translationLoading
? 'thinking.cancelTranslation'
: 'thinking.closeTranslation',
)}
</Button>
</div>
</div>
)}
</div>
</PopoverContent>
</Popover>
);
});
}
export function getThinkingSummaryKey({
isStreaming,
@ -565,7 +556,7 @@ export function formatThinkingDuration(ms: number): string {
return sec > 0 ? `${min}m ${sec}s` : `${min}m`;
}
function ThinkingDoneIcon() {
export function ThinkingDoneIcon() {
return (
<svg
width="18"

View file

@ -81,6 +81,11 @@ function renderToolLine(
function renderToolGroup(
tools: ACPToolCall[],
customization = {},
thoughts?: Array<{
content: string;
isStreaming?: boolean;
beforeToolCallId?: string;
}>,
): HTMLElement {
const container = document.createElement('div');
document.body.appendChild(container);
@ -89,7 +94,7 @@ function renderToolGroup(
root.render(
<I18nProvider language="en">
<WebShellCustomizationProvider value={customization}>
<ToolGroup tools={tools} />
<ToolGroup tools={tools} thoughts={thoughts} />
</WebShellCustomizationProvider>
</I18nProvider>,
);
@ -1519,6 +1524,115 @@ describe('tool row rendering', () => {
});
});
describe('thinking rows in the compact summary', () => {
it('shows a running summary while a thought is streaming', () => {
const container = renderToolGroup(
[
makeTool({
callId: 'tool-1',
toolName: 'ReadFile',
status: 'completed',
}),
],
{},
[{ content: 'thinking about it', isStreaming: true }],
);
expect(container.querySelector('button')?.textContent).toContain(
'Thinking',
);
});
it('renders a completed thought line that expands its content on click', () => {
const container = renderToolGroup(
[
makeTool({
callId: 'tool-1',
toolName: 'ReadFile',
status: 'completed',
}),
],
{},
[{ content: 'private chain of thought' }],
);
act(() => {
container.querySelector('button')?.click();
});
const thoughtHeader = Array.from(
container.querySelectorAll('[role="button"]'),
).find((el) =>
(el as HTMLElement).textContent?.includes('Done thinking'),
) as HTMLElement;
expect(thoughtHeader).toBeTruthy();
// Collapsed by default; content appears on click.
expect(container.textContent).not.toContain('private chain of thought');
act(() => thoughtHeader.click());
expect(container.textContent).toContain('private chain of thought');
});
it('keeps the single tool compact when thinking is folded in', () => {
const container = renderToolGroup(
[
makeTool({
callId: 'tool-1',
toolName: 'ReadFile',
status: 'completed',
content: [
{
type: 'content',
content: { type: 'text', text: 'DUMPED CONTENT' },
},
],
}),
],
{},
[{ content: 'thinking' }],
);
act(() => {
container.querySelector('button')?.click();
});
// The single tool renders as a compact line, not a force-expanded dump.
expect(container.textContent).not.toContain('DUMPED CONTENT');
});
it('renders thoughts interleaved with their tools in original order', () => {
const container = renderToolGroup(
[
makeTool({
callId: 'tool-1',
toolName: 'ReadFile',
status: 'completed',
}),
makeTool({ callId: 'tool-2', toolName: 'Glob', status: 'completed' }),
],
{},
[
{ content: 'first thought', beforeToolCallId: 'tool-1' },
{ content: 'second thought', beforeToolCallId: 'tool-2' },
],
);
act(() => {
container.querySelector('button')?.click();
for (const header of container.querySelectorAll('[role="button"]')) {
(header as HTMLElement).click();
}
});
const text = container.textContent ?? '';
const positions = [
'first thought',
'ReadFile',
'second thought',
'Glob',
].map((marker) => text.indexOf(marker));
expect(
positions.every((v, i) => v >= 0 && (i === 0 || v > positions[i - 1]!)),
).toBe(true);
});
});
describe('tool output logic', () => {
it('sanitizes read-file languages before building markdown fences', () => {
expect(languageForPath('src/App.tsx')).toBe('tsx');

View file

@ -1,4 +1,5 @@
import {
Fragment,
memo,
useContext,
useEffect,
@ -12,6 +13,7 @@ import type {
PermissionRequest,
TodoItem,
} from '../../adapters/types';
import type { SessionContentGenerator } from './AssistantMessage';
import {
hasActiveAgents,
isBackgroundSubAgentToolCall,
@ -32,6 +34,7 @@ import { useSubagentDetails } from '../../subagentDetailsContext';
import { useMonitorDetails } from '../../monitorDetailsContext';
import { TodoEventSummary, TodoFullList } from './TodoView';
import { Markdown } from './Markdown';
import { ThinkingDoneIcon, ThinkingTranslateButton } from './AssistantMessage';
import {
formatDurationMs,
formatElapsed,
@ -71,9 +74,21 @@ import styles from './tools/ToolChrome.module.css';
interface ToolGroupProps {
tools: ACPToolCall[];
/**
* Thinking aggregated with the tools in this summary (compact mode), in
* the original order. Streaming entries drive the "Thinking…" summary;
* each entry renders as a click-to-expand row.
*/
thoughts?: Array<{
content: string;
isStreaming?: boolean;
beforeToolCallId?: string;
}>;
pendingApproval?: PermissionRequest | null;
workspaceCwd?: string;
isLocateFlashing?: boolean;
/** Powers the translate action on completed thinking rows (zh-CN). */
generateContent?: SessionContentGenerator;
}
function openMonitorDetailsOnce(
@ -1490,11 +1505,78 @@ export const ToolLine = memo(function ToolLine({
);
}, areToolLinePropsEqual);
function ThoughtLine({
content,
isStreaming,
generateContent,
}: {
content: string;
isStreaming?: boolean;
generateContent?: SessionContentGenerator;
}) {
const { language, t } = useI18n();
const [expanded, setExpanded] = useState(false);
return (
<div className={styles.chatSummaryThought}>
<div
className={`${styles.chatSummaryThoughtHeader}${
expanded ? ` ${styles.chatSummaryThoughtHeaderExpanded}` : ''
}`}
role="button"
tabIndex={0}
aria-expanded={expanded}
onClick={() => setExpanded((value) => !value)}
onKeyDown={(event) => {
// Only the container itself toggles; keys pressed inside nested
// controls (the translate button) keep their own behavior.
if (event.target !== event.currentTarget) return;
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
setExpanded((value) => !value);
}}
>
<span className={styles.chatSummaryThoughtIcon} aria-hidden="true">
<ThinkingDoneIcon />
</span>
<span
className={`${styles.chatSummaryThoughtLabel}${
isStreaming ? ` ${styles.chatSummaryThoughtLabelActive}` : ''
}`}
>
{t(isStreaming ? 'thinking.running' : 'thinking.done')}
</span>
{language === 'zh-CN' && !isStreaming && generateContent && (
<ThinkingTranslateButton
content={content}
generateContent={generateContent}
className={styles.chatSummaryThoughtTranslate}
/>
)}
<span
className={
expanded
? styles.chatSummaryThoughtChevronDown
: styles.chatSummaryThoughtChevronRight
}
aria-hidden="true"
/>
</div>
{expanded && (
<div className={styles.chatSummaryThoughtContent}>
<Markdown content={content} source="thinking" />
</div>
)}
</div>
);
}
export const ToolGroup = memo(function ToolGroup({
tools,
thoughts,
pendingApproval,
workspaceCwd,
isLocateFlashing = false,
generateContent,
}: ToolGroupProps) {
const { t } = useI18n();
const subagentDetails = useSubagentDetails();
@ -1510,7 +1592,11 @@ export const ToolGroup = memo(function ToolGroup({
(tool) =>
isActiveToolStatus(tool.status) && !isBackgroundSubAgentToolCall(tool),
) ?? (tools.length > 0 ? getActiveTool(tools) : undefined);
// Single-tool identity stays available for the compact summary and the
// subagent/monitor drawer shortcut even when thoughts are folded in; only
// the force-expanded content dump is suppressed for thought groups.
const singleTool = tools.length === 1 ? tools[0] : undefined;
const compactToolLines = !!thoughts?.length;
const singleSubagent =
singleTool && isSubAgentToolCall(singleTool) ? singleTool : undefined;
const singleMonitor =
@ -1521,7 +1607,11 @@ export const ToolGroup = memo(function ToolGroup({
(tool) =>
isActiveToolStatus(tool.status) && !isBackgroundSubAgentToolCall(tool),
);
const animateSummary = hasRunningTool && hasForegroundActiveTool;
const streamingThought = thoughts?.find((thought) => thought.isStreaming);
const animateSummary =
hasRunningTool && hasForegroundActiveTool
? true
: streamingThought !== undefined;
const opensSubagentDetails = Boolean(singleSubagent && subagentDetails);
const opensMonitorDetails = Boolean(
singleMonitor && monitorDetailsAvailable && !monitorDetailsUnavailable,
@ -1576,7 +1666,9 @@ export const ToolGroup = memo(function ToolGroup({
}
>
<span className={styles.chatSummaryIcon} aria-hidden="true">
{summaryIconTool ? (
{streamingThought ? (
<ThinkingDoneIcon />
) : summaryIconTool ? (
<ToolSummaryIcon tool={summaryIconTool} />
) : (
<ToolGroupIcon />
@ -1589,7 +1681,9 @@ export const ToolGroup = memo(function ToolGroup({
: styles.chatSummaryText
}
>
{singleTool ? (
{streamingThought ? (
t('thinking.running')
) : singleTool ? (
<SingleToolSummary
tool={singleTool}
workspaceCwd={workspaceCwd}
@ -1615,16 +1709,39 @@ export const ToolGroup = memo(function ToolGroup({
<div className={styles.chatSummaryContentInner}>
<div className={`${styles.group} ${styles.chatSummaryGroup}`}>
{tools.map((tool) => (
<ToolLine
key={tool.callId}
tool={tool}
approval={pendingApproval}
workspaceCwd={workspaceCwd}
summaryOnly={!singleTool}
forceExpanded={!!singleTool}
hideHeader={!!singleTool}
/>
<Fragment key={tool.callId}>
{thoughts
?.filter(
(thought) => thought.beforeToolCallId === tool.callId,
)
.map((thought, index) => (
<ThoughtLine
key={`thought-${tool.callId}-${index}`}
content={thought.content}
isStreaming={thought.isStreaming}
generateContent={generateContent}
/>
))}
<ToolLine
tool={tool}
approval={pendingApproval}
workspaceCwd={workspaceCwd}
summaryOnly={!singleTool || compactToolLines}
forceExpanded={!!singleTool && !compactToolLines}
hideHeader={!!singleTool && !compactToolLines}
/>
</Fragment>
))}
{thoughts
?.filter((thought) => thought.beforeToolCallId === undefined)
.map((thought, index) => (
<ThoughtLine
key={`thought-trailing-${index}`}
content={thought.content}
isStreaming={thought.isStreaming}
generateContent={generateContent}
/>
))}
</div>
</div>
</div>

View file

@ -19,6 +19,194 @@
gap: 2px;
}
/* Completed thinking folded into the merged tool summary: a clickable line
like a tool row, expanding to the thought content on click. */
.chatSummaryThought {
padding: 2px 0;
min-width: 0;
}
.chatSummaryThoughtHeader {
display: flex;
align-items: center;
gap: 7px;
margin: 0;
padding: 0;
border: none;
border-radius: 0;
background: transparent;
color: var(--muted-foreground);
cursor: pointer;
text-align: left;
font: inherit;
}
.chatSummaryThoughtHeader:hover,
.chatSummaryThoughtHeader:focus-visible {
color: var(--primary);
}
.chatSummaryThoughtHeader:focus-visible {
outline: 2px solid color-mix(in srgb, var(--primary) 45%, transparent);
outline-offset: 2px;
border-radius: 4px;
}
.chatSummaryThoughtIcon {
width: 14px;
height: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
line-height: 1;
}
.chatSummaryThoughtIcon svg {
display: block;
}
/* The chevron is hover-only and hugs the content, like the thinking block. */
.chatSummaryThoughtChevronRight,
.chatSummaryThoughtChevronDown {
margin-left: 7px;
width: 14px;
height: 14px;
position: relative;
flex-shrink: 0;
opacity: 0;
transition: opacity 120ms ease;
}
.chatSummaryThoughtHeader:hover .chatSummaryThoughtChevronRight,
.chatSummaryThoughtHeader:hover .chatSummaryThoughtChevronDown,
.chatSummaryThoughtHeader:focus-within .chatSummaryThoughtChevronRight,
.chatSummaryThoughtHeader:focus-within .chatSummaryThoughtChevronDown {
opacity: 1;
}
.chatSummaryThoughtLabelActive {
background-image: linear-gradient(
105deg,
var(--muted-foreground) 0%,
var(--muted-foreground) 46%,
rgba(255, 255, 255, 0.92) 50%,
var(--muted-foreground) 54%,
var(--muted-foreground) 100%
);
background-size: 300% 100%;
background-position: 150% 0;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: chat-summary-thinking-shine 5s linear infinite;
}
@keyframes chat-summary-thinking-shine {
0% {
background-position: 150% 0;
}
100% {
background-position: -150% 0;
}
}
.chatSummaryThoughtLabel {
font-size: 13px;
line-height: 1.5;
}
.chatSummaryThoughtTranslate {
flex-shrink: 0;
margin-left: 7px;
padding: 0;
border: none;
border-radius: 0;
background: transparent;
color: var(--muted-foreground);
cursor: pointer;
font: inherit;
font-size: 12px;
line-height: inherit;
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease;
}
.chatSummaryThoughtTranslate:hover,
.chatSummaryThoughtTranslate:focus-visible {
color: var(--primary);
}
.chatSummaryThoughtHeader:hover .chatSummaryThoughtTranslate,
.chatSummaryThoughtHeaderExpanded .chatSummaryThoughtTranslate,
.chatSummaryThoughtTranslate:focus-visible,
.chatSummaryThoughtTranslate[data-state='open'] {
opacity: 1;
pointer-events: auto;
}
/* The expanded-state chevron stays visible, like the thinking block. */
.chatSummaryThoughtChevronDown {
opacity: 1;
}
.chatSummaryThoughtChevronRight::before,
.chatSummaryThoughtChevronRight::after,
.chatSummaryThoughtChevronDown::before,
.chatSummaryThoughtChevronDown::after {
content: '';
position: absolute;
width: 6px;
height: 1px;
background: currentColor;
border-radius: 1px;
}
.chatSummaryThoughtChevronRight::before {
top: calc(50% - 2px);
left: calc(50% - 3px);
transform: rotate(45deg);
}
.chatSummaryThoughtChevronRight::after {
top: calc(50% + 2px);
left: calc(50% - 3px);
transform: rotate(-45deg);
}
.chatSummaryThoughtChevronDown::before {
top: 50%;
left: calc(50% - 5px);
transform: rotate(45deg);
}
.chatSummaryThoughtChevronDown::after {
top: 50%;
left: calc(50% - 1px);
transform: rotate(-45deg);
}
.chatSummaryThoughtContent {
margin-top: 4px;
padding-left: 21px;
color: var(--muted-foreground);
font-size: 13px;
line-height: 1.5;
opacity: 0.9;
}
.chatSummaryThoughtContent p,
.chatSummaryThoughtContent ul,
.chatSummaryThoughtContent ol {
margin-top: 0;
margin-bottom: 4px;
}
.chatSummaryThoughtContent :last-child {
margin-bottom: 0;
}
.line {
padding: 2px 0;
min-width: 0;

View file

@ -0,0 +1,103 @@
import { expect, test, type Page, type TestInfo } from '@playwright/test';
import {
assistantTextEvent,
createWebShellDaemonScenario,
installMockDaemon,
replayCompleteEvent,
type DaemonEvent,
type MockDaemonController,
type WebShellDaemonScenario,
} from './utils/mockDaemon';
function thoughtTextEvent(text: string): DaemonEvent {
return {
v: 1,
type: 'session_update',
data: {
update: {
sessionUpdate: 'agent_thought_chunk',
content: { type: 'text', text },
},
},
};
}
test('compact mode keeps the thinking block visible while streaming', async ({
page,
}, testInfo) => {
const scenario = createWebShellDaemonScenario({
settings: {
settings: [
{
key: 'ui.compactMode',
type: 'boolean',
label: 'Compact Mode',
category: 'UI',
requiresRestart: false,
default: false,
values: { effective: true, workspace: true, user: false },
},
],
},
});
const daemon = await installScenario(page, scenario, testInfo);
await gotoSession(page, scenario, daemon);
await fillComposer(page, 'Ping from tmp compact test');
await page.locator('[data-web-shell-composer-submit]').click();
await expect.poll(() => daemon.promptRequests().length).toBe(1);
// The thinking phase shows the thinking block as its own row; the thought
// content stays collapsed and the top collapse row keeps its generic live
// label.
await daemon.sse.split(
thoughtTextEvent('private chain of thought about the weather'),
);
const list = page.locator('[data-web-shell-message-list]');
await expect(list).toContainText('Thinking', { timeout: 5000 });
await expect(list).toContainText('Processing');
await expect(list).not.toContainText('private chain of thought');
// Thinking ends once the assistant starts answering: the block's live label
// flips to its completed summary while the collapse row stays.
await daemon.sse.split(assistantTextEvent('the weather is rainy'));
await expect(list).not.toContainText('Thinking', { timeout: 5000 });
await expect(list).toContainText('Processing');
});
async function installScenario(
page: Page,
scenario: WebShellDaemonScenario,
testInfo: TestInfo,
): Promise<MockDaemonController> {
return installMockDaemon(page, scenario, {
baseURL: String(testInfo.project.use.baseURL),
});
}
async function gotoSession(
page: Page,
scenario: WebShellDaemonScenario,
daemon: MockDaemonController,
): Promise<void> {
await page.goto(`/session/${encodeURIComponent(scenario.sessionId)}`);
await expect(page.locator('[data-web-shell-root]')).toBeVisible();
const connection = await daemon.sse.waitForConnection(scenario.sessionId);
await daemon.sendEvent(
replayCompleteEvent({
sessionId: connection.sessionId,
replayedCount: scenario.events.length,
}),
);
await expect(page.getByText('Loading...')).toHaveCount(0);
}
async function fillComposer(page: Page, text: string): Promise<void> {
const editor = page.locator('[data-web-shell-composer-editor] .cm-content');
await editor.click();
await page.keyboard.press(
process.platform === 'darwin' ? 'Meta+A' : 'Control+A',
);
await page.keyboard.type(text);
}