1 parent
0ca5865a5c
commit
61a0ed487e
6 files changed
+95
-12
No files matched your search
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user