feat(web): render web_search/web_fetch output as a web card
Consume the card:'web' result view (structured sources + answer for search, the URL and HTTP status for fetch) the web backend PR added. WebBlock (ui-primitives) draws both kinds via the kind discriminant: a citation list of safe external links (the MarkdownText protocol allowlist, title-or-hostname label), a truncation indicator, a height cap matching TerminalBlock; a fetch summary for the other kind. web-card-model is the single resultView derivation; a keyed WebRow registers under web_search and web_fetch with the card resident under its summary. The generic fallback and the details panel are web-aware. Fixture gains web_search and web_fetch turns for the built-boot snapshot.
This commit is contained in:
@@ -136,6 +136,54 @@ const TERMINAL_EXIT_STATUS: Record<string, { exitCode: number } | { signal: stri
|
||||
[TERMINAL_OUTPUT_FIXTURE]: { exitCode: 1 },
|
||||
}
|
||||
|
||||
/**
|
||||
* One inline-authored source for the `web_search` fixture. Structurally the
|
||||
* contract's `WebSource`; authored locally because the fixture cannot import the
|
||||
* web tool, and kept minimal so a missing optional field renders its fallback.
|
||||
*/
|
||||
interface WebSourceFixture {
|
||||
url: string
|
||||
title?: string
|
||||
snippet?: string
|
||||
publishedAt?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The structured `web_search` result view for fixture turn 66, authored inline
|
||||
* because this client-side fixture cannot import the web tool that projects it.
|
||||
* The sources exercise the citation list's features: a titled source with a
|
||||
* snippet and a date, a source with no title (its hostname labels the link), and
|
||||
* a source with a snippet but no date. `truncated` marks the capped indicator.
|
||||
*/
|
||||
const WEB_SEARCH_RESULT: { answer: string; sources: WebSourceFixture[]; truncated: boolean } = {
|
||||
answer: 'DeepSeek Harness is a plugin-based agent harness on vendored Cordis where **every capability is a plugin**.',
|
||||
sources: [
|
||||
{
|
||||
url: 'https://github.com/deepseek-ai/deepseek-harness',
|
||||
title: 'DeepSeek Harness — plugin-based agent harness',
|
||||
snippet: 'Everything is a plugin: session, tools, agent-loop, and LLM adapters all mount on the same Cordis context.',
|
||||
publishedAt: '2026-07-01',
|
||||
},
|
||||
{
|
||||
url: 'https://www.deepseek.com/blog/harness-architecture',
|
||||
snippet: 'The capability-seam pattern splits each capability into interface, implementation, and consumer packages.',
|
||||
},
|
||||
{
|
||||
url: 'https://docs.deepseek.com/harness/plugins',
|
||||
title: 'Writing a harness plugin',
|
||||
publishedAt: '2026-06-15',
|
||||
},
|
||||
],
|
||||
truncated: true,
|
||||
}
|
||||
|
||||
/** The `web_fetch` result view for fixture turn 67, authored inline for the same reason. */
|
||||
const WEB_FETCH_RESULT: { url: string; statusCode: number; truncated: boolean } = {
|
||||
url: 'https://www.deepseek.com/blog/harness-architecture',
|
||||
statusCode: 200,
|
||||
truncated: false,
|
||||
}
|
||||
|
||||
const DEEPSEEK_REASONING = {
|
||||
efforts: [
|
||||
{ id: 'off', name: 'Off' },
|
||||
@@ -296,8 +344,20 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
// strip empty and take the todo surfaces' own coverage with it.
|
||||
toolTurn(65, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE)
|
||||
|
||||
// Turns 66-67: the web render intent — a web_search whose result view carries
|
||||
// structured sources plus an answer (the citation list, one source lacking a
|
||||
// title so its hostname labels the link, the capped indicator on), and a
|
||||
// web_fetch whose result view carries the fetched URL and its HTTP status.
|
||||
// Both keep a generic pending call view and add the `web` card only at
|
||||
// result time, which is the contract's result-only web shape. Named after
|
||||
// the real tools so they hit the keyed WebRow registration. Ordered BEFORE
|
||||
// the todo turn for the same reason turn 65 is: the standing plan retires at
|
||||
// the next turn/start, so a turn after it would empty the dock's plan strip.
|
||||
toolTurn(66, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.')
|
||||
toolTurn(67, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
|
||||
|
||||
const todoArgs = JSON.stringify({ todos: fixtureTodos })
|
||||
toolTurn(66, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
|
||||
toolTurn(68, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
|
||||
// The real tool appends the snapshot mid-execution — between tool/call and
|
||||
// tool/result — so the fixture reproduces that exact ordering (the last
|
||||
// toolTurn events run ... tool/call, tool/result, step/end, turn/end).
|
||||
@@ -336,6 +396,13 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
|
||||
return { card: 'generic', title: `Edit ${str(args.file_path)}`, kind: 'edit', rawInput: args }
|
||||
case 'write':
|
||||
return { card: 'generic', title: `Write ${str(args.file_path)}`, kind: 'edit', rawInput: args }
|
||||
// The web tools keep a GENERIC pending card and add the `web` result card
|
||||
// only at result time (the contract's result-only web shape); their pending
|
||||
// kind matches the result kind so a call and its result read as one category.
|
||||
case 'web_search':
|
||||
return { card: 'generic', title: `Search ${str(args.query)}`, kind: 'search', rawInput: args }
|
||||
case 'web_fetch':
|
||||
return { card: 'generic', title: `Fetch ${str(args.url)}`, kind: 'fetch', rawInput: args }
|
||||
default:
|
||||
return undefined // echo et al: the documented no-view fallback path
|
||||
}
|
||||
@@ -344,6 +411,16 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
|
||||
function presentResult(name: string, argsRaw: string, resultText: string): ToolResultView | undefined {
|
||||
const call = presentCall(name, argsRaw)
|
||||
if (call === undefined) return undefined
|
||||
// The web tools keep a generic pending card, so their result card is chosen
|
||||
// by tool name rather than by the pending card tag: the structured `web`
|
||||
// card the frontend consumes, with the model-facing text kept as the
|
||||
// capability-less fallback content.
|
||||
if (name === 'web_search') {
|
||||
return { card: 'web', kind: 'search', ...WEB_SEARCH_RESULT, content: text(resultText) }
|
||||
}
|
||||
if (name === 'web_fetch') {
|
||||
return { card: 'web', kind: 'fetch', ...WEB_FETCH_RESULT, content: text(resultText) }
|
||||
}
|
||||
switch (call.card) {
|
||||
case 'terminal':
|
||||
// The sample's own exit status, authored beside it: re-parsing the
|
||||
|
||||
@@ -19,6 +19,7 @@ import { InputBar } from './skeleton/InputBar.tsx'
|
||||
import { ChatView } from './chat/ChatView.tsx'
|
||||
import { StatsLine } from './chat/StatsLine.tsx'
|
||||
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
|
||||
import { webToolview } from './toolviews/web-row.tsx'
|
||||
import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx'
|
||||
import { todoToolview } from './toolviews/todo-row.tsx'
|
||||
import { todoDockEntry } from './skeleton/TodoPanel.tsx'
|
||||
@@ -254,6 +255,11 @@ export function apply(ctx: Context): void {
|
||||
// (ToolRow-matching Bash · {description} chrome; scoped badge in child sessions).
|
||||
ctx.plugin(bashToolviewSample)
|
||||
|
||||
// The web rows ride the same seam: one WebRow registered under both
|
||||
// web_search and web_fetch, rendering the completed retrieval's web card
|
||||
// resident under the summary (a product registration, not a sample).
|
||||
ctx.plugin(webToolview)
|
||||
|
||||
// The todo_write row rides the same seam (a product registration, not a sample).
|
||||
ctx.plugin(todoToolview)
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/* The generic card grows a resident web card under its summary row when the
|
||||
tool declares the `web` render intent but has no keyed row of its own (the
|
||||
web_search/web_fetch rows register their own WebRow). A column around the
|
||||
ToolRow keeps the row's own 24px height. */
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
|
||||
and replaces the primitive's standalone vertical margin with the flow's. */
|
||||
.web {
|
||||
margin: 4px 0 4px 22px;
|
||||
}
|
||||
@@ -7,12 +7,14 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconSparkle16,
|
||||
IconThinkOutline14,
|
||||
IconThinkOutline14, WebBlock,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolRowOwnerProps } from '../contract/slots.ts'
|
||||
import { terminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../contract/web-card-model.ts'
|
||||
import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
|
||||
import { ToolRow } from './ToolRow.tsx'
|
||||
import css from './GenericToolCard.module.css'
|
||||
|
||||
/** Variant leading icons (figma table); all glyphs render at 14 inside the 16px leading box. */
|
||||
const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
|
||||
@@ -29,8 +31,9 @@ const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
|
||||
export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwnerProps) {
|
||||
const model = toolRowModel(toolName, block, cwd)
|
||||
const terminal = terminalCardModel(block, cwd)
|
||||
const web = webCardModel(block)
|
||||
const singleFile = model.filePath !== undefined
|
||||
return (
|
||||
const row = (
|
||||
<ToolRow
|
||||
variant={model.variant}
|
||||
toolName={toolName}
|
||||
@@ -47,4 +50,16 @@ export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwner
|
||||
onOpenFile={singleFile ? openFile : undefined}
|
||||
/>
|
||||
)
|
||||
// A web-declaring tool without its own keyed row lands here; its card is
|
||||
// resident under the summary, mirroring WebRow (and BashRow's terminal card).
|
||||
if (web === null) return row
|
||||
return (
|
||||
<div className={css.card}>
|
||||
{row}
|
||||
<WebBlock
|
||||
{...(web.kind === 'search' ? { ...web, maxSources: CHAT_WEB_MAX_SOURCES } : web)}
|
||||
className={css.web}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Pure derivation of the web-card props from a frozen call slice: the
|
||||
* `card:'web'` render intent the `web_search`/`web_fetch` tools declare at
|
||||
* result time arrives on the snapshot as `resultView`, and this is the one
|
||||
* place that turns it into what {@link WebBlock} draws. Both conversation
|
||||
* render sites (the chat tool row's resident/expanded body and the details
|
||||
* panel's Output section) call this, so the sources and fetch summary they
|
||||
* show are derived once.
|
||||
*
|
||||
* The web card is result-only by contract: those tools keep a generic pending
|
||||
* call view, so there is nothing to derive while the call is still running and
|
||||
* a running call always takes the generic path.
|
||||
* @module
|
||||
*/
|
||||
import type { WebBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolCallBlock } from './tool-call-model.ts'
|
||||
|
||||
/**
|
||||
* Sources the chat row's web body shows before collapsing the middle — half
|
||||
* the primitive's own default, which the details panel keeps. A chat row is a
|
||||
* summary surface inside the message flow: the flow must stay scannable across
|
||||
* many calls, while the details panel is the single-call reading surface. A
|
||||
* design constant of this UI's row geometry, not a deployment choice, so it is
|
||||
* fixed here rather than a plugin Config field.
|
||||
*/
|
||||
export const CHAT_WEB_MAX_SOURCES = 8
|
||||
|
||||
/**
|
||||
* Derive the web-card props for a tool call, or null when this call is not a
|
||||
* web card and belongs on the generic path.
|
||||
*
|
||||
* The result side supplies the whole card: the sources and answer for a
|
||||
* `search`, the URL and status for a `fetch`. Cases producing null, all of
|
||||
* them the documented generic-card default:
|
||||
*
|
||||
* - A running call (no `resultView` yet): the web tools keep a generic pending
|
||||
* card, so nothing web-shaped exists until the call settles.
|
||||
* - A settled call whose result view is not a web card — including a `card`
|
||||
* value this UI version does not know, which arrives over the wire and so
|
||||
* cannot be trusted to be one of the compiled variants, and a generic result
|
||||
* view (a web tool's error path returns the generic card, whose text the
|
||||
* generic path preserves).
|
||||
* @param block - RunningToolCall or ToolResultNode off the snapshot caches.
|
||||
* @returns the web-card props, or null for the generic path.
|
||||
*/
|
||||
export function webCardModel(block: ToolCallBlock): WebBlockProps | null {
|
||||
// Running calls have no result view; the web card is result-only.
|
||||
if (!('kind' in block)) return null
|
||||
const result = block.resultView
|
||||
if (result?.card !== 'web') return null
|
||||
if (result.kind === 'search') {
|
||||
return {
|
||||
kind: 'search',
|
||||
answer: result.answer,
|
||||
sources: result.sources.map(source => ({
|
||||
url: source.url,
|
||||
title: source.title,
|
||||
snippet: source.snippet,
|
||||
publishedAt: source.publishedAt,
|
||||
})),
|
||||
truncated: result.truncated,
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: 'fetch',
|
||||
url: result.url,
|
||||
statusCode: result.statusCode,
|
||||
truncated: result.truncated,
|
||||
}
|
||||
}
|
||||
@@ -106,3 +106,9 @@
|
||||
.terminal {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Same rule for the web card: it sits under the section label, so the section
|
||||
owns the spacing rather than the primitive's own vertical margin. */
|
||||
.web {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -7,11 +7,12 @@
|
||||
// share the store seat exists for) and derives the call material from the
|
||||
// session snapshot — no data of its own.
|
||||
|
||||
import { CodeBlock, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { CodeBlock, TerminalBlock, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { DetailsSlotProps } from '../contract/slots.ts'
|
||||
import { terminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import { webCardModel } from '../contract/web-card-model.ts'
|
||||
import type { ToolCallBlock } from '../contract/tool-call-model.ts'
|
||||
import css from './DetailsPanel.module.css'
|
||||
|
||||
@@ -127,8 +128,10 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo
|
||||
* The Output section's body for the selected call. A terminal-card call — a
|
||||
* shell command's call/result views — renders through the shared TerminalBlock
|
||||
* at the primitive's own full height allowance, so column-aligned output keeps
|
||||
* its alignment and scrolls sideways instead of folding. Every other call, and
|
||||
* a running call with no terminal card yet, keeps the flattened text form.
|
||||
* its alignment and scrolls sideways instead of folding. A web-card call — a
|
||||
* `web_search`/`web_fetch` result — renders through WebBlock at its own full
|
||||
* source-list allowance. Every other call, and a running call with no card
|
||||
* yet, keeps the flattened text form.
|
||||
* @param props.material - the selected call's material from {@link materialFor}.
|
||||
* @param props.cwd - the session workspace root, resolving the terminal view's cwd.
|
||||
* @returns the Output section's body element.
|
||||
@@ -147,6 +150,10 @@ function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | u
|
||||
</>
|
||||
)
|
||||
}
|
||||
const web = webCardModel(material.block)
|
||||
// Full source-list allowance here (the panel is the single-call reading
|
||||
// surface); the chat rows cap it at CHAT_WEB_MAX_SOURCES.
|
||||
if (web !== null) return <WebBlock {...web} className={css.web} />
|
||||
// A settled call always carries the result node the flattened form needs;
|
||||
// the running shape has no result to flatten.
|
||||
if (!('kind' in material.block)) return <div className={css.empty}>运行中…</div>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/* Web toolview: same geometry/tokens as ToolRow (figma icon · summary), plus
|
||||
the web card the row stacks under its summary line, mirroring the bash row's
|
||||
resident terminal card. */
|
||||
|
||||
/* Summary line over the web card; the summary row keeps its own 24px height,
|
||||
so the card is a column around it rather than a change to it. */
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
|
||||
and replaces the primitive's standalone vertical margin with the flow's. */
|
||||
.web {
|
||||
margin: 4px 0 4px 22px;
|
||||
}
|
||||
|
||||
.root {
|
||||
position: relative; /* sweep-glare overlay anchor */
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow. */
|
||||
.root[data-state='running']::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 300px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: dsh-web-row-sweep 2.6s ease-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes dsh-web-row-sweep {
|
||||
0% { left: -300px; }
|
||||
90%, 100% { left: 100%; }
|
||||
}
|
||||
|
||||
.leading {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 6px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.sep {
|
||||
flex: none;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
margin: 0 8px;
|
||||
background: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.summary {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.visuallyHidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Web toolview registrant: third-party posture over the keyed toolview hole
|
||||
// (ctx.slots.register + ToolRowProps only — never imports the chat domain).
|
||||
// Registered under BOTH web_search and web_fetch, since both declare the one
|
||||
// `web` render intent and render through the one WebBlock family; the row
|
||||
// discriminates on the toolName only to pick its icon and title.
|
||||
//
|
||||
// A web tool declares the `web` render intent at result time, so this row
|
||||
// renders the completed retrieval through WebBlock resident below its summary,
|
||||
// the same posture BashRow uses for the terminal card: no expand control on the
|
||||
// row itself, not a details-panel target, and the block's own expander keeps a
|
||||
// long source list from taking over the message flow (CHAT_WEB_MAX_SOURCES is
|
||||
// passed as maxSources — the chat flow's tighter cap over the block's default
|
||||
// of 16). Until the call settles there is no web card (the tools keep a generic
|
||||
// pending view), so a running row is the summary line alone.
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { IconBrowseOutline16, IconSearchOutline16, StateDot, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolRowProps } from '../contract/slots.ts'
|
||||
import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../contract/web-card-model.ts'
|
||||
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
|
||||
import css from './web-row.module.css'
|
||||
|
||||
/** web_fetch reads one URL; web_search queries. Titles are figma literals. */
|
||||
const WEB_TITLES: Record<string, string> = {
|
||||
web_search: 'Search',
|
||||
web_fetch: 'Fetch',
|
||||
}
|
||||
|
||||
/** Leading icon per tool, yielding to the state semantic while failed/stopped. */
|
||||
function leadingFor(toolName: string, state: ToolRowState) {
|
||||
switch (state) {
|
||||
case 'error': return <StateDot state="error" />
|
||||
case 'stopped': return <StateDot state="warning" />
|
||||
// Running keeps the icon — the row sweep carries the in-flight signal.
|
||||
default: return toolName === 'web_fetch' ? <IconBrowseOutline16 size={14} /> : <IconSearchOutline16 size={14} />
|
||||
}
|
||||
}
|
||||
|
||||
/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */
|
||||
function stateStatus(state: ToolRowState): string | null {
|
||||
switch (state) {
|
||||
case 'running': return '运行中'
|
||||
case 'error': return '失败'
|
||||
case 'stopped': return '已停止'
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Web row: icon + Search/Fetch · {summary} in the shared ToolRow chrome, with
|
||||
* the completed retrieval's web card resident below it. The summary row is not
|
||||
* a details-panel control (tool rows stopped being one), so the card's own
|
||||
* links and expander are the row's only interactions.
|
||||
*/
|
||||
export function WebRow({ toolName, block }: ToolRowProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
const web = webCardModel(block)
|
||||
const status = stateStatus(model.state)
|
||||
return (
|
||||
<div className={css.card}>
|
||||
<div className={css.root} data-variant="web" data-tool={toolName} data-state={model.state}>
|
||||
<span className={css.leading}>{leadingFor(toolName, model.state)}</span>
|
||||
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
|
||||
<span className={css.title}>{WEB_TITLES[toolName] ?? model.title}</span>
|
||||
<span className={css.sep} aria-hidden />
|
||||
<span className={css.summary}>{model.summary}</span>
|
||||
</div>
|
||||
{web !== null && (
|
||||
<WebBlock
|
||||
{...(web.kind === 'search' ? { ...web, maxSources: CHAT_WEB_MAX_SOURCES } : web)}
|
||||
className={css.web}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The web rows as a plain registrant plugin, riding the same load-order seam as
|
||||
* the bash sample: `inject: ['conversation']` guarantees the chat entry (and
|
||||
* with it the 'conversation.chat.toolview' declaration) is on the ledger. One
|
||||
* WebRow component registers under both web tool names.
|
||||
*/
|
||||
export const webToolview = {
|
||||
name: 'web-toolview',
|
||||
inject: ['slots', 'conversation'],
|
||||
/**
|
||||
* Register the web row under both web tool names' keyed toolview holes.
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_search' }, WebRow)
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_fetch' }, WebRow)
|
||||
},
|
||||
}
|
||||
@@ -80,12 +80,13 @@ describe('apply wiring', () => {
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('mounts the bash sample and the todo row as keyed entries through the load-order seam', async () => {
|
||||
it('mounts the bash sample, the web rows, and the todo row as keyed entries through the load-order seam', async () => {
|
||||
const b = await bench()
|
||||
// Both registrant plugins' inject: ['slots', 'conversation'] resolved — the
|
||||
// service being present implies the chat entry declared the hole first.
|
||||
// Every registrant plugin's inject: ['slots', 'conversation'] resolved — the
|
||||
// service being present implies the chat entry declared the hole first. The
|
||||
// web rows register one component under both web tool names.
|
||||
const entries = b.slots.entries('conversation.chat.toolview')
|
||||
expect(entries.map(e => e.options.key)).toEqual(['bash', 'todo_write'])
|
||||
expect(entries.map(e => e.options.key)).toEqual(['bash', 'web_search', 'web_fetch', 'todo_write'])
|
||||
// Stats stick with the composer (not inside ChatView).
|
||||
expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats'])
|
||||
await b.runtime.dispose()
|
||||
|
||||
255
packages/client/ui-conversation/tests/web-card.spec.tsx
Normal file
255
packages/client/ui-conversation/tests/web-card.spec.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
// @vitest-environment jsdom
|
||||
// The web render intent on the web side: the pure webCardModel derivation over
|
||||
// resultView, and the conversation render sites that consume it — the keyed
|
||||
// WebRow (registered under both web_search and web_fetch), the GenericToolCard
|
||||
// render-site fallback, and the details panel's Output section. Mirrors
|
||||
// terminal-card.spec.tsx: model derivation + null arms, both kinds, the chat
|
||||
// row's resident card, the panel arm, and the keyed registration.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { SelectionTarget, ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../src/client/contract/web-card-model.ts'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
|
||||
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
|
||||
import { WebRow, webToolview } from '../src/client/toolviews/web-row.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
const SEARCH_ARGS = '{"query":"deepseek harness"}'
|
||||
const FETCH_ARGS = '{"url":"https://example.com/page"}'
|
||||
|
||||
/** A web_search result view; overrides tune the sources / answer / truncation. */
|
||||
const resultSearch = (over?: Partial<Extract<ToolResultView, { card: 'web'; kind: 'search' }>>): ToolResultView => ({
|
||||
card: 'web', kind: 'search', truncated: false,
|
||||
answer: 'A short answer.',
|
||||
sources: [
|
||||
{ url: 'https://example.com/a', title: 'Titled', snippet: 'excerpt', publishedAt: '2026-07-01' },
|
||||
{ url: 'https://plain.example.org/b' },
|
||||
],
|
||||
...over,
|
||||
})
|
||||
|
||||
/** A web_fetch result view. */
|
||||
const resultFetch = (over?: Partial<Extract<ToolResultView, { card: 'web'; kind: 'fetch' }>>): ToolResultView => ({
|
||||
card: 'web', kind: 'fetch', url: 'https://example.com/page', statusCode: 200, truncated: false, ...over,
|
||||
})
|
||||
|
||||
const runningSearch = (over?: Partial<RunningToolCall>): RunningToolCall => ({
|
||||
callId: 'c1', name: 'web_search', argsRaw: SEARCH_ARGS,
|
||||
turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Search', kind: 'search' }, ...over,
|
||||
})
|
||||
|
||||
const settledSearch = (over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
|
||||
call: { name: 'web_search', argsRaw: SEARCH_ARGS },
|
||||
callTime: 1_000,
|
||||
content: [{ type: 'text', text: 'search text' }], isError: false,
|
||||
callView: { card: 'generic', title: 'Search', kind: 'search' }, resultView: resultSearch(), ...over,
|
||||
})
|
||||
|
||||
const settledFetch = (over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
kind: 'tool-result', seq: 11, time: 2_000, callId: 'c2',
|
||||
call: { name: 'web_fetch', argsRaw: FETCH_ARGS },
|
||||
callTime: 1_000,
|
||||
content: [{ type: 'text', text: 'fetch body' }], isError: false,
|
||||
callView: { card: 'generic', title: 'Fetch', kind: 'fetch' }, resultView: resultFetch(), ...over,
|
||||
})
|
||||
|
||||
describe('webCardModel', () => {
|
||||
it('derives a search card from the result view, projecting every source field', () => {
|
||||
expect(webCardModel(settledSearch())).toEqual({
|
||||
kind: 'search',
|
||||
answer: 'A short answer.',
|
||||
truncated: false,
|
||||
sources: [
|
||||
{ url: 'https://example.com/a', title: 'Titled', snippet: 'excerpt', publishedAt: '2026-07-01' },
|
||||
{ url: 'https://plain.example.org/b', title: undefined, snippet: undefined, publishedAt: undefined },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('carries the search truncation flag and an absent answer', () => {
|
||||
const model = webCardModel(settledSearch({ resultView: { card: 'web', kind: 'search', truncated: true, sources: [] } }))
|
||||
expect(model).toEqual({ kind: 'search', answer: undefined, truncated: true, sources: [] })
|
||||
})
|
||||
|
||||
it('derives a fetch card from the result view', () => {
|
||||
expect(webCardModel(settledFetch())).toEqual({
|
||||
kind: 'fetch', url: 'https://example.com/page', statusCode: 200, truncated: false,
|
||||
})
|
||||
expect(webCardModel(settledFetch({ resultView: resultFetch({ statusCode: 404, truncated: true }) })))
|
||||
.toEqual({ kind: 'fetch', url: 'https://example.com/page', statusCode: 404, truncated: true })
|
||||
})
|
||||
|
||||
it('returns null for a running call, since the web card is result-only', () => {
|
||||
expect(webCardModel(runningSearch())).toBeNull()
|
||||
// Even a running call that somehow carried a web call view stays generic:
|
||||
// the derivation reads resultView only.
|
||||
expect(webCardModel(runningSearch({ callView: null }))).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a settled call whose result view is not a web card', () => {
|
||||
expect(webCardModel(settledSearch({ resultView: null }))).toBeNull()
|
||||
expect(webCardModel(settledSearch({ resultView: { card: 'generic' } }))).toBeNull()
|
||||
// A card tag this UI version does not know arrives over the wire; the
|
||||
// documented generic-card default takes it, not a crash.
|
||||
const future = { card: 'chart', kind: 'search' } as unknown as ToolResultView
|
||||
expect(webCardModel(settledSearch({ resultView: future }))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('chat row web body', () => {
|
||||
const ownerProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolRowOwnerProps => ({
|
||||
callId: block.callId, toolName, block, openFile: vi.fn(),
|
||||
})
|
||||
// WebRow reads only toolName/block off the full runtime share; the standard
|
||||
// kit is unused, so the cast supplies the owner slice alone (as BashRow's
|
||||
// tests do for the terminal card).
|
||||
const rowProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolRowProps =>
|
||||
ownerProps(block, toolName) as unknown as ToolRowProps
|
||||
|
||||
it('the WebRow renders the search card resident under the summary, capped tighter than the panel', () => {
|
||||
expect(CHAT_WEB_MAX_SOURCES).toBeLessThan(16)
|
||||
const view = render(<WebRow {...rowProps(settledSearch(), 'web_search')} />)
|
||||
// The summary row plus the resident card, without any expand gesture on the row itself.
|
||||
expect(view.getByText('Search')).toBeTruthy()
|
||||
expect(view.getByText('Titled')).toBeTruthy()
|
||||
expect(view.getByText('excerpt')).toBeTruthy()
|
||||
// hostname fallback for the source with no title
|
||||
expect(view.getByText('plain.example.org')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('the WebRow renders the fetch card resident, titled Fetch', () => {
|
||||
const view = render(<WebRow {...rowProps(settledFetch(), 'web_fetch')} />)
|
||||
expect(view.getByText('Fetch')).toBeTruthy()
|
||||
// The url shows in the summary row and as the card's link; scope to the card.
|
||||
const card = view.container.querySelector('[data-web="fetch"]')
|
||||
expect(card?.querySelector('a')?.getAttribute('href')).toBe('https://example.com/page')
|
||||
expect(view.getByText('HTTP 200')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a running web call is the summary row alone (no card until it settles)', () => {
|
||||
const view = render(<WebRow {...rowProps(runningSearch(), 'web_search')} />)
|
||||
expect(view.getByText('Search')).toBeTruthy()
|
||||
expect(view.queryByText('Titled')).toBeNull()
|
||||
expect(view.container.querySelector('[data-web]')).toBeNull()
|
||||
})
|
||||
|
||||
it('a failed web call keeps the summary row without the card', () => {
|
||||
const view = render(<WebRow {...rowProps(settledSearch({
|
||||
isError: true, resultView: { card: 'generic' },
|
||||
}), 'web_search')} />)
|
||||
expect(view.getByText('Search')).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-web]')).toBeNull()
|
||||
// The row reflects the error state so the summary line still reads as failed.
|
||||
expect(view.container.querySelector('[data-state="error"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('the GenericToolCard fallback also renders a resident web card for a web-declaring tool', () => {
|
||||
// A web-declaring tool without its own keyed row lands on the fallback; its
|
||||
// card is resident there too.
|
||||
const view = render(<GenericToolCard {...ownerProps(settledSearch({
|
||||
call: { name: 'fx-web', argsRaw: SEARCH_ARGS },
|
||||
}), 'fx-web')} />)
|
||||
expect(view.getByText('Titled')).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-web="search"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('the GenericToolCard fallback keeps the plain row for a non-web call', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(settledSearch({
|
||||
call: { name: 'echo', argsRaw: '{}' }, callView: null, resultView: null,
|
||||
}), 'echo')} />)
|
||||
expect(view.container.querySelector('[data-web]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DetailsPanel web Output section', () => {
|
||||
function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null) {
|
||||
localStorage.clear()
|
||||
const chat = createChatStore().create()
|
||||
if (selection !== null) chat.actions.select(selection)
|
||||
const sessions = createSnapshotStore<SessionListState>({ ids: [], byId: {}, current: undefined, phase: 'ready' })
|
||||
const workspaces = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})
|
||||
return render(
|
||||
<DetailsPanel
|
||||
sessionId={SID}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })}
|
||||
useSessions={bindSnapshotSelector(sessions)}
|
||||
useWorkspaces={bindSnapshotSelector(workspaces)}
|
||||
useInput={(() => { throw new Error('unused') })}
|
||||
inputActions={{ setDraft: () => {}, submit: () => {} }}
|
||||
useProjection={(() => undefined)}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, lastAgentError: null, ...over,
|
||||
}
|
||||
}
|
||||
|
||||
it('renders the search card at full source allowance', () => {
|
||||
const view = mount(snapshot({ nodes: [settledSearch()] }), { turnSeq: 10, callId: 'c1', toolName: 'web_search' })
|
||||
expect(view.getByText('Titled')).toBeTruthy()
|
||||
expect(view.getByText('excerpt')).toBeTruthy()
|
||||
// The Input JSON section survives beside it.
|
||||
expect(view.getByText(/"query"/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the fetch card', () => {
|
||||
const view = mount(snapshot({ nodes: [settledFetch()] }), { turnSeq: 11, callId: 'c2', toolName: 'web_fetch' })
|
||||
const card = view.container.querySelector('[data-web="fetch"]')
|
||||
expect(card?.querySelector('a')?.getAttribute('href')).toBe('https://example.com/page')
|
||||
expect(view.getByText('HTTP 200')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a non-web result keeps the flattened pre form', () => {
|
||||
const view = mount(snapshot({
|
||||
nodes: [settledSearch({ callView: null, resultView: null })],
|
||||
}), { turnSeq: 10, callId: 'c1', toolName: 'web_search' })
|
||||
expect(view.container.querySelector('[data-web]')).toBeNull()
|
||||
const output = view.getByText('Output').closest('section')
|
||||
expect(output?.querySelector('pre')?.textContent).toContain('search text')
|
||||
})
|
||||
})
|
||||
|
||||
describe('web toolview registration', () => {
|
||||
it('registers one WebRow under both web_search and web_fetch', () => {
|
||||
const registered: { key: string; component: unknown }[] = []
|
||||
const ctx = {
|
||||
slots: {
|
||||
register: (options: { name: string; key: string }, component: unknown) => {
|
||||
registered.push({ key: options.key, component })
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
} as unknown as import('cordis').Context
|
||||
webToolview.apply(ctx)
|
||||
expect(registered.map(r => r.key)).toEqual(['web_search', 'web_fetch'])
|
||||
// One component under both keys, not two thin rows.
|
||||
expect(registered[0]?.component).toBe(WebRow)
|
||||
expect(registered[1]?.component).toBe(WebRow)
|
||||
// The load-order seam the render site depends on.
|
||||
expect(webToolview.inject).toEqual(['slots', 'conversation'])
|
||||
})
|
||||
})
|
||||
124
packages/client/ui-primitives/src/WebBlock.module.css
Normal file
124
packages/client/ui-primitives/src/WebBlock.module.css
Normal file
@@ -0,0 +1,124 @@
|
||||
/* Geometry mirrors CodeBlock/TerminalBlock (12px radius, code-block surface,
|
||||
16px vertical margin) so a web card, a terminal card, and a fenced code block
|
||||
read as one family. A source list is prose, not aligned output, so it wraps
|
||||
normally rather than scrolling horizontally like a terminal card's output. */
|
||||
|
||||
.block {
|
||||
--dsl-web-radius: 12px;
|
||||
|
||||
margin: 16px 0;
|
||||
padding: 12px 14px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
border-radius: var(--dsl-web-radius);
|
||||
}
|
||||
|
||||
/* The provider answer reads as body prose above the citation list; its own
|
||||
MarkdownText margins are trimmed so the list sits tight under it. */
|
||||
.answer {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.answer > :global(div) > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.answer > :global(div) > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* The citation list: ordered so each source reads as a numbered reference. */
|
||||
.sources {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.source {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sourceLink {
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.sourceLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.snippet {
|
||||
margin-top: 2px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 19px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.published {
|
||||
margin-top: 2px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
.expand {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.expand:hover {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.truncated {
|
||||
margin-top: 8px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
/* The fetch card is a compact summary: the URL over a status/truncation row. */
|
||||
.fetch {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.fetchUrl {
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 13px;
|
||||
line-height: 19px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.fetchUrl:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.fetchMeta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.status {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
/* The fetch card's truncation note sits inline beside the status, so it drops
|
||||
the search card's top margin. */
|
||||
.fetch .truncated {
|
||||
margin-top: 0;
|
||||
}
|
||||
209
packages/client/ui-primitives/src/WebBlock.tsx
Normal file
209
packages/client/ui-primitives/src/WebBlock.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
// WebBlock: the surface for a completed web retrieval. One component draws both
|
||||
// kinds of the `web` render intent, discriminated by `kind`: a `search` shows an
|
||||
// optional provider answer above a citation list of sources (each a safe
|
||||
// external link labelled by its title, or its hostname when the provider gave
|
||||
// none, with the snippet and publication date below it), and a `fetch` shows a
|
||||
// compact retrieval summary (the linked final URL and its HTTP status). Both
|
||||
// mark a capped retrieval. Every link is a same-origin-safe external anchor:
|
||||
// only http(s) URLs become anchors (target/rel set), the same protocol allowlist
|
||||
// MarkdownText applies to untrusted assistant-authored links; an unparseable or
|
||||
// non-http URL renders as plain text. Geometry, radius, and fonts mirror
|
||||
// CodeBlock/TerminalBlock so a web card reads as one family with them; a long
|
||||
// source list caps at maxSources with a head/tail collapse using the same
|
||||
// arithmetic as TerminalBlock's output cap.
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { MarkdownText } from './markdown/MarkdownText.tsx'
|
||||
import css from './WebBlock.module.css'
|
||||
|
||||
/**
|
||||
* Sources shown before the height cap collapses the middle of a citation list.
|
||||
* Matches TerminalBlock's default output budget so both cards cut a long body
|
||||
* at the same place; the chat row narrows it through the maxSources prop.
|
||||
*/
|
||||
export const DEFAULT_WEB_MAX_SOURCES = 16
|
||||
|
||||
/**
|
||||
* One citeable source drawn in a search card: the projection of the contract's
|
||||
* `WebSource`, with the optional fields kept optional so a provider that
|
||||
* returned only a URL still renders (its hostname becomes the label).
|
||||
*/
|
||||
export interface WebSourceView {
|
||||
/** The source URL; becomes a safe external link when it is http(s). */
|
||||
url: string
|
||||
/** The source title; when absent the URL's hostname labels the link. */
|
||||
title?: string | undefined
|
||||
/** A short excerpt or summary shown under the link. */
|
||||
snippet?: string | undefined
|
||||
/** Publication/crawl timestamp, a provider-supplied string shown under the link. */
|
||||
publishedAt?: string | undefined
|
||||
}
|
||||
|
||||
/** A `web_search` card: an optional answer over a capped citation list. */
|
||||
export interface WebSearchBlockProps {
|
||||
kind: 'search'
|
||||
/** The provider-generated answer, rendered as markdown above the sources. */
|
||||
answer?: string | undefined
|
||||
/** The cited sources, in provider order. */
|
||||
sources: WebSourceView[]
|
||||
/** True when the tool cut the source list to its result cap. */
|
||||
truncated: boolean
|
||||
/** Sources shown before the middle collapses (default {@link DEFAULT_WEB_MAX_SOURCES}). */
|
||||
maxSources?: number | undefined
|
||||
/** Extra class merged onto the wrapper (callers position; this component draws). */
|
||||
className?: string | undefined
|
||||
}
|
||||
|
||||
/** A `web_fetch` card: the retrieval summary for one fetched URL. */
|
||||
export interface WebFetchBlockProps {
|
||||
kind: 'fetch'
|
||||
/** The final URL after allowed redirects; becomes a safe external link when http(s). */
|
||||
url: string
|
||||
/** HTTP status code of the fetched response. */
|
||||
statusCode: number
|
||||
/** True when the provider or the output cap cut the fetched content. */
|
||||
truncated: boolean
|
||||
/** Extra class merged onto the wrapper (callers position; this component draws). */
|
||||
className?: string | undefined
|
||||
}
|
||||
|
||||
/** A completed web retrieval card, discriminated by `kind`. */
|
||||
export type WebBlockProps = WebSearchBlockProps | WebFetchBlockProps
|
||||
|
||||
/**
|
||||
* The URL to link to, or undefined when the URL must render as plain text. The
|
||||
* allowlist is MarkdownText's own for untrusted links: only http(s) becomes a
|
||||
* navigable external anchor, so a `javascript:`/`data:`/`file:` URL or an
|
||||
* unparseable string never reaches the DOM as an href.
|
||||
* @param url - the source or fetch URL, from tool result content.
|
||||
* @returns the href to use, or undefined for plain text.
|
||||
*/
|
||||
function safeHref(url: string): string | undefined {
|
||||
try {
|
||||
const { protocol } = new URL(url)
|
||||
return protocol === 'http:' || protocol === 'https:' ? url : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The link's visible label: the title when the provider gave one, otherwise the
|
||||
* URL's hostname, falling back to the raw URL when it does not parse.
|
||||
* @param url - the source URL.
|
||||
* @param title - the provider title, if any.
|
||||
* @returns the label text.
|
||||
*/
|
||||
function linkLabel(url: string, title: string | undefined): string {
|
||||
if (title !== undefined && title !== '') return title
|
||||
try {
|
||||
return new URL(url).hostname
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A single URL rendered as a safe external anchor, or as plain text when the
|
||||
* URL is not an http(s) link.
|
||||
* @param props.url - the URL to render.
|
||||
* @param props.label - the visible label.
|
||||
* @param props.className - class for the anchor or the plain span.
|
||||
* @returns the anchor or span element.
|
||||
*/
|
||||
function SafeLink({ url, label, className }: { url: string; label: string; className?: string | undefined }) {
|
||||
const href = safeHref(url)
|
||||
if (href === undefined) return <span className={className}>{label}</span>
|
||||
return (
|
||||
<a className={className} href={href} target="_blank" rel="noopener noreferrer">
|
||||
{label}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One source row in a search card: the safe link plus its snippet and date.
|
||||
* @param props.source - the source to render.
|
||||
* @returns the source list item.
|
||||
*/
|
||||
function SourceItem({ source }: { source: WebSourceView }) {
|
||||
return (
|
||||
<li className={css.source}>
|
||||
<SafeLink url={source.url} label={linkLabel(source.url, source.title)} className={css.sourceLink} />
|
||||
{source.snippet !== undefined && source.snippet !== '' && (
|
||||
<div className={css.snippet}>{source.snippet}</div>
|
||||
)}
|
||||
{source.publishedAt !== undefined && source.publishedAt !== '' && (
|
||||
<div className={css.published}>{source.publishedAt}</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The search card body: the answer over the capped source list.
|
||||
* @param props - see {@link WebSearchBlockProps}.
|
||||
* @returns the search card element.
|
||||
*/
|
||||
function WebSearchBlock({ answer, sources, truncated, maxSources = DEFAULT_WEB_MAX_SOURCES, className }: WebSearchBlockProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
|
||||
const hidden = sources.length - maxSources
|
||||
const capped = hidden > 0 && !expanded
|
||||
// Same split arithmetic as TerminalBlock's output cap, so a long body's head
|
||||
// and tail slices agree between the two cards.
|
||||
const headCount = Math.ceil(maxSources / 2)
|
||||
const tailCount = maxSources - headCount
|
||||
const head = capped ? sources.slice(0, headCount) : sources
|
||||
const tail = capped ? sources.slice(sources.length - tailCount) : []
|
||||
return (
|
||||
<div className={clsx(css.block, className)} data-web="search">
|
||||
{answer !== undefined && answer !== '' && (
|
||||
<div className={css.answer}><MarkdownText text={answer} /></div>
|
||||
)}
|
||||
<ol className={css.sources}>
|
||||
{head.map((source, index) => <SourceItem key={index} source={source} />)}
|
||||
{hidden > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.expand}
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? '收起来源' : `展开其余 ${hidden} 条来源`}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{expanded ? '收起' : `… 其余 ${hidden} 条来源`}
|
||||
</button>
|
||||
)}
|
||||
{tail.map((source, index) => <SourceItem key={sources.length - tailCount + index} source={source} />)}
|
||||
</ol>
|
||||
{truncated && <div className={css.truncated}>来源列表已截断</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The fetch card body: the linked URL and its HTTP status.
|
||||
* @param props - see {@link WebFetchBlockProps}.
|
||||
* @returns the fetch card element.
|
||||
*/
|
||||
function WebFetchBlock({ url, statusCode, truncated, className }: WebFetchBlockProps) {
|
||||
return (
|
||||
<div className={clsx(css.block, css.fetch, className)} data-web="fetch">
|
||||
<SafeLink url={url} label={url} className={css.fetchUrl} />
|
||||
<div className={css.fetchMeta}>
|
||||
<span className={css.status}>HTTP {statusCode}</span>
|
||||
{truncated && <span className={css.truncated}>内容已截断</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a completed web retrieval as a structured card.
|
||||
* @param props - see {@link WebBlockProps}; `kind` selects the search or fetch body.
|
||||
* @returns the web card element.
|
||||
*/
|
||||
export function WebBlock(props: WebBlockProps) {
|
||||
return props.kind === 'search' ? <WebSearchBlock {...props} /> : <WebFetchBlock {...props} />
|
||||
}
|
||||
@@ -22,6 +22,8 @@ export { JsonTree } from './JsonTree.tsx'
|
||||
export type { JsonTreeProps } from './JsonTree.tsx'
|
||||
export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx'
|
||||
export type { TerminalBlockProps } from './TerminalBlock.tsx'
|
||||
export { WebBlock, DEFAULT_WEB_MAX_SOURCES } from './WebBlock.tsx'
|
||||
export type { WebBlockProps, WebSearchBlockProps, WebFetchBlockProps, WebSourceView } from './WebBlock.tsx'
|
||||
export { CodeBlock } from './markdown/CodeBlock.tsx'
|
||||
export { JsonBlock } from './markdown/JsonBlock.tsx'
|
||||
export { MarkdownText } from './markdown/MarkdownText.tsx'
|
||||
|
||||
165
packages/client/ui-primitives/tests/web-block.spec.tsx
Normal file
165
packages/client/ui-primitives/tests/web-block.spec.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
// @vitest-environment jsdom
|
||||
// WebBlock: both kinds of the web card. The search card's answer, its citation
|
||||
// list with the title-or-hostname label fallback and optional snippet/date, the
|
||||
// source-list height cap and its expand control, and the truncated indicator;
|
||||
// the fetch card's linked URL, status, and truncation. Safe-link attributes on
|
||||
// both kinds: an http(s) URL becomes an external anchor (target/rel), any other
|
||||
// URL renders as plain text with no href.
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { DEFAULT_WEB_MAX_SOURCES, WebBlock } from '../src/index.ts'
|
||||
import type { WebSourceView } from '../src/index.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
/** `count` sources with sequential hostnames, so the cap slices read distinctly. */
|
||||
function sources(count: number): WebSourceView[] {
|
||||
return Array.from({ length: count }, (_value, index) => ({
|
||||
url: `https://site-${index}.example.com/page`,
|
||||
title: `Source ${index}`,
|
||||
}))
|
||||
}
|
||||
|
||||
describe('WebBlock search card', () => {
|
||||
it('renders the answer above the citation list', () => {
|
||||
const view = render(<WebBlock kind="search" answer="**Answer** text" sources={sources(2)} truncated={false} />)
|
||||
expect(view.getByText('Answer')).toBeTruthy()
|
||||
expect(view.getByText('Source 0')).toBeTruthy()
|
||||
expect(view.getByText('Source 1')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('omits the answer block when there is no answer', () => {
|
||||
const view = render(<WebBlock kind="search" sources={sources(1)} truncated={false} />)
|
||||
expect(view.container.querySelector('[class^="_answer_"]')).toBeNull()
|
||||
const empty = render(<WebBlock kind="search" answer="" sources={sources(1)} truncated={false} />)
|
||||
expect(empty.container.querySelector('[class^="_answer_"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('labels a source by its title, and by hostname when the title is absent', () => {
|
||||
const view = render(<WebBlock kind="search" truncated={false} sources={[
|
||||
{ url: 'https://example.com/a', title: 'Titled' },
|
||||
{ url: 'https://plain.example.org/b' },
|
||||
{ url: 'https://empty.example.net/c', title: '' },
|
||||
]} />)
|
||||
expect(view.getByText('Titled')).toBeTruthy()
|
||||
// No title / empty title: the hostname labels the link.
|
||||
expect(view.getByText('plain.example.org')).toBeTruthy()
|
||||
expect(view.getByText('empty.example.net')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders a source as a safe external anchor for an http(s) url', () => {
|
||||
const view = render(<WebBlock kind="search" truncated={false} sources={[
|
||||
{ url: 'https://example.com/a', title: 'Titled' },
|
||||
]} />)
|
||||
const anchor = view.getByText('Titled') as HTMLAnchorElement
|
||||
expect(anchor.tagName).toBe('A')
|
||||
expect(anchor.getAttribute('href')).toBe('https://example.com/a')
|
||||
expect(anchor.getAttribute('target')).toBe('_blank')
|
||||
expect(anchor.getAttribute('rel')).toBe('noopener noreferrer')
|
||||
})
|
||||
|
||||
it('renders a non-http url as plain text with no href, and its raw text label when unparseable', () => {
|
||||
const view = render(<WebBlock kind="search" truncated={false} sources={[
|
||||
{ url: 'javascript:alert(1)', title: 'Dangerous' },
|
||||
{ url: 'not a url' },
|
||||
]} />)
|
||||
const unsafe = view.getByText('Dangerous')
|
||||
expect(unsafe.tagName).toBe('SPAN')
|
||||
expect(unsafe.getAttribute('href')).toBeNull()
|
||||
// An unparseable url is not a link and cannot yield a hostname, so its raw
|
||||
// text is the label.
|
||||
const raw = view.getByText('not a url')
|
||||
expect(raw.tagName).toBe('SPAN')
|
||||
})
|
||||
|
||||
it('shows a source snippet and publication date when present, and omits them when absent or empty', () => {
|
||||
const view = render(<WebBlock kind="search" truncated={false} sources={[
|
||||
{ url: 'https://a.example.com', title: 'A', snippet: 'excerpt', publishedAt: '2026-07-01' },
|
||||
{ url: 'https://b.example.com', title: 'B', snippet: '', publishedAt: '' },
|
||||
{ url: 'https://c.example.com', title: 'C' },
|
||||
]} />)
|
||||
expect(view.getByText('excerpt')).toBeTruthy()
|
||||
expect(view.getByText('2026-07-01')).toBeTruthy()
|
||||
// The empty-string and absent arms both draw nothing beyond the link.
|
||||
expect(view.container.querySelectorAll('[class^="_snippet_"]')).toHaveLength(1)
|
||||
expect(view.container.querySelectorAll('[class^="_published_"]')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows the truncated indicator only when the list was capped by the tool', () => {
|
||||
const on = render(<WebBlock kind="search" sources={sources(1)} truncated />)
|
||||
expect(on.getByText('来源列表已截断')).toBeTruthy()
|
||||
cleanup()
|
||||
const off = render(<WebBlock kind="search" sources={sources(1)} truncated={false} />)
|
||||
expect(off.queryByText('来源列表已截断')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders every source and no expand control under the cap', () => {
|
||||
const view = render(<WebBlock kind="search" sources={sources(4)} truncated={false} maxSources={4} />)
|
||||
expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(4)
|
||||
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
|
||||
})
|
||||
|
||||
it('slices head and tail over the cap and expands on click', () => {
|
||||
const view = render(<WebBlock kind="search" sources={sources(10)} truncated={false} maxSources={4} />)
|
||||
// maxSources 4: head = ceil(4/2) = 2, tail = 4 - 2 = 2, 6 hidden.
|
||||
expect([...view.container.querySelectorAll('[class^="_sourceLink_"]')].map(n => n.textContent))
|
||||
.toEqual(['Source 0', 'Source 1', 'Source 8', 'Source 9'])
|
||||
const toggle = view.getByRole('button', { name: '展开其余 6 条来源' })
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(toggle.textContent).toBe('… 其余 6 条来源')
|
||||
|
||||
fireEvent.click(toggle)
|
||||
expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(10)
|
||||
const collapse = view.getByRole('button', { name: '收起来源' })
|
||||
expect(collapse.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(collapse.textContent).toBe('收起')
|
||||
|
||||
fireEvent.click(collapse)
|
||||
expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('renders the head slice alone when the cap leaves no tail', () => {
|
||||
const view = render(<WebBlock kind="search" sources={sources(5)} truncated={false} maxSources={1} />)
|
||||
expect([...view.container.querySelectorAll('[class^="_sourceLink_"]')].map(n => n.textContent)).toEqual(['Source 0'])
|
||||
expect(view.getByRole('button', { name: '展开其余 4 条来源' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('caps at the documented default when maxSources is absent', () => {
|
||||
const view = render(<WebBlock kind="search" sources={sources(DEFAULT_WEB_MAX_SOURCES + 1)} truncated={false} />)
|
||||
expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(DEFAULT_WEB_MAX_SOURCES)
|
||||
expect(view.getByRole('button', { name: '展开其余 1 条来源' })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('WebBlock fetch card', () => {
|
||||
it('renders the fetched url as a safe external anchor and its HTTP status', () => {
|
||||
const view = render(<WebBlock kind="fetch" url="https://example.com/page" statusCode={200} truncated={false} />)
|
||||
const anchor = view.getByText('https://example.com/page') as HTMLAnchorElement
|
||||
expect(anchor.tagName).toBe('A')
|
||||
expect(anchor.getAttribute('href')).toBe('https://example.com/page')
|
||||
expect(anchor.getAttribute('target')).toBe('_blank')
|
||||
expect(anchor.getAttribute('rel')).toBe('noopener noreferrer')
|
||||
expect(view.getByText('HTTP 200')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders a non-http fetch url as plain text with no href', () => {
|
||||
const view = render(<WebBlock kind="fetch" url="file:///etc/passwd" statusCode={200} truncated={false} />)
|
||||
const label = view.getByText('file:///etc/passwd')
|
||||
expect(label.tagName).toBe('SPAN')
|
||||
expect(label.getAttribute('href')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the truncated indicator only when the content was cut', () => {
|
||||
const on = render(<WebBlock kind="fetch" url="https://example.com" statusCode={200} truncated />)
|
||||
expect(on.getByText('内容已截断')).toBeTruthy()
|
||||
cleanup()
|
||||
const off = render(<WebBlock kind="fetch" url="https://example.com" statusCode={200} truncated={false} />)
|
||||
expect(off.queryByText('内容已截断')).toBeNull()
|
||||
})
|
||||
|
||||
it('carries a non-200 status verbatim', () => {
|
||||
const view = render(<WebBlock kind="fetch" url="https://example.com/missing" statusCode={404} truncated={false} />)
|
||||
expect(view.getByText('HTTP 404')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user