From 61a0ed487eb675aa67107ca2ae8c2dfdc1fc1ffb Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 24 Sep 2026 18:41:58 +0800 Subject: [PATCH] feat(ui-tool): link the collapsed web fetch URL Closes #3390 --- apps/web/tests/clickable-links-gallery.e2e.ts | 19 ++++++++--- .../clickable-links-gallery/ui.expected.md | 5 ++- .../src/client/tool/components/ToolRow.tsx | 30 ++++++++++++++--- .../src/client/tool/models/web-card-model.ts | 18 +++++++++++ .../src/client/tool/toolviews/web-row.tsx | 3 +- .../ui-tool/tests/web-card.client.spec.tsx | 32 ++++++++++++++++++- 6 files changed, 95 insertions(+), 12 deletions(-) diff --git a/apps/web/tests/clickable-links-gallery.e2e.ts b/apps/web/tests/clickable-links-gallery.e2e.ts index b67bcfb4ef..8017951e89 100644 --- a/apps/web/tests/clickable-links-gallery.e2e.ts +++ b/apps/web/tests/clickable-links-gallery.e2e.ts @@ -372,10 +372,21 @@ describe('web e2e: clickable links gallery', () => { // — TOOL_VARIANTS has no entry for it, so it falls to the generic row with // no openable path even though its create still joins the produced chips. expect(await page.locator('button[class*="fileLink"]').count()).toBe(7) + // The collapsed web-fetch row's summary is its URL, opened in a new tab. + const fetchSummaryLink = page.locator(`a[class*="fileLink"][href="${FETCH_URL}"]`) + expect(await fetchSummaryLink.count()).toBe(1) + expect(await fetchSummaryLink.getAttribute('target')).toBe('_blank') - // A row-center click can hit the nested fileLink button and invoke the - // Host opener; right-edge clicks expand the card itself. await expandTurnProcesses(page) + // Clicking the collapsed summary link opens the page without expanding the row. + const fetchPopupPromise = page.waitForEvent('popup') + await fetchSummaryLink.click() + const fetchPopup = await fetchPopupPromise + await fetchPopup.waitForURL(FETCH_URL) + await fetchPopup.close() + expect(await page.locator('[data-web="fetch"]').count()).toBe(0) + // A row-center click can hit a nested summary link and invoke its opener; + // right-edge clicks expand the card itself. for (const row of [ /^Search clickable link styles/, /^Fetch /, @@ -400,7 +411,7 @@ describe('web e2e: clickable links gallery', () => { const sourceLink = page.locator(`a[href="${SOURCE_URL}"]`) await expect.poll(() => sourceLink.count(), { timeout: 10_000 }).toBe(1) expect(await page.locator('a[href^="ftp:"]').count()).toBe(0) - expect(await page.locator(`a[href="${FETCH_URL}"]`).count()).toBe(1) + expect(await page.locator(`a[href="${FETCH_URL}"]`).count()).toBe(2) expect(await page.locator(`a[href="${GUIDE_URL}"]`).count()).toBe(2) const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) @@ -423,7 +434,7 @@ describe('web e2e: clickable links gallery', () => { ['markdown anchor', guideLink], ['file mention', mentions.first()], ['search source', sourceLink.first()], - ['fetch url', page.locator(`a[href="${FETCH_URL}"]`).first()], + ['fetch url', page.locator(`[data-web="fetch"] a[href="${FETCH_URL}"]`)], ] as const) { expect.soft(await styleOf(link, 'color'), `${name} color`).toBe(LINK_BLUE) expect.soft(await styleOf(link, 'font-weight'), `${name} weight`).toBe('500') diff --git a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md index 25c6502dad..f85e34a349 100644 --- a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md +++ b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md @@ -83,7 +83,10 @@ - text: One cursor token, one focus ring. - listitem: Mirror spec (non-http) A non-http source renders inert. - button "Inspect" -- button "Fetch https://docs.example.test/tokens" [expanded] +- button "Fetch https://docs.example.test/tokens" [expanded]: + - text: Fetch + - link "https://docs.example.test/tokens": + - /url: https://docs.example.test/tokens - link "https://docs.example.test/tokens": - /url: https://docs.example.test/tokens - text: HTTP 200 diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx index 618344b4c7..6603ccecd7 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -86,6 +86,8 @@ export interface ToolRowProps { filePathLine?: number | undefined /** Open the path (already cwd-resolved), landing on `filePathLine` when given. */ onOpenFile?: ((path: string, options?: OpenFileOptions) => void) | undefined + /** Safe http(s) URL from tool args; when set, the summary renders as a link that opens it in a new tab. */ + href?: string | undefined /** * Jump to this call in the trajectory view: a hover-revealed Inspect pill * over the expanded body. Absent = no affordance. @@ -93,6 +95,11 @@ export interface ToolRowProps { inspect?: (() => void) | undefined } +/** Keep a summary link click from toggling the row; the browser still follows the link. */ +function stopLinkClick(event: MouseEvent): void { + event.stopPropagation() +} + /** Visually hidden run-state label for color-only running and settlement cues. */ function stateStatus(state: ToolRowState, t: TranslateNS<'conversation'>): string | null { switch (state) { @@ -135,6 +142,7 @@ export const ToolRow = memo(function ToolRow({ filePath, filePathLine, onOpenFile, + href, inspect, useDisclosure, }: ToolRowProps) { @@ -189,11 +197,12 @@ export const ToolRow = memo(function ToolRow({ else onOpenFile(filePath, { line: filePathLine }) } : undefined, [filePath, filePathLine, onOpenFile, settledWithCue]) - // Keep Enter/Space on the focused path link from bubbling to the row's + const linkHref = settledWithCue ? undefined : href + // Keep Enter/Space on the focused path or URL link from bubbling to the row's // keydown handler, which would preventDefault() the key and toggle expand - // instead of activating the link — the keyboard analogue of openFile's - // stopPropagation. The native button still fires its own onClick from the key. - const fileLinkKeyDown = useCallback((event: KeyboardEvent) => { + // instead of activating the link — the keyboard analogue of the click + // handlers' stopPropagation. The native element still activates from the key. + const fileLinkKeyDown = useCallback((event: KeyboardEvent) => { if (event.key === 'Enter' || event.key === ' ') event.stopPropagation() }, []) // The code variant's program renders through CodeBlock (shiki), so only its @@ -213,6 +222,17 @@ export const ToolRow = memo(function ToolRow({ > {summaryText} + ) : linkHref !== undefined ? ( + + {summaryText} + ) : ( {suffix} )} - ), [diffStat, fileLinkKeyDown, openFile, running, state, suffix, summaryText]) + ), [diffStat, fileLinkKeyDown, linkHref, openFile, running, state, suffix, summaryText]) const expandedContent = useMemo(() => open ? (
{askQuestionBody !== null diff --git a/packages/client/ui-tool/src/client/tool/models/web-card-model.ts b/packages/client/ui-tool/src/client/tool/models/web-card-model.ts index 27e416f99d..018b84ed37 100644 --- a/packages/client/ui-tool/src/client/tool/models/web-card-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/web-card-model.ts @@ -80,3 +80,21 @@ export function webCardModel(block: ToolCallBlock): WebCardModelProps | null { truncated: meta.truncated, } } + +/** + * Read the openable URL of a web_fetch call from its arguments. + * @param block - running or settled Tool block. + * @returns the http(s) URL, or undefined for another tool, protocol, or unparsable argument. + */ +export function webFetchHref(block: ToolCallBlock): string | undefined { + const call = parsedToolCall(block) + if (call?.name !== 'web_fetch' || typeof call.args.url !== 'string') return undefined + const { url } = call.args + try { + const { protocol } = new URL(url) + return protocol === 'http:' || protocol === 'https:' ? url : undefined + } catch { + // An unparsable URL stays plain summary text. + return undefined + } +} diff --git a/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx index c81acaf305..bea710dc28 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx @@ -2,7 +2,7 @@ import type { Context } from '@deepseek-ai/cordis' import { IconBrowseOutlineRegular, IconGlobeOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '../../contract/slots.ts' -import { webCardModel } from '../models/web-card-model.ts' +import { webCardModel, webFetchHref } from '../models/web-card-model.ts' import { toolRowModel } from '../models/tool-call-model.ts' import { ToolRow } from '../components/ToolRow.tsx' import { CONVERSATION_NS as NS } from '../../locale.ts' @@ -33,6 +33,7 @@ export function WebRow({ toolName, block, inspect, useDisclosure, t }: WebRowPro output={model.output} errorSummary={model.errorSummary} web={web} + href={webFetchHref(block)} state={model.state} inspect={inspect} /> diff --git a/packages/client/ui-tool/tests/web-card.client.spec.tsx b/packages/client/ui-tool/tests/web-card.client.spec.tsx index 1ead44b794..2341267eec 100644 --- a/packages/client/ui-tool/tests/web-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/web-card.client.spec.tsx @@ -6,7 +6,7 @@ import { cleanup, fireEvent, render } from '@testing-library/react' import type { StartedToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import type { ToolCallOwnerProps } from '@deepseek-ai/dsh-client-ui-tool/client' import { IconGlobeOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' -import { webCardModel } from '../src/client/tool/models/web-card-model.ts' +import { webCardModel, webFetchHref } from '../src/client/tool/models/web-card-model.ts' import { GenericToolCard } from '../src/client/tool/toolviews/GenericToolCard.tsx' import { WebRow, webToolview } from '../src/client/tool/toolviews/web-row.tsx' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' @@ -123,6 +123,20 @@ describe('webCardModel', () => { }) }) +describe('webFetchHref', () => { + it('returns only an http(s) web_fetch URL', () => { + expect(webFetchHref(settledFetch())).toBe('https://example.com/page') + expect(webFetchHref(settledFetch({ call: { name: 'web_fetch', argsRaw: '{"url":"http://a.test/"}' } }))) + .toBe('http://a.test/') + expect(webFetchHref(settledFetch({ call: { name: 'web_fetch', argsRaw: '{"url":"javascript:alert(1)"}' } }))) + .toBeUndefined() + expect(webFetchHref(settledFetch({ call: { name: 'web_fetch', argsRaw: '{"url":"not a url"}' } }))).toBeUndefined() + expect(webFetchHref(settledFetch({ call: { name: 'web_fetch', argsRaw: '{"url":1}' } }))).toBeUndefined() + expect(webFetchHref(settledFetch({ call: null }))).toBeUndefined() + expect(webFetchHref(settledSearch())).toBeUndefined() + }) +}) + describe('chat row web body', () => { const ownerProps = (block: StartedToolCall | ToolResultNode, toolName: string): ToolCallOwnerProps => ({ useDisclosure, callId: block.callId, toolName, ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), openFile: vi.fn(), loadImage: vi.fn(() => Promise.reject(new Error('not used'))), @@ -165,6 +179,22 @@ describe('chat row web body', () => { expect(view.getByText('HTTP 200')).toBeTruthy() }) + it('the collapsed WebRow summary opens the fetch URL in a new tab without expanding', () => { + const view = render() + const link = view.getByRole('link', { name: 'https://example.com/page' }) + expect(link.getAttribute('href')).toBe('https://example.com/page') + expect(link.getAttribute('target')).toBe('_blank') + expect(link.getAttribute('rel')).toBe('noopener noreferrer') + fireEvent.click(link) + fireEvent.keyDown(link, { key: 'Enter' }) + expect(view.container.querySelector('[data-web]')).toBeNull() + }) + + it('a failed web fetch keeps its plain error summary', () => { + const view = render() + expect(view.queryByRole('link')).toBeNull() + }) + it('a running web call is the summary row alone, with nothing to expand', () => { const view = render() expect(view.getByText('网页搜索')).toBeTruthy()