mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
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:
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();
|
||||
});
|
||||
Reference in new issue
Block a user