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

View File

@@ -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)

View File

@@ -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;
}

View File

@@ -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>
)
}

View File

@@ -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,
}
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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)
},
}

View File

@@ -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()

View 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'])
})
})