feat(ui-tool): link the collapsed web fetch URL

Closes #3390
This commit is contained in:
Turtle committed 2026-09-24 18:41:58 +08:00
1 parent 0ca5865a5c
commit 61a0ed487e
6 files changed
+95 -12

No files matched your search

+15 -4
View File
@@ -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')
@@ -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
@@ -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<HTMLAnchorElement>): 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<HTMLButtonElement>) => {
// 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<HTMLElement>) => {
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({
>
<TextShimmer active={running}>{summaryText}</TextShimmer>
</button>
) : linkHref !== undefined ? (
<a
className={css.fileLink}
href={linkHref}
target="_blank"
rel="noopener noreferrer"
onClick={stopLinkClick}
onKeyDown={fileLinkKeyDown}
>
<TextShimmer active={running}>{summaryText}</TextShimmer>
</a>
) : (
<span
className={clsx(
@@ -228,7 +248,7 @@ export const ToolRow = memo(function ToolRow({
<TextShimmer className={clsx(css.summarySuffix, suffix === diffStat && css.diffStat)} active={running}>{suffix}</TextShimmer>
)}
</>
), [diffStat, fileLinkKeyDown, openFile, running, state, suffix, summaryText])
), [diffStat, fileLinkKeyDown, linkHref, openFile, running, state, suffix, summaryText])
const expandedContent = useMemo(() => open ? (
<div className={clsx(css.bodyWrap, detailsBody !== null && css.detailsBodyWrap)}>
{askQuestionBody !== null
@@ -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
}
}
@@ -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}
/>
@@ -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(<WebRow {...rowProps(settledFetch(), 'web_fetch')} />)
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(<WebRow {...rowProps(settledFetch({ isError: true }), 'web_fetch')} />)
expect(view.queryByRole('link')).toBeNull()
})
it('a running web call is the summary row alone, with nothing to expand', () => {
const view = render(<WebRow {...rowProps(runningSearch(), 'web_search')} />)
expect(view.getByText('网页搜索')).toBeTruthy()