Merge remote-tracking branch 'origin/master' into feat/web-diff-card
# Conflicts: # apps/web/tests/built-boot.snapshot.ts # packages/client/connection/src/client/fixture.ts # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/src/client/apply.ts # packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx # packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx # packages/client/ui-conversation/tests/chat-apply.spec.tsx # packages/client/ui-primitives/README.i18n.yaml # packages/client/ui-primitives/README.md # packages/client/ui-primitives/README.zh.md # packages/client/ui-primitives/src/index.ts
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
||||
README.md: 159aecca8a741195ee7272c4f788a5aefbe1507d
|
||||
README.zh.md: 401a7ea1639895d0647fa17b2b44cea26a669abe
|
||||
README.md: d8f369231de1341450049b2f0cf1acc01d42b9ad
|
||||
README.zh.md: 49275390c30993e659afdda6aa14ef196b7fa859
|
||||
|
||||
@@ -14,7 +14,9 @@ Logged non-user messages render as a default-collapsed `上下文注入` disclos
|
||||
|
||||
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
|
||||
|
||||
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed for this intent alone; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
|
||||
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
|
||||
|
||||
A tool call declaring the `web` render intent renders its web retrieval inline, at both conversation render sites, through ui-primitives' `WebBlock`. `contract/web-card-model.ts` is the single derivation from the snapshot's `resultView`, mirroring the terminal card, so the sites cannot disagree about what a web call shows; it yields null — the generic path — for a running call, a non-web result view, a generic result view, a `card` tag this client version does not know, or a web card whose `kind` this client version does not know (a newer host's value, which the wire cannot be trusted to be `search` or `fetch`). The keyed `WebRow` registers one component under both `web_search` and `web_fetch`, discriminating on the tool name only for its icon and title; a web-declaring tool without a keyed row lands on the `GenericToolCard` fallback, which grows the same resident card, and the details panel renders it at the primitive's full source allowance and, below the card, the flattened model-visible result content — a fetch body is readable only there, since its card carries only the URL and status. Rows cap at `CHAT_WEB_MAX_SOURCES` (8) against the panel's 16, the same summary-versus-reading split the terminal card draws ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)).
|
||||
|
||||
A tool call declaring the `diff` render intent (the `write`/`edit` tools) renders its applied change inline through ui-primitives' `DiffBlock`, the same four-layer shape. `contract/diff-card-model.ts` is the single derivation from the `callView`/`resultView` pair; the settled result's hunks replace the call-time diff, and it yields null — the generic path — for any other card tag or a generic result view (write/edit's execution errors). The keyed `FileMutationRow` (registered under both `write` and `edit`) carries the card resident below its summary, whose path link still opens the file through the host; the render-site fallback and the details panel are diff-aware too. Rows cap at `CHAT_DIFF_MAX_LINES` (8) against the panel's 16 ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md)).
|
||||
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
|
||||
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。
|
||||
|
||||
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出只对该意图开放;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
|
||||
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
|
||||
|
||||
声明 `web` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `WebBlock` 内联渲染其 web 检索。`contract/web-card-model.ts` 是从快照的 `resultView` 推导的唯一位置,镜像终端卡片,因此两个渲染点不可能对一次 web 调用的显示产生分歧;对运行中的调用、非 web 的 result view、generic result view、本客户端版本不认识的 `card` 标签,或本客户端版本不认识 `kind` 的 web 卡片(更新的 host 发来的值,wire 上不可信其为 `search` 或 `fetch`),它返回 null,落回通用路径。键控的 `WebRow` 把一个组件注册在 `web_search` 与 `web_fetch` 两个键下,仅根据工具名判别以选取图标与标题;没有自己键控行的 web 声明工具落到 `GenericToolCard` 兜底,它长出同一张常驻卡片,详情面板则以原语的完整 source 额度渲染它,并在卡片下方渲染摊平的模型可见结果内容——fetch 正文只在此处可读,因为其卡片只携带 URL 和状态。行的上限是 `CHAT_WEB_MAX_SOURCES`(8),面板为 16,与终端卡片所画的摘要面对阅读面的同一划分([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md))。
|
||||
|
||||
声明 `diff` 渲染意图的工具调用(`write`/`edit` 工具),通过 ui-primitives 的 `DiffBlock` 内联渲染其已应用的改动,采用同一套四层结构。`contract/diff-card-model.ts` 是从 `callView`/`resultView` 对推导的唯一位置;已结算 result 的 hunk 替换 call 时 diff,对任何其他 card 标签或 generic result view(write/edit 的执行错误)它返回 null,落回通用路径。键控的 `FileMutationRow`(在 `write` 与 `edit` 下都注册)把卡片常驻在摘要之下,其路径链接仍经 host 打开文件;渲染点兜底行与详情面板同样感知 diff。行的上限是 `CHAT_DIFF_MAX_LINES`(8),面板为 16([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md))。
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import { ChatView } from './chat/ChatView.tsx'
|
||||
import { StatsLine } from './chat/StatsLine.tsx'
|
||||
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
|
||||
import { fileMutationToolview } from './toolviews/file-mutation-row.tsx'
|
||||
import { webToolview } from './toolviews/web-row.tsx'
|
||||
import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx'
|
||||
import { todoToolview } from './toolviews/todo-row.tsx'
|
||||
import { askQuestionToolview } from './toolviews/ask-question-row.tsx'
|
||||
@@ -323,6 +324,10 @@ export function apply(ctx: Context): void {
|
||||
// diff render intent, so these rows stack the applied diff card under their
|
||||
// path-link summary (the terminal card's posture, applied to diffs).
|
||||
ctx.plugin(fileMutationToolview)
|
||||
// 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,13 +7,15 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconSparkle16,
|
||||
IconThinkOutline14,
|
||||
IconThinkOutline14, WebBlock,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps, ToolRowOwnerProps } from '../contract/slots.ts'
|
||||
import { diffCardModel } from '../contract/diff-card-model.ts'
|
||||
import { terminalCardModel, terminalFailed } 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> = {
|
||||
@@ -36,13 +38,14 @@ export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }:
|
||||
const model = toolRowModel(toolName, block, cwd)
|
||||
const terminal = terminalCardModel(block, cwd)
|
||||
const diff = diffCardModel(block)
|
||||
const web = webCardModel(block)
|
||||
// A failing exit status is the terminal card's own error signal (the call
|
||||
// itself settles isError:false), surfaced as the row's red state dot.
|
||||
const state = model.state === 'ok' && terminal !== null && terminalFailed(terminal)
|
||||
? 'error'
|
||||
: model.state
|
||||
const singleFile = model.filePath !== undefined
|
||||
return (
|
||||
const row = (
|
||||
<ToolRow
|
||||
t={t}
|
||||
variant={model.variant}
|
||||
@@ -66,4 +69,13 @@ export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }:
|
||||
inspect={inspect}
|
||||
/>
|
||||
)
|
||||
// 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} maxSources={CHAT_WEB_MAX_SOURCES} className={css.web} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 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).
|
||||
* - A web card whose `kind` this UI version does not know (a newer host's
|
||||
* value): the wire cannot be trusted to be `search` or `fetch`, so it takes
|
||||
* the generic path rather than rendering as a malformed fetch.
|
||||
* @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,
|
||||
}
|
||||
}
|
||||
// Discriminate `fetch` explicitly rather than treating it as the else of
|
||||
// `search`: a `kind` this UI version does not know arrives over the wire from
|
||||
// a newer host, and reading it as a fetch would draw an empty URL and
|
||||
// `HTTP undefined`. It takes the generic path, the same wire-boundary default
|
||||
// an unknown `card` tag takes above. The static union narrows `kind` to
|
||||
// `'fetch'` here, but the runtime value is off the wire, so the guard and its
|
||||
// null fallthrough are load-bearing despite the type.
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition
|
||||
if (result.kind === 'fetch') {
|
||||
return {
|
||||
kind: 'fetch',
|
||||
url: result.url,
|
||||
statusCode: result.statusCode,
|
||||
truncated: result.truncated,
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -107,3 +107,9 @@
|
||||
.cardBody {
|
||||
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,12 +7,13 @@
|
||||
// share the store seat exists for) and derives the call material from the
|
||||
// session snapshot — no data of its own.
|
||||
|
||||
import { CodeBlock, DiffBlock, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { CodeBlock, DiffBlock, 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 { diffCardModel } from '../contract/diff-card-model.ts'
|
||||
import { terminalBlockLabels, terminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import { webCardModel } from '../contract/web-card-model.ts'
|
||||
import { resultText, type ToolCallBlock } from '../contract/tool-call-model.ts'
|
||||
import css from './DetailsPanel.module.css'
|
||||
|
||||
@@ -130,8 +131,9 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo
|
||||
* at the primitive's own full height allowance, so column-aligned output keeps
|
||||
* its alignment and scrolls sideways instead of folding. A diff-card call — a
|
||||
* write/edit's applied change — renders through the shared DiffBlock at the same
|
||||
* full height. Every other call, and a running call with neither card yet, keeps
|
||||
* the flattened text form.
|
||||
* full height. 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.
|
||||
* @param props.t - the panel's locale seat, passed down as a plain prop.
|
||||
@@ -153,6 +155,24 @@ function OutputBody({ material, cwd, t }: { material: CallMaterial; cwd: string
|
||||
}
|
||||
const diff = diffCardModel(material.block)
|
||||
if (diff !== null) return <DiffBlock {...diff.card} className={css.cardBody} />
|
||||
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. Below the card the
|
||||
// panel also renders the flattened result content — the model-visible text
|
||||
// the card does not carry verbatim (a web_fetch card shows only the URL and
|
||||
// status, so its fetched body lives only here; a search card's answer and
|
||||
// sources are structured, so the flattened form repeats them as the raw text
|
||||
// the model saw).
|
||||
if (web !== null) {
|
||||
const settled = 'kind' in material.block ? material.block : null
|
||||
const body = settled === null ? '' : resultText(settled)
|
||||
return (
|
||||
<>
|
||||
<WebBlock {...web} className={css.web} />
|
||||
{body !== '' && <pre className={css.code}>{body}</pre>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
// 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}>{t('details.running')}</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,92 @@
|
||||
// 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} maxSources={CHAT_WEB_MAX_SOURCES} 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)
|
||||
},
|
||||
}
|
||||
@@ -84,13 +84,14 @@ describe('apply wiring', () => {
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('mounts the bash sample, the file-mutation rows, and the product rows as keyed entries through the load-order seam', async () => {
|
||||
it('mounts the bash sample, the file-mutation rows, the web rows, and the product rows as keyed entries through the load-order seam', async () => {
|
||||
const b = await bench()
|
||||
// Every registrant plugin's inject: ['slots', 'conversation'] resolved — the
|
||||
// service being present implies the chat entry declared the hole first. The
|
||||
// file-mutation registrant claims both write and edit for the diff card.
|
||||
// file-mutation registrant claims both write and edit for the diff card; 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', 'edit', 'write', 'todo_write', 'ask_user_question'])
|
||||
expect(entries.map(e => e.options.key)).toEqual(['bash', 'edit', 'write', 'web_search', 'web_fetch', 'todo_write', 'ask_user_question'])
|
||||
// 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()
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
// @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'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
/** Locale seat for the card render sites (GenericToolCard, DetailsPanel), as the sibling suites build it. */
|
||||
const t = makeTranslate(zh, commonZh)
|
||||
|
||||
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()
|
||||
// A web card whose kind this UI version does not know (a newer host's
|
||||
// value) also takes the generic path, not a malformed fetch.
|
||||
const futureKind = { card: 'web', kind: 'timeline' } as unknown as ToolResultView
|
||||
expect(webCardModel(settledSearch({ resultView: futureKind }))).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')} t={t} />)
|
||||
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')} t={t} />)
|
||||
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()}
|
||||
t={t}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
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 and keeps the fetched body below it', () => {
|
||||
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()
|
||||
// The card is a summary (URL + status only); the panel is the single-call
|
||||
// reading surface, so the fetched body still renders below the card.
|
||||
const output = view.getByText('输出').closest('section')
|
||||
expect(output?.querySelector('pre')?.textContent).toContain('fetch body')
|
||||
})
|
||||
|
||||
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('输出').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'])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user