mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-21 14:46:19 +00:00
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
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:
parent
4ade537a10
commit
a669957f3d
11 changed files with 932 additions and 285 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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* () {}}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
103
packages/web-shell/client/e2e/web-shell.compact-thinking.spec.ts
Normal file
103
packages/web-shell/client/e2e/web-shell.compact-thinking.spec.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue