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:
Chinesezjc
2026-07-30 17:43:13 +08:00
parent ba0757223d
commit f6802ee019
18 changed files with 1256 additions and 10 deletions
@@ -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)
},
}