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:
@@ -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)
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user