mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-07 15:55:50 +00:00
* perf(web-shell): optimize long session rendering * test(web-shell): align long-session expectations * fix(web-shell): address long-session review feedback * test(web-shell): cover bounded refresh metadata and auto-pagination status suppression * fix(web-shell): harden long-session refresh fallback and turn animation (#7408) --------- Co-authored-by: ytahdn <ytahdn@gmail.com> Co-authored-by: Qwen Autofix <autofix@qwen-code.dev>
463 lines
15 KiB
TypeScript
463 lines
15 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { act, type ReactNode } from 'react';
|
|
import { createRoot, type Root } from 'react-dom/client';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import type { DaemonTranscriptBlock } from '@qwen-code/sdk/daemon';
|
|
import { WebShellTranscript } from './WebShellTranscript';
|
|
|
|
const mounted: Array<{ root: Root; container: HTMLElement }> = [];
|
|
|
|
function block(
|
|
value: Omit<
|
|
DaemonTranscriptBlock,
|
|
'clientReceivedAt' | 'createdAt' | 'updatedAt'
|
|
>,
|
|
timestamp = 1,
|
|
): DaemonTranscriptBlock {
|
|
return {
|
|
...value,
|
|
clientReceivedAt: timestamp,
|
|
createdAt: timestamp,
|
|
updatedAt: timestamp,
|
|
} as DaemonTranscriptBlock;
|
|
}
|
|
|
|
function render(node: ReactNode): {
|
|
container: HTMLElement;
|
|
unmount: () => void;
|
|
} {
|
|
const container = document.createElement('div');
|
|
container.style.height = '640px';
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
act(() => root.render(node));
|
|
const entry = { root, container };
|
|
mounted.push(entry);
|
|
return {
|
|
container,
|
|
unmount() {
|
|
const index = mounted.indexOf(entry);
|
|
if (index >= 0) mounted.splice(index, 1);
|
|
act(() => root.unmount());
|
|
container.remove();
|
|
},
|
|
};
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const { root, container } of mounted.splice(0)) {
|
|
act(() => root.unmount());
|
|
container.remove();
|
|
}
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('WebShellTranscript DOM integration', () => {
|
|
it('renders representative transcript blocks without daemon providers', () => {
|
|
const blocks: DaemonTranscriptBlock[] = [
|
|
block({ id: 'u1', kind: 'user', text: 'Inspect the project' }, 1),
|
|
block({ id: 't1', kind: 'thought', text: 'Thinking through it' }, 2),
|
|
block(
|
|
{
|
|
id: 'tool1',
|
|
kind: 'tool',
|
|
toolCallId: 'call1',
|
|
title: 'Read package file',
|
|
toolName: 'read_file',
|
|
status: 'completed',
|
|
preview: { kind: 'file_read', path: 'package.json' },
|
|
rawOutput: 'package contents',
|
|
},
|
|
3,
|
|
),
|
|
block(
|
|
{
|
|
id: 'agent1',
|
|
kind: 'tool',
|
|
toolCallId: 'agent-call-1',
|
|
title: 'Explore the codebase',
|
|
toolName: 'Task',
|
|
status: 'completed',
|
|
preview: { kind: 'generic' },
|
|
rawInput: { subagent_type: 'Explore', prompt: 'Find the entrypoint' },
|
|
rawOutput: 'Found the entrypoint',
|
|
},
|
|
4,
|
|
),
|
|
block({ id: 'a1', kind: 'assistant', text: '**Finished** reading.' }, 5),
|
|
block(
|
|
{
|
|
id: 'p1',
|
|
kind: 'status',
|
|
text: `plan: ${JSON.stringify({
|
|
sessionUpdate: 'plan',
|
|
entries: [{ content: 'Verify the design', status: 'in_progress' }],
|
|
})}`,
|
|
},
|
|
6,
|
|
),
|
|
block({ id: 's1', kind: 'status', text: 'Historical status' }, 7),
|
|
block({ id: 'e1', kind: 'error', text: 'Historical error' }, 8),
|
|
block({ id: 'c1', kind: 'prompt_cancelled' }, 9),
|
|
];
|
|
const { container } = render(
|
|
<WebShellTranscript
|
|
blocks={blocks}
|
|
collapseCompletedTurns={false}
|
|
language="en"
|
|
/>,
|
|
);
|
|
|
|
expect(container.textContent).toContain('Inspect the project');
|
|
expect(container.textContent).not.toContain('Thinking through it');
|
|
const thinkingToggle = container.querySelector<HTMLButtonElement>(
|
|
'button[title="Expand thinking"]',
|
|
);
|
|
expect(thinkingToggle).not.toBeNull();
|
|
act(() => thinkingToggle?.click());
|
|
expect(container.textContent).toContain('Thinking through it');
|
|
expect(container.textContent).toContain('Read package file');
|
|
expect(container.textContent).toContain('Explore the codebase');
|
|
expect(container.textContent).toContain('Finished');
|
|
expect(container.textContent).toContain('Verify the design');
|
|
expect(container.textContent).toContain('Historical status');
|
|
expect(container.textContent).toContain('Historical error');
|
|
expect(container.textContent).toContain('You cancelled this request');
|
|
});
|
|
|
|
it('derives task detail from todo_write transcript snapshots', () => {
|
|
const todoSnapshot = (
|
|
id: string,
|
|
status: 'in_progress' | 'completed',
|
|
timestamp: number,
|
|
stats: {
|
|
promptTokens: number;
|
|
cachedTokens: number;
|
|
candidateTokens: number;
|
|
apiTimeMs: number;
|
|
},
|
|
) =>
|
|
block(
|
|
{
|
|
id,
|
|
kind: 'tool',
|
|
toolCallId: `${id}-call`,
|
|
title: 'Updated Plan',
|
|
toolName: 'todo_write',
|
|
toolKind: 'updated_plan',
|
|
status: 'completed',
|
|
preview: { kind: 'generic' },
|
|
rawOutput: {
|
|
entries: [
|
|
{
|
|
id: 'task-1',
|
|
content: 'Prepare release',
|
|
status,
|
|
},
|
|
],
|
|
stats,
|
|
},
|
|
},
|
|
timestamp,
|
|
);
|
|
const { container } = render(
|
|
<WebShellTranscript
|
|
blocks={[
|
|
todoSnapshot('todo-start', 'in_progress', 1000, {
|
|
promptTokens: 100,
|
|
cachedTokens: 10,
|
|
candidateTokens: 20,
|
|
apiTimeMs: 500,
|
|
}),
|
|
todoSnapshot('todo-done', 'completed', 5000, {
|
|
promptTokens: 300,
|
|
cachedTokens: 40,
|
|
candidateTokens: 80,
|
|
apiTimeMs: 1500,
|
|
}),
|
|
]}
|
|
collapseCompletedTurns={false}
|
|
language="en"
|
|
/>,
|
|
);
|
|
|
|
const summaries = Array.from(
|
|
container.querySelectorAll<HTMLButtonElement>('button'),
|
|
).filter((button) => button.textContent?.includes('Updated task list'));
|
|
expect(summaries).toHaveLength(2);
|
|
act(() => {
|
|
summaries[1].click();
|
|
});
|
|
const completedSnapshot = summaries[1].parentElement;
|
|
const detailButton = completedSnapshot?.querySelector<HTMLButtonElement>(
|
|
'button[title="Show task detail"]',
|
|
);
|
|
expect(detailButton).not.toBeNull();
|
|
|
|
act(() => {
|
|
detailButton?.click();
|
|
});
|
|
const detailText = completedSnapshot?.textContent ?? '';
|
|
expect(detailText).toContain('Tokens');
|
|
expect(detailText).toContain('200');
|
|
expect(detailText).toContain('60');
|
|
expect(detailText).toContain('30');
|
|
expect(detailText).toContain('Time spent');
|
|
expect(detailText).toContain('1.0s');
|
|
expect(detailText).toContain('4.0s');
|
|
});
|
|
|
|
it('derives the transition introduced by each plan snapshot', () => {
|
|
const planSnapshot = (
|
|
id: string,
|
|
firstStatus: 'in_progress' | 'completed',
|
|
secondStatus: 'pending' | 'in_progress',
|
|
timestamp: number,
|
|
) =>
|
|
block(
|
|
{
|
|
id,
|
|
kind: 'status',
|
|
text: `plan: ${JSON.stringify({
|
|
sessionUpdate: 'plan',
|
|
entries: [
|
|
{
|
|
id: 'task-1',
|
|
content: 'Prepare release',
|
|
status: firstStatus,
|
|
},
|
|
{
|
|
id: 'task-2',
|
|
content: 'Run verification',
|
|
status: secondStatus,
|
|
},
|
|
],
|
|
})}`,
|
|
},
|
|
timestamp,
|
|
);
|
|
const { container } = render(
|
|
<WebShellTranscript
|
|
blocks={[
|
|
planSnapshot('plan-start', 'in_progress', 'pending', 1000),
|
|
planSnapshot('plan-next', 'completed', 'in_progress', 5000),
|
|
]}
|
|
collapseCompletedTurns={false}
|
|
language="en"
|
|
/>,
|
|
);
|
|
|
|
const planHeaders = Array.from(
|
|
container.querySelectorAll<HTMLButtonElement>('button'),
|
|
).filter((button) => button.textContent?.includes('Plan'));
|
|
expect(planHeaders).toHaveLength(2);
|
|
const secondSnapshotText = planHeaders[1].parentElement?.textContent ?? '';
|
|
expect(secondSnapshotText).toContain('Prepare release');
|
|
expect(secondSnapshotText).toContain('Run verification');
|
|
});
|
|
|
|
it('omits pending permissions and AskUserQuestion controls', () => {
|
|
const blocks: DaemonTranscriptBlock[] = [
|
|
block({ id: 'u1', kind: 'user', text: 'Before permission' }),
|
|
block({
|
|
id: 'permission',
|
|
kind: 'permission',
|
|
requestId: 'request-1',
|
|
title: 'Choose a deployment target',
|
|
options: [
|
|
{ optionId: 'staging', label: 'Staging', raw: {} },
|
|
{ optionId: 'production', label: 'Production', raw: {} },
|
|
],
|
|
toolCall: { toolCallId: 'ask-1', kind: 'think' },
|
|
preview: {
|
|
kind: 'ask_user_question',
|
|
questions: [
|
|
{
|
|
header: 'Target',
|
|
question: 'Where should this deploy?',
|
|
options: [
|
|
{ label: 'Staging', raw: {} },
|
|
{ label: 'Production', raw: {} },
|
|
],
|
|
raw: {},
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
];
|
|
const { container } = render(<WebShellTranscript blocks={blocks} />);
|
|
|
|
expect(container.textContent).toContain('Before permission');
|
|
expect(container.textContent).not.toContain('Where should this deploy?');
|
|
expect(container.textContent).not.toContain('Production');
|
|
expect(container.querySelector('button[type="submit"]')).toBeNull();
|
|
});
|
|
|
|
it('renders the historical result when AskUserQuestion has a tool block', () => {
|
|
const blocks: DaemonTranscriptBlock[] = [
|
|
block({
|
|
id: 'permission',
|
|
kind: 'permission',
|
|
requestId: 'request-1',
|
|
title: 'Ask user 1 question',
|
|
options: [{ optionId: 'submit', label: 'Submit', raw: {} }],
|
|
toolCall: {
|
|
toolCallId: 'ask-1',
|
|
kind: 'think',
|
|
rawInput: {
|
|
questions: [
|
|
{
|
|
header: 'Target',
|
|
question: 'Where should this deploy?',
|
|
options: [{ label: 'Staging' }, { label: 'Production' }],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
preview: { kind: 'generic' },
|
|
resolved: 'selected:submit',
|
|
}),
|
|
block(
|
|
{
|
|
id: 'ask-result',
|
|
kind: 'tool',
|
|
toolCallId: 'ask-1',
|
|
title: 'ask_user_question',
|
|
toolName: 'ask_user_question',
|
|
status: 'completed',
|
|
preview: { kind: 'generic' },
|
|
rawOutput: 'User answer: Staging',
|
|
},
|
|
2,
|
|
),
|
|
];
|
|
const { container } = render(
|
|
<WebShellTranscript blocks={blocks} collapseCompletedTurns={false} />,
|
|
);
|
|
|
|
expect(container.textContent).toContain('Ask user 1 question');
|
|
expect(container.textContent).toContain('User answer: Staging');
|
|
expect(container.querySelector('button[type="submit"]')).toBeNull();
|
|
});
|
|
|
|
it('keeps MessageList turn expansion as a local viewing interaction', () => {
|
|
const { container } = render(
|
|
<WebShellTranscript
|
|
blocks={[
|
|
block({ id: 'u1', kind: 'user', text: 'Collapsed turn' }, 1),
|
|
block({ id: 't1', kind: 'thought', text: 'Hidden reasoning' }, 2),
|
|
block({ id: 'a1', kind: 'assistant', text: 'Final answer' }, 3),
|
|
]}
|
|
/>,
|
|
);
|
|
const toggle = container.querySelector('[data-testid="toggle-u1"]');
|
|
const row = toggle?.closest('[role="button"]');
|
|
expect(row?.getAttribute('aria-expanded')).toBe('false');
|
|
expect(container.textContent).not.toContain('Hidden reasoning');
|
|
|
|
act(() => {
|
|
row?.dispatchEvent(
|
|
new MouseEvent('click', { bubbles: true, cancelable: true }),
|
|
);
|
|
});
|
|
expect(row?.getAttribute('aria-expanded')).toBe('true');
|
|
expect(container.textContent).not.toContain('Hidden reasoning');
|
|
const thinkingToggle = container.querySelector<HTMLButtonElement>(
|
|
'button[title="Expand thinking"]',
|
|
);
|
|
expect(thinkingToggle).not.toBeNull();
|
|
act(() => thinkingToggle?.click());
|
|
expect(container.textContent).toContain('Hidden reasoning');
|
|
});
|
|
|
|
it('suppresses session and goal events while preserving their text', () => {
|
|
const sessionEvents: unknown[] = [];
|
|
const goalEvents: unknown[] = [];
|
|
const onSession = (event: Event) =>
|
|
sessionEvents.push((event as CustomEvent).detail);
|
|
const onGoal = (event: Event) =>
|
|
goalEvents.push((event as CustomEvent).detail);
|
|
window.addEventListener('qwen:open-session', onSession);
|
|
window.addEventListener('web-shell-goal-status-active', onGoal);
|
|
const { container } = render(
|
|
<WebShellTranscript
|
|
blocks={[
|
|
block({
|
|
id: 'assistant',
|
|
kind: 'assistant',
|
|
text: '[Open child](qwen-session://child-session)',
|
|
}),
|
|
block(
|
|
{
|
|
id: 'goal',
|
|
kind: 'status',
|
|
text: '',
|
|
source: 'goal',
|
|
data: {
|
|
kind: 'set',
|
|
condition: 'All checks pass',
|
|
setAt: 1,
|
|
},
|
|
},
|
|
2,
|
|
),
|
|
]}
|
|
collapseCompletedTurns={false}
|
|
/>,
|
|
);
|
|
|
|
const sessionText = Array.from(container.querySelectorAll('span')).find(
|
|
(element) => element.textContent === 'Open child',
|
|
);
|
|
expect(sessionText).not.toBeUndefined();
|
|
expect(container.querySelector('a[role="button"]')).toBeNull();
|
|
expect(container.textContent).toContain('All checks pass');
|
|
expect(sessionEvents).toEqual([]);
|
|
expect(goalEvents).toEqual([]);
|
|
window.removeEventListener('qwen:open-session', onSession);
|
|
window.removeEventListener('web-shell-goal-status-active', onGoal);
|
|
});
|
|
|
|
it('mounts a themed scoped portal root and removes it on unmount', () => {
|
|
const { container, unmount } = render(
|
|
<WebShellTranscript blocks={[]} theme="light" language="zh-CN" />,
|
|
);
|
|
const root = container.querySelector<HTMLElement>('[data-web-shell-root]');
|
|
const portal = document.body.querySelector<HTMLElement>(
|
|
'[data-web-shell-portal-root]',
|
|
);
|
|
expect(root?.lang).toBe('zh-CN');
|
|
expect(root?.classList.contains('dark')).toBe(false);
|
|
expect(portal?.dataset.webShellShadcn).toBe('');
|
|
expect(portal?.lang).toBe('zh-CN');
|
|
expect(portal?.classList.contains('dark')).toBe(false);
|
|
|
|
unmount();
|
|
expect(
|
|
document.body.querySelector('[data-web-shell-portal-root]'),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('falls back to the built-in Markdown renderer when customization throws', () => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const { container } = render(
|
|
<WebShellTranscript
|
|
blocks={[
|
|
block({
|
|
id: 'assistant',
|
|
kind: 'assistant',
|
|
text: 'Before\n\n```ts\nconst value = 1;\n```\n\nAfter',
|
|
}),
|
|
]}
|
|
markdown={{
|
|
renderCodeBlock() {
|
|
throw new Error('custom renderer failed');
|
|
},
|
|
}}
|
|
/>,
|
|
);
|
|
expect(container.textContent).toContain('Before');
|
|
expect(container.textContent).toContain('const value = 1;');
|
|
expect(container.textContent).toContain('After');
|
|
});
|
|
});
|