feat(web-shell): open link artifacts in a new page (#12317)

A recorded link artifact is an entry to a resource rather than a document the
side panel can render, so its turn card now opens the address instead of a
metadata tab: the control becomes an anchor routed through the shell's external
opener, which hands the URL to the OS browser in the packaged desktop window and
falls back to a native new tab in a plain one. Destinations that opener cannot
take, such as a redacted locator, keep the metadata panel, and Artifact-published
deliveries keep the panel's live preview.

The live-preview design doc records the split in both languages, and the e2e case
that pinned the old metadata behaviour now asserts the new one.
This commit is contained in:
ytahdn authored and GitHub committed 2026-09-20 09:08:52 +00:00
1 parent fda4abad19
commit c3e86ea1bd
6 files changed
+202 -55

No files matched your search

@@ -15,8 +15,9 @@ or shell tool.
Preview history belongs to the conversation, independently of the viewing
panel. Opening an Artifact-published HTTP/HTTPS webpage from a turn opens its URL
in the preview when the feature is enabled. Ordinary `record_artifact` links
open metadata with an external action. Closing the panel or its tab does not
in the preview when the feature is enabled. Other link artifacts open their
address in a new page from the turn card, which keeps the metadata panel only for
an address the external opener cannot take. Closing the panel or its tab does not
remove the artifact card; users can reopen it from the original message.
Manually entering an address in the panel does not create a transcript record.
@@ -11,9 +11,9 @@
切换和外部打开。服务器仍通过现有终端或 shell 工具运行。
预览历史属于对话,与查看面板相互独立。启用功能后,从某一轮打开 Artifact 发布的
HTTP/HTTPS 网页,会在预览中打开其 URL。普通 `record_artifact` 链接仍展示元数据和
外部打开操作。关闭面板或标签不会删除产物卡片,用户可以从原消息重新打开。
在面板中手动输入地址不会创建对话记录。
HTTP/HTTPS 网页,会在预览中打开其 URL。其他 link 产物从轮次卡片直接在新页面打开
其地址;只有外部打开器无法接收该地址时才保留元数据面板。关闭面板或标签不会删除
产物卡片,用户可以从原消息重新打开。在面板中手动输入地址不会创建对话记录。
这些是实时 URL 的历史入口,不是冻结的网站版本。预览会明确标注这个区别。
复用开发 URL 时,即使从旧轮次打开,也会显示当前应用。现有产物元数据也会按身份更新,
@@ -667,4 +667,65 @@ describe('TurnOutputs artifact downloads', () => {
act(() => root.unmount());
});
it('opens a recorded link through the external opener instead of the panel', () => {
const invoke = vi.fn().mockResolvedValue(undefined);
(window as { __TAURI__?: unknown }).__TAURI__ = { core: { invoke } };
const onOpenRequest = vi.fn();
const onOpenArtifact = vi.fn();
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<I18nProvider language="en">
<TurnOutputs
turnId="turn-link"
workspaceCwd="/primary"
changes={[]}
artifacts={[
{
id: 'recorded-link',
kind: 'link',
storage: 'external_url',
status: 'available',
title: 'Recorded link',
url: 'https://platform.example.com/detail?id=7',
} as DaemonSessionArtifact,
]}
scheduledTasks={[]}
onOpenRequest={onOpenRequest}
onReviewChanges={() => {}}
onOpenArtifact={onOpenArtifact}
onOpenScheduledTask={() => {}}
/>
</I18nProvider>,
);
});
// The card keeps a single control: the address it opens.
expect(container.querySelectorAll('button, a')).toHaveLength(1);
const link = container.querySelector('a');
expect(link?.textContent?.trim()).toBe('Open');
expect(link?.getAttribute('href')).toBe(
'https://platform.example.com/detail?id=7',
);
expect(link?.getAttribute('target')).toBe('_blank');
expect(link?.getAttribute('rel')).toBe('noopener noreferrer');
act(() => {
link?.dispatchEvent(
new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 }),
);
});
expect(invoke).toHaveBeenCalledWith('plugin:opener|open_url', {
url: 'https://platform.example.com/detail?id=7',
});
expect(onOpenRequest).not.toHaveBeenCalled();
expect(onOpenArtifact).not.toHaveBeenCalled();
act(() => root.unmount());
delete (window as { __TAURI__?: unknown }).__TAURI__;
});
});
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import type { DaemonSessionArtifact } from '@qwen-code/sdk/daemon';
import {
canOpenWorkspaceArtifact,
getArtifactExternalUrl,
getArtifactPreviewContent,
getFileChangePreviewContent,
getWorkspaceArtifactOpenBlockReason,
@@ -187,4 +188,49 @@ describe('TurnOutputs helpers', () => {
'File not found in the workspace',
);
});
it.each([
[
'external_url',
'https://platform.example.com/detail?id=7',
'https://platform.example.com/detail?id=7',
],
[
'external_url',
' https://example.com/detail ',
'https://example.com/detail',
],
['external_url', 'mailto:[email protected]', 'mailto:[email protected]'],
['published', 'https://example.com/detail', undefined],
['external_url', 'javascript:alert(1)', undefined],
['external_url', 'w/agent/report.html', undefined],
['external_url', undefined, undefined],
])(
'opens a %s link artifact with %s in a new page',
(storage, url, expected) => {
expect(
getArtifactExternalUrl({
id: 'link-1',
kind: 'link',
storage,
status: 'available',
title: 'Detail page',
...(url === undefined ? {} : { url }),
} as DaemonSessionArtifact),
).toBe(expected);
},
);
it('keeps non-link artifacts on the panel path', () => {
expect(
getArtifactExternalUrl({
id: 'page-1',
kind: 'html',
storage: 'external_url',
status: 'available',
title: 'Published page',
url: 'https://example.com/page',
} as DaemonSessionArtifact),
).toBeUndefined();
});
});
@@ -5,8 +5,10 @@ import type {
import type { ACPToolCall } from '../../adapters/types';
import { DownloadIcon, SquareArrowOutUpRightIcon } from 'lucide-react';
import { memo, useEffect, useRef, useState } from 'react';
import { useExternalLinkOpener } from '../../hooks/useExternalLinkOpener';
import { useI18n } from '../../i18n';
import { extractErrorDetail } from '../../utils/errorDetail';
import { isExternalOpenUrl } from '../../utils/externalOpen';
import { describeCron } from '../dialogs/scheduledTasksSchedule';
import {
formatArtifactSize,
@@ -367,29 +369,33 @@ function TurnOutputsComponent({
</div>
)}
{visibleArtifacts.map((artifact) => (
<ArtifactCard
key={artifact.id}
artifact={artifact}
onOpen={
canOpenWorkspaceArtifact(artifact)
? () => openArtifact(artifact)
: undefined
}
onError={onError}
onDownload={
canDownloadArtifact(artifact) && workspaceActions
? (isCancelled) =>
downloadWorkspaceFile(
workspaceActions,
artifact.workspacePath,
artifact.mimeType,
isCancelled,
)
: undefined
}
/>
))}
{visibleArtifacts.map((artifact) => {
const externalUrl = getArtifactExternalUrl(artifact);
return (
<ArtifactCard
key={artifact.id}
artifact={artifact}
externalUrl={externalUrl}
onOpen={
externalUrl || !canOpenWorkspaceArtifact(artifact)
? undefined
: () => openArtifact(artifact)
}
onError={onError}
onDownload={
canDownloadArtifact(artifact) && workspaceActions
? (isCancelled) =>
downloadWorkspaceFile(
workspaceActions,
artifact.workspacePath,
artifact.mimeType,
isCancelled,
)
: undefined
}
/>
);
})}
{remainingArtifacts > 0 && (
<button
type="button"
@@ -421,16 +427,19 @@ function TurnOutputsComponent({
function ArtifactCard({
artifact,
externalUrl,
onOpen,
onDownload,
onError,
}: {
artifact: DaemonSessionArtifact;
externalUrl?: string;
onOpen?: () => void;
onDownload?: (isCancelled: () => boolean) => Promise<void>;
onError?: (error: unknown, fallback: string) => void;
}) {
const { t } = useI18n();
const openExternalLink = useExternalLinkOpener();
const [downloading, setDownloading] = useState(false);
const mountedRef = useRef(true);
useEffect(() => {
@@ -463,6 +472,9 @@ function ArtifactCard({
setDownloading(false);
}
};
const openIcon = (
<SquareArrowOutUpRightIcon size={16} strokeWidth={1.8} aria-hidden="true" />
);
return (
<div className={styles.card}>
<div className={styles.summary}>
@@ -499,19 +511,28 @@ function ArtifactCard({
className={styles.openButtonWrapper}
title={blockedReason ?? artifact.title}
>
<button
type="button"
className={styles.reviewButton}
onClick={onOpen}
disabled={!onOpen}
>
<SquareArrowOutUpRightIcon
size={16}
strokeWidth={1.8}
aria-hidden="true"
/>
{t('common.open')}
</button>
{externalUrl ? (
<a
className={styles.reviewButton}
href={externalUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(event) => openExternalLink(event, externalUrl)}
>
{openIcon}
{t('common.open')}
</a>
) : (
<button
type="button"
className={styles.reviewButton}
onClick={onOpen}
disabled={!onOpen}
>
{openIcon}
{t('common.open')}
</button>
)}
</span>
</div>
</div>
@@ -684,6 +705,22 @@ export function canOpenWorkspaceArtifact(
return artifact.status === 'available' || artifact.status === 'changed';
}
/**
* Link artifacts are entries to a resource, so their card opens the address in
* a new page instead of the side panel. Only a URL the shell may hand to the
* external opener qualifies, and published deliveries stay with the panel's
* live preview (`handleTurnOutputOpen`).
*/
export function getArtifactExternalUrl(
artifact: DaemonSessionArtifact,
): string | undefined {
if (artifact.kind !== 'link' || artifact.storage === 'published') {
return undefined;
}
const url = artifact.url?.trim();
return isExternalOpenUrl(url) ? url : undefined;
}
export function getWorkspaceArtifactOpenBlockReason(
artifact: DaemonSessionArtifact,
t: (key: string, vars?: Record<string, string | number>) => string,
@@ -374,22 +374,24 @@ test('reopens a live webpage from its historical message after closing the tab a
}
});
test('recorded links open metadata without automatically loading a live frame', async ({
test('opens recorded links in a new page instead of a panel tab', async ({
page,
}) => {
await page
.getByRole('button', { name: 'Close Web preview', exact: true })
.click();
await page
.locator('[data-web-shell-message-list] [title="Recorded link"] > button')
.click();
const tabs = page.getByRole('tab');
const tabsBefore = await tabs.count();
expect(tabsBefore).toBeGreaterThan(0);
const card = page.locator(
'[data-web-shell-message-list] [title="Recorded link"]',
);
await expect(card.locator('a')).toHaveAttribute('href', fixtureUrl);
const popupPromise = page.waitForEvent('popup');
await card.locator('a').click();
const popup = await popupPromise;
await popup.waitForLoadState('domcontentloaded');
expect(new URL(popup.url()).origin).toBe(fixtureUrl);
await expect(tabs).toHaveCount(tabsBefore);
await expect(
page.getByRole('tab', { name: 'Recorded link', exact: true }),
).toBeVisible();
await expect(page.locator('iframe[title="Web preview frame"]')).toHaveCount(
0,
);
await expect(
page.getByRole('link', { name: 'Open link', exact: true }),
).toHaveAttribute('href', fixtureUrl);
).toHaveCount(0);
await popup.close();
});