Skip to content
5 changes: 5 additions & 0 deletions packages/web-shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,11 @@ const projection = projectChatRecordsToDaemonTranscript(records);
宿主应显示 `projection.diagnostics`,并在 `complete=false` 或 `truncated=true` 时提示
历史可能不完整。组件需要一个可用高度;自定义 renderer 的副作用仍由宿主负责。

## 消息操作

- 已完成的 assistant 消息支持复制;具备持久化 checkpoint 时还支持分支。
- 终态 turn error 支持复制显示的错误文本。重试入口保持独立,错误轮次不支持分支。

## Props

### WebShellWithProviders
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,52 @@
color: var(--foreground, #fff);
}

.actions {
display: flex;
width: fit-content;
align-items: center;
margin-top: 4px;
margin-left: -2px;
opacity: 0;
transition: opacity 0.12s ease;
}

.message:hover .actions,
.message:focus-within .actions {
opacity: 1;
}
Comment thread
Wkewen marked this conversation as resolved.
Resolved

@media (hover: none) {
.actions {
opacity: 1;
}
}

.copyButton {
display: inline-flex;
width: 20px;
height: 20px;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 5px;
background: transparent;
color: var(--muted-foreground);
cursor: pointer;
}

.copyButton:hover,
.copyButton:focus-visible {
background: var(--secondary);
outline: none;
}

.copyButton svg {
width: 14px;
height: 14px;
}

/* Right-aligned, subtle marker for a user-cancelled (ESC) stream — reads as a
user-side action and keeps the same divider treatment as a turn status row. */
.cancelled {
Expand Down
131 changes: 131 additions & 0 deletions packages/web-shell/client/components/messages/SystemMessage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ afterEach(() => {
act(() => root.unmount());
container.remove();
}
vi.useRealTimers();
vi.restoreAllMocks();
});

function render(node: ReactNode, language: 'en' | 'zh-CN' = 'en'): HTMLElement {
Expand Down Expand Up @@ -60,6 +62,135 @@ describe('SystemMessage — prompt_cancelled marker', () => {
});
});

describe('SystemMessage — terminal turn error copy', () => {
it('copies the displayed error without triggering retry', async () => {
vi.useFakeTimers();
const clipboardDescriptor = Object.getOwnPropertyDescriptor(
navigator,
'clipboard',
);
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
const onRetryClick = vi.fn();

try {
const container = render(
<SystemMessage
content="The model stream was interrupted."
variant="error"
source="turn_error"
showRetryHint
onRetryClick={onRetryClick}
/>,
);
const copyButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Copy"]',
);
expect(copyButton).not.toBeNull();
expect(container.textContent).toContain('Press Ctrl+Y to retry');

await act(async () => {
copyButton?.click();
});

expect(writeText).toHaveBeenCalledOnce();
expect(writeText).toHaveBeenCalledWith(
'The model stream was interrupted.',
);
expect(onRetryClick).not.toHaveBeenCalled();
expect(copyButton?.querySelector('.lucide-check')).not.toBeNull();

act(() => vi.advanceTimersByTime(1999));
expect(copyButton?.querySelector('.lucide-check')).not.toBeNull();
act(() => vi.advanceTimersByTime(1));
expect(copyButton?.querySelector('.lucide-copy')).not.toBeNull();
} finally {
if (clipboardDescriptor) {
Object.defineProperty(navigator, 'clipboard', clipboardDescriptor);
} else {
Reflect.deleteProperty(navigator, 'clipboard');
}
}
});

it('keeps the copy icon when writing to the clipboard fails', async () => {
const clipboardDescriptor = Object.getOwnPropertyDescriptor(
navigator,
Comment thread
Wkewen marked this conversation as resolved.
Resolved
'clipboard',
);
const execCommandDescriptor = Object.getOwnPropertyDescriptor(
document,
'execCommand',
);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
});
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: vi.fn().mockReturnValue(false),
});
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);

try {
const container = render(
<SystemMessage
content="The model stream was interrupted."
variant="error"
source="turn_error"
/>,
);
const copyButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Copy"]',
);

await act(async () => {
copyButton?.click();
await Promise.resolve();
});

expect(copyButton?.querySelector('.lucide-copy')).not.toBeNull();
expect(copyButton?.querySelector('.lucide-check')).toBeNull();
expect(warn).toHaveBeenCalledOnce();
} finally {
if (clipboardDescriptor) {
Object.defineProperty(navigator, 'clipboard', clipboardDescriptor);
} else {
Reflect.deleteProperty(navigator, 'clipboard');
}
if (execCommandDescriptor) {
Object.defineProperty(document, 'execCommand', execCommandDescriptor);
} else {
Reflect.deleteProperty(document, 'execCommand');
}
}
});

it('shows Copy for non-retryable terminal turn errors', () => {
const container = render(
<SystemMessage
content="The model stopped because it was repeating itself."
variant="error"
source="turn_error"
/>,
);

expect(container.querySelector('button[aria-label="Copy"]')).not.toBeNull();
expect(container.textContent).not.toContain('retry');
});

it('does not add Copy to ordinary system errors', () => {
const container = render(
<SystemMessage content="A system error occurred." variant="error" />,
);

expect(container.querySelector('button[aria-label="Copy"]')).toBeNull();
});
});

describe('SystemMessage — vision bridge notice', () => {
it('localizes a cancelled notice with egress details', () => {
const container = render(
Expand Down
34 changes: 33 additions & 1 deletion packages/web-shell/client/components/messages/SystemMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { memo } from 'react';
import { memo, useCallback, useState } from 'react';
import {
CheckIcon,
CircleCheckIcon,
CircleMinusIcon,
CircleXIcon,
CopyIcon,
InfoIcon,
} from 'lucide-react';
import { useI18n } from '../../i18n';
import {
warnClipboardWriteFailure,
writeClipboardText,
} from '../../utils/clipboard';
import {
ContextUsageMessage,
parseContextUsageMessage,
Expand Down Expand Up @@ -106,6 +112,15 @@ export const SystemMessage = memo(function SystemMessage({
onRetryClick,
}: SystemMessageProps) {
const { t } = useI18n();
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(() => {
void writeClipboardText(content)
.then(() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
Comment thread
Wkewen marked this conversation as resolved.
Resolved
})
.catch(warnClipboardWriteFailure);
Comment thread
Wkewen marked this conversation as resolved.
Resolved
}, [content]);
if (source === 'mid_turn_message_injected') {
return (
<UserMessage
Expand Down Expand Up @@ -319,6 +334,23 @@ export const SystemMessage = memo(function SystemMessage({
</button>
</div>
)}
{source === 'turn_error' && variant === 'error' && (
<div className={styles.actions} data-web-shell-message-actions>
<button
type="button"
className={styles.copyButton}
title={t('common.copy')}
aria-label={t('common.copy')}
onClick={handleCopy}
>
{copied ? (
<CheckIcon aria-hidden="true" />
) : (
<CopyIcon aria-hidden="true" />
)}
</button>
</div>
)}
</div>
</div>
);
Expand Down
94 changes: 93 additions & 1 deletion packages/web-shell/client/e2e/visuals/screenshots.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { expect, test } from '@playwright/test';
import { devices, expect, test } from '@playwright/test';
import type { DaemonEvent } from '@qwen-code/sdk/daemon';
import {
assistantTextEvent,
Expand All @@ -31,6 +31,26 @@ const THEMES: readonly VisualTheme[] = ['dark', 'light'];

test.use({ viewport: { ...VISUAL_VIEWPORT } });

function createTerminalTurnErrorScenario(sessionId: string) {
return createWebShellDaemonScenario({
sessionId,
events: [
userTextEvent('Summarize the current workspace.', { id: 1 }),
{
id: 2,
v: 1,
type: 'turn_error',
data: {
sessionId,
message:
'The model provider closed the response stream before the answer finished. Retry the request or copy these details when reporting the failure.',
promptId: 'prompt-turn-error-visual',
},
},
],
});
}

for (const theme of THEMES) {
test.describe(`web-shell screenshots (${theme})`, () => {
test(`session transcript`, async ({ page }, testInfo) => {
Expand Down Expand Up @@ -64,6 +84,78 @@ for (const theme of THEMES) {
await captureScreenshot(page, `session-transcript-${theme}`);
});

test(`terminal turn error`, async ({ browser, page }, testInfo) => {
const baseURL = resolveBaseURL(testInfo);
const scenario = createTerminalTurnErrorScenario(
'turn-error-copy-visual',
);
const daemon = await installScenario(page, scenario, baseURL);
await gotoSession(page, scenario, daemon, theme);

const errorRow = page
.locator('[data-web-shell-message-row]')
.filter({ hasText: 'The model provider closed the response stream' });
const copyButton = errorRow.getByRole('button', {
name: 'Copy',
exact: true,
});
const actions = errorRow.locator('[data-web-shell-message-actions]');
await expect(actions).toHaveCSS('opacity', '0');
await copyButton.focus();
await expect(actions).toHaveCSS('opacity', '1');
await copyButton.evaluate((button) => button.blur());
await expect(actions).toHaveCSS('opacity', '0');
await errorRow.hover();
await expect(actions).toHaveCSS('opacity', '1');
await captureScreenshot(page, `terminal-turn-error-copy-${theme}`);
Comment thread
Wkewen marked this conversation as resolved.
Resolved

await page.setViewportSize({ width: 720, height: 800 });
await page.mouse.move(0, 0);
await expect(actions).toHaveCSS('opacity', '0');
await errorRow.hover();
await expect(actions).toHaveCSS('opacity', '1');
await captureScreenshot(page, `terminal-turn-error-copy-narrow-${theme}`);

const touchContext = await browser.newContext({
...devices['Pixel 7'],
baseURL,
});
try {
const touchPage = await touchContext.newPage();
const touchScenario = createTerminalTurnErrorScenario(
'turn-error-copy-touch-visual',
);
const touchDaemon = await installScenario(
touchPage,
touchScenario,
baseURL,
);
await gotoSession(touchPage, touchScenario, touchDaemon, theme);
expect(
await touchPage.evaluate(
() => window.matchMedia('(hover: none)').matches,
),
).toBe(true);
const touchErrorRow = touchPage
.locator('[data-web-shell-message-row]')
.filter({ hasText: 'The model provider closed the response stream' });
const touchCopyButton = touchErrorRow.getByRole('button', {
name: 'Copy',
exact: true,
});
await expect(
touchErrorRow.locator('[data-web-shell-message-actions]'),
).toHaveCSS('opacity', '1');
await expect(touchCopyButton).toBeVisible();
await captureScreenshot(
touchPage,
`terminal-turn-error-copy-touch-${theme}`,
);
} finally {
await touchContext.close();
}
});

test(`parallel agents group`, async ({ page }, testInfo) => {
// The group renders only when a turn carries two or more background
// Agent tool calls; seed both as completed so the rows are static and
Expand Down