Merge remote-tracking branch 'origin/feat/web-presenter' into feat/web-web-card
This commit is contained in:
@@ -2805,7 +2805,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'WebFetchResultView',
|
||||
declaration: 'export interface WebFetchResultView {\n card: \'web\';\n kind: \'fetch\';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n content?: ContentBlock[];\n}',
|
||||
declaration: 'export interface WebFetchResultView {\n card: \'web\';\n kind: \'fetch\';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n}',
|
||||
},
|
||||
{
|
||||
name: 'WebResultView',
|
||||
@@ -2833,7 +2833,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'WebSearchResultView',
|
||||
declaration: 'export interface WebSearchResultView {\n card: \'web\';\n kind: \'search\';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n content?: ContentBlock[];\n}',
|
||||
declaration: 'export interface WebSearchResultView {\n card: \'web\';\n kind: \'search\';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n}',
|
||||
},
|
||||
{
|
||||
name: 'WebSearchSource',
|
||||
|
||||
@@ -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/core/tools/README.md
|
||||
README.md: e5adb153e77d7a2d8c4068b016194ab6abb6473e
|
||||
README.zh.md: c67a2f2ee4ac2a9d587c6efbf2b5c60d14fc58c2
|
||||
README.md: e7f395f8c1d6417db856e590f5267cf6887e4d12
|
||||
README.zh.md: acb4c047bf86e36c828882ff751d4be1f627f99e
|
||||
|
||||
@@ -108,7 +108,7 @@ Optional `isConcurrencySafe(args)` receives typed, softly validated arguments. E
|
||||
Tools optionally own pure `presentCall()` and `presentResult()` render intents, so UIs do not special-case tool names:
|
||||
|
||||
- Call views are `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`, `{ card: 'terminal', title, description?, cwd? }`, or `{ card: 'diff', title, diffs, locations? }`.
|
||||
- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, or `{ card: 'diff', title?, diffs }`.
|
||||
- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, `{ card: 'diff', title?, diffs }`, or `{ card: 'web', kind: 'search' | 'fetch', title?, … }` (a completed web retrieval; the `kind` arms carry the structured search sources or the fetch summary, and a UI without the `web` capability falls back to the raw result content).
|
||||
|
||||
Returning `undefined` selects generic fallback. Presenters depend only on their arguments and the durable result because UIs call them during live streaming and log replay. `output.presentationMeta(args, value)` derives JSON metadata for direct surface calls; that metadata persists with `tool/result` and returns to `presentResult`, while the canonical value itself remains execution-local and is never replayed. Nested Code dispatches do not compute metadata. `defineTool` soft-validates older logged arguments and falls back instead of crashing replay. `dsh-tool-bash` and `dsh-tool-fs` are the reference implementations; the [canonical-output Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) owns the value/presentation split and the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns card vocabulary.
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ ctx.tools.register(defineTool({
|
||||
工具可以选择拥有纯 `presentCall()` 和 `presentResult()` 呈现意图,使 UI 无需特殊处理工具名称:
|
||||
|
||||
- 调用视图为 `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`、`{ card: 'terminal', title, description?, cwd? }` 或 `{ card: 'diff', title, diffs, locations? }`。
|
||||
- 结果视图为 `{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }` 或 `{ card: 'diff', title?, diffs }`。
|
||||
- 结果视图为 `{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`、`{ card: 'diff', title?, diffs }` 或 `{ card: 'web', kind: 'search' | 'fetch', title?, … }`(已完成的 web 检索;`kind` 各分支携带结构化的搜索来源或抓取摘要,不具备 `web` 能力的 UI 回退到原始结果内容)。
|
||||
|
||||
返回 `undefined` 会选择通用回退。呈现器只依赖其参数和持久结果,因为 UI 会在实时流式输出和日志回放期间调用它们。`output.presentationMeta(args, value)` 为直接接口调用派生 JSON 元数据;该元数据随 `tool/result` 持久化并传回 `presentResult`,而规范值本身仍只存在于执行局部,绝不会回放。嵌套 Code 分发不会计算元数据。`defineTool` 会软验证较旧的日志参数并回退,而不会使回放崩溃。`dsh-tool-bash` 与 `dsh-tool-fs` 是参考实现;[规范输出 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) 规定值/呈现拆分,[呈现意图 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) 规定卡片词汇。
|
||||
|
||||
|
||||
@@ -179,11 +179,11 @@ export interface DiffResultView {
|
||||
|
||||
/**
|
||||
* One citeable source in a completed {@link WebSearchResultView}, the faithful
|
||||
* projection of one web-search source. The render text a web tool returns is
|
||||
* lossy — its markdown list collapses `title`/`snippet`/`publishedAt` into one
|
||||
* free-text line and labels a source by title OR hostname — so a UI cannot
|
||||
* reliably recover these fields by reparsing that text. A tool therefore
|
||||
* projects this structured shape through `output.presentationMeta`, and its
|
||||
* projection of one web-search source. The presentation projection of `dsh-web`'s
|
||||
* `WebSearchSource`: that seam type is the authoritative shape (core cannot depend
|
||||
* on the web seam, so the two are declared separately and MUST evolve together).
|
||||
* A web tool projects this shape through `output.presentationMeta` because the
|
||||
* render text cannot losslessly carry it (see the web-result-card Agent Note); its
|
||||
* `presentResult` reads it back.
|
||||
*/
|
||||
export interface WebSource {
|
||||
@@ -202,18 +202,25 @@ export interface WebSource {
|
||||
* by a web tool whose call retrieves from the web (`web_search`, `web_fetch`).
|
||||
* One `kind`-tagged union carries both shapes because both are web retrieval and
|
||||
* a UI renders them with one component family; a UI switches on `kind`. An
|
||||
* incapable UI falls back to `content` (the reformatted model-facing text). This
|
||||
* is the result-time analogue of the `web_search`/`web_fetch` calls' generic
|
||||
* call views (`kind: 'search'`/`'fetch'`); those tools keep their generic
|
||||
* pending card and add only this completed card.
|
||||
* incapable UI falls back to the raw `tool/result` content (this view carries no
|
||||
* `content` copy — see the web-result-card Agent Note). This is the result-time
|
||||
* analogue of the `web_search`/`web_fetch` calls' generic call views
|
||||
* (`kind: 'search'`/`'fetch'`); those tools keep their generic pending card and
|
||||
* add only this completed card.
|
||||
*
|
||||
* The `kind` field here is this union's own discriminant, NOT a
|
||||
* {@link ToolCallKind}: the two values deliberately match the tools' pending
|
||||
* `ToolCallKind` (`'search'`/`'fetch'`) so a call and its result read as one
|
||||
* category, but a new arm is a union edit plus a consumer branch, not any
|
||||
* arbitrary `ToolCallKind` value.
|
||||
*/
|
||||
export type WebResultView = WebSearchResultView | WebFetchResultView
|
||||
|
||||
/**
|
||||
* The completed state of a `web_search` call: the structured sources the model
|
||||
* cited, an optional provider answer, and whether the source list was cut to the
|
||||
* result cap. A capable UI renders the sources as a citation list; an incapable
|
||||
* UI renders `content`.
|
||||
* result cap. A capable UI renders the sources as a citation list; a UI without
|
||||
* the `web` capability falls back to the raw `tool/result` content.
|
||||
*/
|
||||
export interface WebSearchResultView {
|
||||
card: 'web'
|
||||
@@ -224,21 +231,15 @@ export interface WebSearchResultView {
|
||||
sources: WebSource[]
|
||||
/** The provider-generated answer text, when any. */
|
||||
answer?: string
|
||||
/** True when the tool cut the source list to its result cap. */
|
||||
/** True when the seam cut the source list to honor the result cap. */
|
||||
truncated: boolean
|
||||
/**
|
||||
* UI-facing fallback content (harness {@link ContentBlock}s), reformatted from
|
||||
* the model-facing result. A UI without the `web` capability renders this.
|
||||
* Omit to let the UI render the raw result content.
|
||||
*/
|
||||
content?: ContentBlock[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The completed state of a `web_fetch` call: the fetched URL, its HTTP status,
|
||||
* and whether the content was cut. The body itself is already markdown in the
|
||||
* result content, so this card carries the retrieval summary and leaves the body
|
||||
* to `content`.
|
||||
* raw `tool/result` content, so this card carries only the retrieval summary and
|
||||
* a UI without the `web` capability falls back to that content.
|
||||
*/
|
||||
export interface WebFetchResultView {
|
||||
card: 'web'
|
||||
@@ -249,12 +250,10 @@ export interface WebFetchResultView {
|
||||
url: string
|
||||
/** HTTP status code of the fetched response. */
|
||||
statusCode: number
|
||||
/** True when the provider or the output cap cut the content. */
|
||||
truncated: boolean
|
||||
/**
|
||||
* UI-facing fallback content (harness {@link ContentBlock}s): the already-markdown
|
||||
* body. A UI without the `web` capability renders this. Omit to let the UI
|
||||
* render the raw result content.
|
||||
* True when the provider capped the decoded body, or the output cap or a
|
||||
* pre-conversion source cut trimmed the rendered text (the effective
|
||||
* truncation the model-facing text also reflects).
|
||||
*/
|
||||
content?: ContentBlock[]
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
@@ -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/host/directory-picker-browse/README.md
|
||||
README.md: 23153881b84dcb71dfb05d4f297a5818c410ca77
|
||||
README.zh.md: d7010e2941a801ba6358082824330eaae46e42b7
|
||||
README.md: 52b5fe7e89f915be3b50324628e9d5c48f1ef94c
|
||||
README.zh.md: 742da39470083887a71ddba4a7c8012f0ce0ea1f
|
||||
|
||||
@@ -6,7 +6,7 @@ The **in-app browsing backend** of the [directory-picker seam](../directory-pick
|
||||
|
||||
Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated); window insertion is binary with an O(1) full-window tail rejection, and `list` threads the caller's `AbortSignal` so a disconnect or timeout stops the scan instead of letting it outlive the caller. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md).
|
||||
|
||||
**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored: a crumb jump or a submitted path commits the target immediately, then re-selects its actual entry in its parent level once that level arrives — two panes, so stepping back never collapses (a failed or truncated parent leg keeps the single-pane landing; the display root keeps the single wide level); breadcrumb with a click-to-edit path zone whose editor seeds a trailing separator, prefix-filters the listed level from the draft's final segment while typing (case-insensitively, over the listed — possibly truncated — rows only; Enter still navigates by the exact text), and cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft); a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from both filters; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind).
|
||||
**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored and quiet: the previous view keeps rendering while a crumb jump or a submitted path is scanned (a "Loading…" pill floats over it only once the scan outlives a 300ms silence window, never shifting the columns), then target and parent legs land as one two-pane frame with the target re-selected as its actual parent-level entry — so stepping back never collapses and no intermediate frame flashes (a parent leg outliving its 200ms wait bound lands the target alone and upgrades in place; a failed or truncated parent leg keeps the single-pane landing; the display root keeps the single wide level); breadcrumb with a click-to-edit path zone whose editor seeds a trailing separator, prefix-filters the listed level from the draft's final segment while typing (case-insensitively, over the listed — possibly truncated — rows only; Enter still navigates by the exact text), and cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft); a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from both filters; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind).
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。
|
||||
|
||||
**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚落地:crumb 跳转或提交的路径会立即提交目标,待父层级到达后再在其中重新选中目标的实际条目——双栏,因此后退绝不塌缩(父层级这一程失败或被截断时保持单栏落地;展示根保持单个宽层级);带点击即编辑路径区的面包屑,其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;Enter 仍按确切文本导航)、按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿);基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受这两种过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。
|
||||
**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚、安静落地:扫描 crumb 跳转或提交的路径期间,先前视图持续渲染("Loading…" 胶囊仅在扫描超出 300ms 静默窗口后才浮于其上,绝不挪动各列),随后目标与父层级两程以单个双栏帧落地,目标被重新选中为其在父层级中的实际条目——因此后退绝不塌缩,也没有中间帧闪现(父层级这一程超出其 200ms 等待上限时,目标单独落地,随后就地升级;父层级这一程失败或被截断时保持单栏落地;展示根保持单个宽层级);带点击即编辑路径区的面包屑,其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;Enter 仍按确切文本导航)、按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿);基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受这两种过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
height: min(500px, calc(100dvh - 32px));
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
/* The Modal card is an l2 surface and the columns below scroll on it:
|
||||
* rebind the scrollbar indirection to the elevation pair here, on the
|
||||
* surface, so it inherits down to whichever descendant scrolls (the
|
||||
* rebinding contract in ui-theme styles/scrollbar.css). */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
/* Card-scope wrapper hosting the path editor's Escape and focus-leave
|
||||
@@ -146,6 +152,8 @@
|
||||
flex-direction: column;
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
/* Anchors the floating loading pill (.loadingFloat). */
|
||||
position: relative;
|
||||
/* Right inset is slimmer than the left: the trailing column's own 8px
|
||||
* scrollbar clearance makes up the optical difference. */
|
||||
padding: 16px 16px 16px 24px;
|
||||
@@ -234,6 +242,10 @@
|
||||
.status,
|
||||
.error {
|
||||
padding: 4px;
|
||||
/* The loading pill occupies the opposite corner while a stale status stays
|
||||
* visible. Reserve its widest localized footprint so wrapped text cannot
|
||||
* run underneath it on a narrow card. */
|
||||
padding-right: 120px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
@@ -246,6 +258,23 @@
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* The slow-scan indicator floats over the content's bottom-RIGHT corner on
|
||||
* the card background instead of occupying a row: a scan must never shift
|
||||
* the columns' height, and the stale view keeps rendering beneath it (it
|
||||
* only appears at all once a scan outlives SLOW_SCAN_DELAY_MS). Right,
|
||||
* not left: the truncated/error status rows flow at the bottom LEFT and
|
||||
* stay on screen through a scan, with their reserved right padding keeping
|
||||
* both legible even on a narrow card. After .status in the cascade — the
|
||||
* element carries both classes and this padding must win the
|
||||
* same-specificity race. */
|
||||
.loadingFloat {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
bottom: 8px;
|
||||
padding: 2px 8px;
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
}
|
||||
|
||||
/* Footer: l3 separator on top, symmetric padding so the row sits vertically
|
||||
* centered in the bar; New-folder and the show-hidden toggle pin left. */
|
||||
.footerBar {
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
* breadcrumb, and a click-to-edit path zone; below it a Miller view — one
|
||||
* full-width level until a row is selected, then two columns splitting the
|
||||
* row evenly (256px floor; level | selected folder's children) around a
|
||||
* hairline divider. Navigations land selection-anchored: a crumb jump or a
|
||||
* submitted path commits the target immediately, then re-selects it in its
|
||||
* parent level once that level arrives, so stepping back keeps two panes
|
||||
* away from the display root. Selecting in the
|
||||
* hairline divider. Navigations land selection-anchored and quiet: the
|
||||
* previous view keeps rendering while a crumb jump or a submitted path is
|
||||
* scanned, then target and parent legs land as one two-pane frame (a slow
|
||||
* parent leg falls back to landing the target alone and upgrading in
|
||||
* place), so stepping back keeps two panes away from the display root and
|
||||
* navigation never flashes an intermediate frame. Selecting in the
|
||||
* right column shifts the view one level deeper. "New folder" opens a nested
|
||||
* create dialog targeting the selected folder (or the level itself) and
|
||||
* selects the created folder. Open adopts the selected folder, falling back
|
||||
@@ -55,6 +57,24 @@ function failureText(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a scan may stay visually silent before the floating "Loading…"
|
||||
* pill appears. The stale view keeps rendering while a scan is in flight, so
|
||||
* a listing that settles inside this window swaps the panes with no
|
||||
* intermediate frame at all; only a genuinely slow host (a network mount, a
|
||||
* cold disk) surfaces the indicator.
|
||||
*/
|
||||
const SLOW_SCAN_DELAY_MS = 300
|
||||
|
||||
/**
|
||||
* How long a navigation landing waits for its parent leg before committing
|
||||
* the target alone. Inside the window both legs land as ONE two-pane frame —
|
||||
* no single-pane flash between them; past it the target commits single-pane
|
||||
* at once (an Enter-submitted navigation is never held hostage by a stalled
|
||||
* parent) and the late parent leg upgrades the landing in place.
|
||||
*/
|
||||
const PARENT_LEG_WAIT_MS = 200
|
||||
|
||||
/**
|
||||
* Breadcrumb rows for display: inside the home subtree the chain starts at a
|
||||
* localized Home crumb; outside it the full ancestry shows, the root labeled
|
||||
@@ -166,6 +186,14 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
const [selected, setSelected] = useState<DirectoryEntry | null>(null)
|
||||
const [child, setChild] = useState<DirectoryListing | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
// Derived from `loading` and `scanWindow` by the slow-scan effect below:
|
||||
// true only once the current listing call has been in flight for
|
||||
// SLOW_SCAN_DELAY_MS, so fast listings never render the indicator at all.
|
||||
const [slowScan, setSlowScan] = useState(false)
|
||||
// Every listing call owns a fresh silence window. `loading` may stay true
|
||||
// across a superseding row pick or across a navigation's target and parent
|
||||
// legs, so its boolean edge cannot identify the start of each scan.
|
||||
const [scanWindow, setScanWindow] = useState(0)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// Path-edit state: null = breadcrumb mode; a string = the draft being typed.
|
||||
const [pathDraft, setPathDraft] = useState<string | null>(null)
|
||||
@@ -209,13 +237,20 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
return ++requestSeq.current
|
||||
}, [])
|
||||
|
||||
/** Hide any prior indicator and start a fresh silence window for one listing call. */
|
||||
const restartSlowScanWindow = useCallback((): void => {
|
||||
setSlowScan(false)
|
||||
setScanWindow(value => value + 1)
|
||||
}, [])
|
||||
|
||||
/** Launch one listing under a fresh controller so a later supersession can abort it. */
|
||||
const launchListing = useCallback((path: string | undefined): { seq: number; scan: Promise<DirectoryListing> } => {
|
||||
const seq = supersede()
|
||||
const controller = new AbortController()
|
||||
scanController.current = controller
|
||||
restartSlowScanWindow()
|
||||
return { seq, scan: listDirectory(path, controller.signal) }
|
||||
}, [supersede, listDirectory])
|
||||
}, [supersede, restartSlowScanWindow, listDirectory])
|
||||
|
||||
/**
|
||||
* Launch a follow-up listing under the CURRENT supersession seq: a newer
|
||||
@@ -224,21 +259,25 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
const continueScan = useCallback((path: string): Promise<DirectoryListing> => {
|
||||
const controller = new AbortController()
|
||||
scanController.current = controller
|
||||
restartSlowScanWindow()
|
||||
return listDirectory(path, controller.signal)
|
||||
}, [listDirectory])
|
||||
}, [restartSlowScanWindow, listDirectory])
|
||||
|
||||
/**
|
||||
* Replace the whole view with a freshly navigated level. The target level
|
||||
* commits the moment it arrives (single wide level: the editor closes and
|
||||
* loading ends on this first settlement, so an Enter-submitted navigation
|
||||
* is never withdrawn waiting on anything further). Away from the display
|
||||
* root — the same collapse the crumb header renders, so crumbs and pane
|
||||
* shape never disagree — a parent leg then upgrades the landing in place:
|
||||
* the target's ACTUAL parent-level entry re-selected (left pane = parent,
|
||||
* right pane = the target), so a crumb jump reads as stepping back one
|
||||
* pane. A failed parent leg, or a truncated parent window that lacks the
|
||||
* target, leaves the committed single-pane landing — the upgrade must
|
||||
* never orphan the selection it exists to anchor.
|
||||
* Replace the whole view with a freshly navigated level. Away from the
|
||||
* display root — the same collapse the crumb header renders, so crumbs and
|
||||
* pane shape never disagree — the landing is two-pane: the target's ACTUAL
|
||||
* parent-level entry re-selected (left pane = parent, right pane = the
|
||||
* target), so a crumb jump reads as stepping back one pane. Both legs land
|
||||
* as one frame when the parent leg settles within
|
||||
* {@link PARENT_LEG_WAIT_MS}; past that bound (or at the display root) the
|
||||
* target commits alone — single wide level, the editor closes, loading
|
||||
* ends — and a late parent leg still upgrades the landing in place. A
|
||||
* failed parent leg, or a truncated parent window that lacks the target,
|
||||
* leaves the single-pane landing — the upgrade must never orphan the
|
||||
* selection it exists to anchor. Until whichever commit comes first, the
|
||||
* previous view keeps rendering: navigation swaps the panes, it never
|
||||
* blanks them.
|
||||
*/
|
||||
const navigate = useCallback((path?: string) => {
|
||||
const { seq, scan } = launchListing(path)
|
||||
@@ -246,16 +285,23 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
setError(null)
|
||||
scan.then((target) => {
|
||||
if (seq !== requestSeq.current) return
|
||||
setParent(target)
|
||||
setSelected(null)
|
||||
setChild(null)
|
||||
setLoading(false)
|
||||
setPathDraft(null)
|
||||
// The single-pane landing; `landed` makes it first-commit-only, while
|
||||
// the two-pane commit below may still upgrade an already-landed view.
|
||||
let landed = false
|
||||
const landSingle = (): void => {
|
||||
if (landed || seq !== requestSeq.current) return
|
||||
landed = true
|
||||
setParent(target)
|
||||
setSelected(null)
|
||||
setChild(null)
|
||||
setLoading(false)
|
||||
setPathDraft(null)
|
||||
}
|
||||
// Arity is label-independent: only the collapsed chain's depth decides.
|
||||
if (displayCrumbs(target, '').length < 2) return
|
||||
if (displayCrumbs(target, '').length < 2) { landSingle(); return }
|
||||
const parentCrumb = target.crumbs.at(-2)
|
||||
/* v8 ignore next -- narrowing: a two-deep display chain implies a parent crumb (root-to-target inclusive). */
|
||||
if (parentCrumb === undefined) return
|
||||
if (parentCrumb === undefined) { landSingle(); return }
|
||||
continueScan(parentCrumb.path).then((parentLevel) => {
|
||||
if (seq !== requestSeq.current) return
|
||||
// Windows resolves a typed path preserving its case; anchor on the
|
||||
@@ -263,15 +309,23 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
const sep = separatorOf(parentLevel)
|
||||
const fold = (value: string): string => (sep === '\\' ? value.toLowerCase() : value)
|
||||
const match = parentLevel.entries.find(entry => fold(entry.path) === fold(target.path))
|
||||
if (match === undefined) return
|
||||
if (match === undefined) { landSingle(); return }
|
||||
landed = true
|
||||
setParent(parentLevel)
|
||||
setSelected(match)
|
||||
setChild(target)
|
||||
// Idempotent on a late upgrade of a timed-out landing: reopening the
|
||||
// editor or starting a newer scan supersedes this seq, so reaching
|
||||
// here means the draft is closed and the loading flag is this
|
||||
// navigation's own.
|
||||
setLoading(false)
|
||||
setPathDraft(null)
|
||||
}, () => {
|
||||
// Swallows the parent-leg failure (its abort included): the
|
||||
// committed single-pane landing stands, and nobody asked to see
|
||||
// the parent level.
|
||||
// The parent-leg failure (its abort included) never surfaces: the
|
||||
// target listed fine, and nobody asked to see the parent level.
|
||||
landSingle()
|
||||
})
|
||||
window.setTimeout(landSingle, PARENT_LEG_WAIT_MS)
|
||||
}, (reason: unknown) => {
|
||||
if (seq !== requestSeq.current) return
|
||||
setLoading(false)
|
||||
@@ -289,7 +343,15 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
const pathInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const editZoneRef = useRef<HTMLButtonElement | null>(null)
|
||||
|
||||
/** Select a row of the listed level and preview its children on the right. */
|
||||
/**
|
||||
* Select a row of the listed level and preview its children on the right.
|
||||
* Deliberately NOT one-frame like navigate(): a pick's first duty is the
|
||||
* immediate selected state on the clicked row, and the pane split IS that
|
||||
* feedback (aria-current pill, crumbs following the selection) — holding
|
||||
* it back for the child listing would make clicks feel dropped. The quiet
|
||||
* rule governs whole-view replacement, where nothing acknowledges the
|
||||
* click but the swap itself.
|
||||
*/
|
||||
const select = useCallback((entry: DirectoryEntry) => {
|
||||
const { seq, scan } = launchListing(entry.path)
|
||||
// A pick while the path editor is open adopts the (filtered) row and
|
||||
@@ -360,6 +422,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
return
|
||||
}
|
||||
supersede()
|
||||
// Closing mid-scan leaves nothing to load: without this edge the
|
||||
// slow-scan effect keeps arming while hidden and the reopened dialog
|
||||
// would show the indicator on its first frame instead of waiting out a
|
||||
// fresh silence window (reopen's navigate() produces no loading edge).
|
||||
setLoading(false)
|
||||
setError(null)
|
||||
setPathDraft(null)
|
||||
setFolderDraft(null)
|
||||
@@ -396,6 +463,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
// create target becomes the listed level and the new folder its selection.
|
||||
const { seq, scan } = launchListing(targetPath)
|
||||
setLoading(true)
|
||||
// Symmetric with navigate/select: a launched scan clears the stale
|
||||
// failure text (and keeps the floating indicator's corner the only
|
||||
// occupant of the content's right edge while it shows).
|
||||
setError(null)
|
||||
scan.then((level) => {
|
||||
/* v8 ignore next -- same fence as navigate/select; the modal blocks superseding input */
|
||||
if (seq !== requestSeq.current) return
|
||||
@@ -415,6 +486,19 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
})
|
||||
}
|
||||
|
||||
// The slow-scan gate for the loading indicator: each listing call restarts
|
||||
// the timer even when a superseding scan or a navigation's parent leg keeps
|
||||
// `loading` continuously true. A settle inside its own window means the swap
|
||||
// happened with nothing shown.
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
setSlowScan(false)
|
||||
return
|
||||
}
|
||||
const timer = window.setTimeout(() => { setSlowScan(true) }, SLOW_SCAN_DELAY_MS)
|
||||
return () => { window.clearTimeout(timer) }
|
||||
}, [loading, scanWindow])
|
||||
|
||||
// After the hooks: a closed dialog renders nothing and evaluates no copy.
|
||||
const crumbSource = child ?? parent
|
||||
const crumbs = crumbSource === null ? [] : displayCrumbs(crumbSource, t('browser.home'))
|
||||
@@ -649,11 +733,15 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{loading && <div className={css.status} role="status">{t('browser.loading')}</div>}
|
||||
{loading && slowScan
|
||||
&& <div className={clsx(css.status, css.loadingFloat)} role="status">{t('browser.loading')}</div>}
|
||||
{/* The backend bounds a level at its complete-result limit; say so
|
||||
* whenever a visible pane was cut instead of letting the tail of a
|
||||
* huge directory go silently missing. */}
|
||||
{(parent?.truncated === true || child?.truncated === true) && !loading
|
||||
* huge directory go silently missing. The note describes the panes
|
||||
* on screen, so an in-flight scan leaves it alone — hiding it while
|
||||
* the stale view still shows the cut level would shift the columns
|
||||
* on every navigation away from it. */}
|
||||
{(parent?.truncated === true || child?.truncated === true)
|
||||
&& <div className={css.status} role="status">{t('browser.truncated')}</div>}
|
||||
{error !== null && <div className={css.error} role="alert">{error}</div>}
|
||||
</div>
|
||||
|
||||
@@ -111,6 +111,12 @@ function rowButton(item: HTMLElement): HTMLButtonElement {
|
||||
}
|
||||
|
||||
describe('DirectoryBrowser', () => {
|
||||
it('renders nothing and launches no listing while initially closed', () => {
|
||||
const b = mount({ open: false })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(b.listDirectory).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens at the Host home as one wide column, hides hidden entries, and roots the crumbs at Home', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
@@ -235,39 +241,249 @@ describe('DirectoryBrowser', () => {
|
||||
expect(columns()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('commits the target immediately, aborts a superseded parent leg on the wire, and drops its late resolution', async () => {
|
||||
const signals: (AbortSignal | undefined)[] = []
|
||||
const settlers: ((value: DirectoryListing) => void)[] = []
|
||||
// Only the FIRST explicit HOME request (the parent leg) hangs; the later
|
||||
// home crumb jump lists normally.
|
||||
let homeCalls = 0
|
||||
const listDirectory = vi.fn(async (path?: string, signal?: AbortSignal) => {
|
||||
signals.push(signal)
|
||||
if (path === HOME && ++homeCalls === 1) {
|
||||
return new Promise<DirectoryListing>((resolve) => { settlers.push(resolve) })
|
||||
}
|
||||
return listingFor(path)
|
||||
it('lands the target single-pane at the wait bound, aborts a superseded parent leg on the wire, and drops its late resolution', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const signals: (AbortSignal | undefined)[] = []
|
||||
const settlers: ((value: DirectoryListing) => void)[] = []
|
||||
// Only the FIRST explicit HOME request (the parent leg) hangs; the
|
||||
// later home crumb jump lists normally.
|
||||
let homeCalls = 0
|
||||
const listDirectory = vi.fn((path?: string, signal?: AbortSignal) => {
|
||||
signals.push(signal)
|
||||
if (path === HOME && ++homeCalls === 1) {
|
||||
return new Promise<DirectoryListing>((resolve) => { settlers.push(resolve) })
|
||||
}
|
||||
return Promise.resolve(listingFor(path))
|
||||
})
|
||||
mount({ listDirectory })
|
||||
await act(async () => {})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: DOCS } })
|
||||
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' })
|
||||
// The target settled but the parent leg hangs: inside the wait bound
|
||||
// nothing commits yet.
|
||||
await act(async () => {})
|
||||
expect(settlers).toHaveLength(1)
|
||||
expect(screen.getByLabelText('browser.editPath', { selector: 'input' })).toBeTruthy()
|
||||
// The wait bound expires: the target commits alone — editor closed,
|
||||
// single-pane DOCS level.
|
||||
await act(async () => { vi.advanceTimersByTime(200) })
|
||||
expect(screen.getByRole('listitem').textContent).toBe('harness')
|
||||
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
|
||||
expect(columns()).toHaveLength(1)
|
||||
// A newer jump aborts the pending parent leg ON THE WIRE, not merely
|
||||
// dropping its settlement.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
|
||||
expect(signals[2]?.aborted).toBe(true)
|
||||
await act(async () => {})
|
||||
expect(screen.getByRole('listitem').textContent).toBe('Documents')
|
||||
// Its late resolution changes nothing either.
|
||||
await act(async () => { settlers[0]!(listingFor(HOME)) })
|
||||
expect(columns()).toHaveLength(1)
|
||||
expect(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Listing fake whose explicit-path scans stay pending until the test
|
||||
* settles them by path; the absent-path form (the initial home listing)
|
||||
* resolves normally so mounting is a one-flush setup.
|
||||
*/
|
||||
function manualLister() {
|
||||
const settlers = new Map<string, (value: DirectoryListing) => void>()
|
||||
const listDirectory = vi.fn((path?: string, _signal?: AbortSignal) => {
|
||||
if (path === undefined) return Promise.resolve(listingFor(path))
|
||||
return new Promise<DirectoryListing>((resolve) => { settlers.set(path, resolve) })
|
||||
})
|
||||
mount({ listDirectory })
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: DOCS } })
|
||||
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' })
|
||||
// The target leg commits at once: editor closed, single-pane DOCS level,
|
||||
// while the parent leg (upgrade) is still in flight.
|
||||
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
|
||||
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
|
||||
expect(columns()).toHaveLength(1)
|
||||
await waitFor(() => { expect(settlers).toHaveLength(1) })
|
||||
// A newer jump aborts the pending parent leg ON THE WIRE, not merely
|
||||
// dropping its settlement.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
|
||||
expect(signals[2]?.aborted).toBe(true)
|
||||
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') })
|
||||
// Its late resolution changes nothing either.
|
||||
await act(async () => { settlers[0]!(listingFor(HOME)) })
|
||||
expect(columns()).toHaveLength(1)
|
||||
expect(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBeNull()
|
||||
return { settlers, listDirectory }
|
||||
}
|
||||
|
||||
it('lands a navigation as ONE two-pane frame: the stale view holds until both legs arrive', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { settlers, listDirectory } = manualLister()
|
||||
mount({ listDirectory })
|
||||
await act(async () => {})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: DOCS } })
|
||||
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' })
|
||||
// The target settles while the parent leg is still in flight: nothing
|
||||
// commits yet — the editor stays open over the stale home level, and no
|
||||
// single-pane DOCS frame ever renders.
|
||||
await act(async () => { settlers.get(DOCS)!(listingFor(DOCS)) })
|
||||
expect(screen.getByLabelText('browser.editPath', { selector: 'input' })).toBeTruthy()
|
||||
expect(screen.queryByText('harness')).toBeNull()
|
||||
// The parent leg settles inside the wait bound: one commit straight to
|
||||
// the two-pane landing, editor closed.
|
||||
await act(async () => { settlers.get(HOME)!(listingFor(HOME)) })
|
||||
expect(columns()).toHaveLength(2)
|
||||
expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true')
|
||||
expect(within(columns()[0]!).getByText('Documents')).toBeTruthy()
|
||||
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
|
||||
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
|
||||
// The wait-bound timer firing after the landing is a no-op.
|
||||
await act(async () => { vi.advanceTimersByTime(200) })
|
||||
expect(columns()).toHaveLength(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('a stalled parent leg lands the target alone at the wait bound, then upgrades in place', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { settlers, listDirectory } = manualLister()
|
||||
mount({ listDirectory })
|
||||
await act(async () => {})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: DOCS } })
|
||||
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' })
|
||||
// The target can consume most of the outer scan's silence window.
|
||||
await act(async () => { vi.advanceTimersByTime(250) })
|
||||
await act(async () => { settlers.get(DOCS)!(listingFor(DOCS)) })
|
||||
// Its parent leg gets a fresh silence window. Crossing the original
|
||||
// scan's 300ms deadline therefore cannot flash the indicator during the
|
||||
// bounded landing wait.
|
||||
await act(async () => { vi.advanceTimersByTime(199) })
|
||||
expect(screen.queryByText('browser.loading')).toBeNull()
|
||||
// The parent leg outlives PARENT_LEG_WAIT_MS: the target lands alone.
|
||||
await act(async () => { vi.advanceTimersByTime(1) })
|
||||
expect(columns()).toHaveLength(1)
|
||||
expect(screen.getByRole('listitem').textContent).toBe('harness')
|
||||
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
|
||||
// The late parent leg still upgrades the landing in place, exactly as
|
||||
// if it had made the bound. (Reopening the editor meanwhile would
|
||||
// supersede the upgrade — the editor-open handler withdraws pending
|
||||
// listings — so a late upgrade can never close a resumed draft.)
|
||||
await act(async () => { settlers.get(HOME)!(listingFor(HOME)) })
|
||||
expect(columns()).toHaveLength(2)
|
||||
expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true')
|
||||
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('Escape inside the landing window withdraws the submitted navigation', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { settlers, listDirectory } = manualLister()
|
||||
mount({ listDirectory })
|
||||
await act(async () => {})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
fireEvent.change(input, { target: { value: DOCS } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
await act(async () => { settlers.get(DOCS)!(listingFor(DOCS)) })
|
||||
// Nothing has committed yet; Escape supersedes the landing entirely.
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
await act(async () => { vi.advanceTimersByTime(200) })
|
||||
expect(columns()).toHaveLength(1)
|
||||
expect(screen.queryByText('harness')).toBeNull()
|
||||
expect(within(columns()[0]!).getByText('Documents')).toBeTruthy()
|
||||
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('shows the loading indicator only once a scan outlives its silence window, floating over the stale view', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
// The home level is truncated so its note is on screen when the slow
|
||||
// scan starts: dropping the note's old !loading guard means it must
|
||||
// keep rendering through the scan, coexisting with the indicator.
|
||||
const settlers = new Map<string, (value: DirectoryListing) => void>()
|
||||
const listDirectory = vi.fn((path?: string, _signal?: AbortSignal) => {
|
||||
if (path === undefined) return Promise.resolve({ ...listingFor(path), truncated: true })
|
||||
return new Promise<DirectoryListing>((resolve) => { settlers.set(path, resolve) })
|
||||
})
|
||||
mount({ listDirectory })
|
||||
await act(async () => {})
|
||||
expect(screen.queryByText('browser.loading')).toBeNull()
|
||||
expect(screen.getByText('browser.truncated')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: DOCS } })
|
||||
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' })
|
||||
// In flight but still inside the silence window: no indicator, and the
|
||||
// stale level's truncated note stays put (no layout churn on launch).
|
||||
expect(screen.queryByText('browser.loading')).toBeNull()
|
||||
expect(screen.getByText('browser.truncated')).toBeTruthy()
|
||||
await act(async () => { vi.advanceTimersByTime(300) })
|
||||
// Past it: the indicator floats while the stale level — truncated note
|
||||
// included — keeps rendering beneath it.
|
||||
expect(screen.getByText('browser.loading')).toBeTruthy()
|
||||
expect(screen.getByText('browser.truncated')).toBeTruthy()
|
||||
expect(screen.getByText('Documents')).toBeTruthy()
|
||||
// Landing (both legs) retires the indicator with the scan, and the
|
||||
// fresh listings' own truncated state replaces the stale note.
|
||||
await act(async () => { settlers.get(DOCS)!(listingFor(DOCS)) })
|
||||
await act(async () => { settlers.get(HOME)!(listingFor(HOME)) })
|
||||
expect(screen.queryByText('browser.loading')).toBeNull()
|
||||
expect(screen.queryByText('browser.truncated')).toBeNull()
|
||||
expect(columns()).toHaveLength(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('restarts the silence window when a row pick supersedes a pending scan', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const pending: ((value: DirectoryListing) => void)[] = []
|
||||
const listDirectory = vi.fn((path?: string, _signal?: AbortSignal) => {
|
||||
if (path === undefined) return Promise.resolve(listingFor(path))
|
||||
return new Promise<DirectoryListing>((resolve) => { pending.push(resolve) })
|
||||
})
|
||||
mount({ listDirectory })
|
||||
await act(async () => {})
|
||||
const documents = rowButton(screen.getByRole('listitem'))
|
||||
fireEvent.click(documents)
|
||||
await act(async () => { vi.advanceTimersByTime(300) })
|
||||
expect(screen.getByText('browser.loading')).toBeTruthy()
|
||||
// The same row remains actionable while its preview is pending. A second
|
||||
// pick starts a new listing without a false `loading` edge.
|
||||
fireEvent.click(documents)
|
||||
expect(screen.queryByText('browser.loading')).toBeNull()
|
||||
await act(async () => { vi.advanceTimersByTime(299) })
|
||||
expect(screen.queryByText('browser.loading')).toBeNull()
|
||||
await act(async () => { vi.advanceTimersByTime(1) })
|
||||
expect(screen.getByText('browser.loading')).toBeTruthy()
|
||||
await act(async () => { pending.at(-1)!(listingFor(DOCS)) })
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('a close mid-scan resets the slow-scan gate: reopening waits a fresh silence window', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
// Every home listing hangs: the initial open's scan is the one the
|
||||
// close interrupts, and the reopen's scan proves the fresh window.
|
||||
const settlers: ((value: DirectoryListing) => void)[] = []
|
||||
const listDirectory = vi.fn((_path?: string, _signal?: AbortSignal) =>
|
||||
new Promise<DirectoryListing>((resolve) => { settlers.push(resolve) }))
|
||||
const { view, props } = mount({ listDirectory })
|
||||
await act(async () => { vi.advanceTimersByTime(300) })
|
||||
expect(screen.getByText('browser.loading')).toBeTruthy()
|
||||
// Close while the scan is in flight, then reopen: the first frame must
|
||||
// wait out a fresh silence window, not inherit the armed indicator.
|
||||
view.rerender(<DirectoryBrowser {...props} open={false} />)
|
||||
view.rerender(<DirectoryBrowser {...props} open />)
|
||||
await act(async () => {})
|
||||
expect(screen.queryByText('browser.loading')).toBeNull()
|
||||
await act(async () => { vi.advanceTimersByTime(300) })
|
||||
expect(screen.getByText('browser.loading')).toBeTruthy()
|
||||
// The reopened scan settles normally.
|
||||
await act(async () => { settlers.at(-1)!(listingFor(undefined)) })
|
||||
expect(screen.queryByText('browser.loading')).toBeNull()
|
||||
expect(screen.getByText('Documents')).toBeTruthy()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the single-pane landing when the truncated parent level lacks the target', async () => {
|
||||
|
||||
@@ -389,10 +389,23 @@ export class ToolCardComponent implements Component {
|
||||
const glyph = this.result === undefined ? '○' : '●'
|
||||
const rawBody = this.renderBody()
|
||||
const view = this.resultView ?? this.callView
|
||||
const genericContent = view.card === 'generic' ? view.content ?? this.result?.content : undefined
|
||||
const unknownXml = this.definition === undefined && genericContent !== undefined
|
||||
// A generic card's own content, or a web card's fallback to the raw result
|
||||
// content (the `web` view carries no `content` copy), both render as one dim
|
||||
// Markdown block below, so links/lists/headings keep the unified dim styling
|
||||
// rather than reading as bare text. Terminal and diff cards own their body
|
||||
// styling, so they are excluded (mirrors renderBody's post-terminal/diff fallback).
|
||||
const markdownContent = view.card === 'generic'
|
||||
? view.content ?? this.result?.content
|
||||
: view.card === 'web'
|
||||
// A web resultView is only assigned alongside this.result (the result
|
||||
// handler sets both) and the pending callView is never a web card, so
|
||||
// the optional-chain undefined side is unreachable here.
|
||||
/* v8 ignore next */
|
||||
? this.result?.content
|
||||
: undefined
|
||||
const unknownXml = this.definition === undefined && markdownContent !== undefined
|
||||
? renderUnknownXml(
|
||||
displayText(contentText(genericContent)),
|
||||
displayText(contentText(markdownContent)),
|
||||
this.maxOutputLines,
|
||||
this.visibility === 'expanded',
|
||||
displayText,
|
||||
@@ -405,7 +418,7 @@ export class ToolCardComponent implements Component {
|
||||
// A generic card renders title and result as one Markdown document, so the
|
||||
// document's own block spacing is preserved, then dims every row — the whole
|
||||
// card body reads as one dim block under the status-colored header.
|
||||
const body = unknownXml ?? (genericContent !== undefined && rawBody.lines.length > 0
|
||||
const body = unknownXml ?? (markdownContent !== undefined && rawBody.lines.length > 0
|
||||
? this.dimBody(rawBody, width)
|
||||
: [...rawBody.prelude, ...rawBody.lines])
|
||||
const visibleBody = unknownXml !== undefined || this.visibility === 'expanded'
|
||||
@@ -502,7 +515,11 @@ export class ToolCardComponent implements Component {
|
||||
// rather than under the dim result-output color.
|
||||
return { prelude: [...hunks, footer], lines: [] }
|
||||
}
|
||||
const content = view.content ?? this.result?.content
|
||||
// The web card carries no `content` copy, so a `web` result view falls back
|
||||
// to the raw result content here (`view.card === 'generic'` narrows the
|
||||
// generic union arm; a `web` card takes the same fallback, mirroring the
|
||||
// `markdownContent` selection in render()).
|
||||
const content = (view.card === 'generic' ? view.content : undefined) ?? this.result?.content
|
||||
const prelude: string[] = []
|
||||
const lines: string[] = []
|
||||
// The presenter title headlines the body now that the header is a fixed
|
||||
|
||||
@@ -4375,6 +4375,14 @@ describe('tool cards and surface replay', () => {
|
||||
name: 'knownXml', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
|
||||
presentCall: () => ({ card: 'generic', title: 'Known XML' }),
|
||||
},
|
||||
// A web card carries no `content` copy, so it falls back to the raw result
|
||||
// content, which must still render through the dim Markdown path (bold
|
||||
// markers stripped) rather than as bare text.
|
||||
webCard: {
|
||||
name: 'webCard', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
|
||||
presentCall: () => ({ card: 'generic', title: 'Fetch page', kind: 'fetch' }),
|
||||
presentResult: () => ({ card: 'web', kind: 'fetch', title: 'https://a.test', url: 'https://a.test', statusCode: 200, truncated: false }),
|
||||
},
|
||||
}
|
||||
|
||||
it('uses terminal, diff, generic, fallback, and collapsed tool presentations', async () => {
|
||||
@@ -4395,6 +4403,7 @@ describe('tool cards and surface replay', () => {
|
||||
['c11', 'terminalResult', '{}'],
|
||||
['c12', 'symbolic', '{}'],
|
||||
['c13', 'knownXml', '{}'],
|
||||
['c16', 'webCard', '{}'],
|
||||
] as const
|
||||
appendAssistant(result.session, [
|
||||
{ type: 'text', text: 'Calling tools' },
|
||||
@@ -4488,6 +4497,14 @@ describe('tool cards and surface replay', () => {
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
result.session.append('tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: 'c16' as never,
|
||||
content: [{ type: 'text', text: 'Fetched **body** text' }],
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
result.session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
@@ -4537,6 +4554,11 @@ describe('tool cards and surface replay', () => {
|
||||
expect(output).toContain('Empty card')
|
||||
expect(output).toContain('converted terminal')
|
||||
expect(output).toContain('<known><value>literal</value></known>')
|
||||
// A web card carries no `content` copy, so it falls back to the raw result
|
||||
// content, which still renders through the dim Markdown path: the bold
|
||||
// markers are stripped rather than shown literally.
|
||||
expect(output).toContain('Fetched body text')
|
||||
expect(output).not.toContain('Fetched **body** text')
|
||||
expect(output).toContain('path: /tmp/a.txt')
|
||||
expect(output).toContain('line (number="1"): hello')
|
||||
expect(output).not.toContain('<result>')
|
||||
|
||||
@@ -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/web/tool-web/README.md
|
||||
README.md: 9b78920b1b6c611118294421dec1e75e381ed5d6
|
||||
README.zh.md: d36258d3a5bd8af6716e1fd9c3384389e8395e23
|
||||
README.md: 7bee0d2d30fbbcf582fd7b60eb5d9130b6bdf888
|
||||
README.zh.md: 3d708839c9ffbdd89df08678fd6997fc6c45ee07
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The model-facing web tool suite — `web_search` and `web_fetch` — over the [web capability seam](../web/README.md) (`ctx.web`). It owns model-facing concerns only: tool names, JSON schemas, snake_case argument names, prompt sections, the result-count bound, result formatting, HTML→markdown presentation, and `presentCall`. All web access goes through `ctx.web`; this package never imports a concrete provider. Neither tool exposes a model-facing timeout — each tool's cooperative tool-call budget is declared here via config (`fetchTimeoutMs`/`searchTimeoutMs`, attached as `ToolDefinition.timeoutMs`) and enforced by [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md) (a `tools/execute` wrapper); each tool just forwards `exec.signal` to the seam.
|
||||
The model-facing web tool suite — `web_search` and `web_fetch` — over the [web capability seam](../web/README.md) (`ctx.web`). It owns model-facing concerns only: tool names, JSON schemas, snake_case argument names, prompt sections, the result-count bound, result formatting, HTML→markdown presentation, and the UI presentation projection — `presentCall`, `presentResult` (a `card: 'web'` result card discriminated by `kind: 'search' | 'fetch'`), and the `output.presentationMeta` that carries the structured search sources or the fetch summary the lossy render text cannot (see the [web-result-card Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card.md)). All web access goes through `ctx.web`; this package never imports a concrete provider. Neither tool exposes a model-facing timeout — each tool's cooperative tool-call budget is declared here via config (`fetchTimeoutMs`/`searchTimeoutMs`, attached as `ToolDefinition.timeoutMs`) and enforced by [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md) (a `tools/execute` wrapper); each tool just forwards `exec.signal` to the seam.
|
||||
|
||||
Each tool is registered independently; a product that wants only one disables the other via config (`{ search: false }` / `{ fetch: false }`).
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
面向模型的 web 工具套件 `web_search` 与 `web_fetch`,构建于 [web 能力 seam](../web/README.md)(`ctx.web`)之上。它只负责面向模型的事项:工具名称、JSON Schema、snake_case 参数名称、提示词区段、结果数量上限、结果格式、HTML→markdown 呈现,以及 `presentCall`。所有 web 访问都通过 `ctx.web`;该包(package)绝不导入具体提供方。两个工具都不公开面向模型的超时:每个工具的协作式工具调用超时预算通过配置在此声明(`fetchTimeoutMs`/`searchTimeoutMs`,附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md)(`tools/execute` 包装层)强制执行;每个工具只把 `exec.signal` 转发给 seam。
|
||||
面向模型的 web 工具套件 `web_search` 与 `web_fetch`,构建于 [web 能力 seam](../web/README.md)(`ctx.web`)之上。它只负责面向模型的事项:工具名称、JSON Schema、snake_case 参数名称、提示词区段、结果数量上限、结果格式、HTML→markdown 呈现,以及 UI 呈现投影——`presentCall`、`presentResult`(以 `kind: 'search' | 'fetch'` 区分的 `card: 'web'` 结果卡片),以及承载有损渲染文本无法携带的结构化搜索来源或抓取摘要的 `output.presentationMeta`(见 [web-result-card Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card.md))。所有 web 访问都通过 `ctx.web`;该包(package)绝不导入具体提供方。两个工具都不公开面向模型的超时:每个工具的协作式工具调用超时预算通过配置在此声明(`fetchTimeoutMs`/`searchTimeoutMs`,附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md)(`tools/execute` 包装层)强制执行;每个工具只把 `exec.signal` 转发给 seam。
|
||||
|
||||
每个工具独立注册;只需要其中一个工具的产品可以通过配置禁用另一个(`{ search: false }`/`{ fetch: false }`)。
|
||||
|
||||
|
||||
@@ -246,26 +246,83 @@ function renderBody(body: WebFetchBody, maxInputChars: number): RenderedBody {
|
||||
/** The truncation notice appended when the provider or the output cap cut content. */
|
||||
const TRUNCATION_FOOTER = '\n\n(Content truncated. Fetch a more specific URL or section for the full text.)'
|
||||
|
||||
/** A rendered fetch output: the model-facing text and its effective truncation. */
|
||||
interface RenderedFetch {
|
||||
/** The complete bounded output — header, rendered body, and truncation footer. */
|
||||
text: string
|
||||
/**
|
||||
* True when the provider capped the body, a pre-conversion source cut applied,
|
||||
* or the complete output exceeded `maxOutputChars`. This is the effective
|
||||
* truncation the returned text reflects (its footer), wider than the
|
||||
* provider-only `WebFetchResult.truncated`.
|
||||
*/
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a fetch result as one model-facing text block, bounded as a whole.
|
||||
* The same cap limits the source prefix processed synchronously, then applies
|
||||
* again where the complete output — header, rendered body, and footer — is known.
|
||||
* Render a fetch result to its bounded model-facing text and effective
|
||||
* truncation. The single source of both the `render` text and the fetch card's
|
||||
* `truncated`, so the card never disagrees with the text the model saw. The cap
|
||||
* limits the source prefix processed synchronously, then applies again where the
|
||||
* complete output — header, rendered body, and footer — is known.
|
||||
*
|
||||
* The tool registry calls this once through `output.render` and again through
|
||||
* `output.presentationMeta`, both with the same frozen result value; the
|
||||
* conversion is memoized per `(result, maxOutputChars)` so the synchronous DOM
|
||||
* parse and turndown walk run once, not twice, on the same body.
|
||||
*
|
||||
* @param result - the seam's fetch outcome.
|
||||
* @param maxOutputChars - cap on the complete returned string; a cut body gets
|
||||
* the same fetch-something-narrower notice as provider-side truncation.
|
||||
* @returns a `Fetched <url> (HTTP <status>)` header, the rendered body, and a
|
||||
* truncation notice when the provider or the cap cut the content.
|
||||
* @returns the complete `Fetched <url> (HTTP <status>)`-headed text and whether
|
||||
* the provider, a source cut, or the cap trimmed the content.
|
||||
*/
|
||||
export function formatFetchOutput(result: WebFetchResult, maxOutputChars: number): string {
|
||||
export function renderFetchOutput(result: WebFetchResult, maxOutputChars: number): RenderedFetch {
|
||||
const byCap = renderCache.get(result) ?? new Map<number, RenderedFetch>()
|
||||
const cached = byCap.get(maxOutputChars)
|
||||
if (cached !== undefined) return cached
|
||||
const computed = computeFetchOutput(result, maxOutputChars)
|
||||
byCap.set(maxOutputChars, computed)
|
||||
renderCache.set(result, byCap)
|
||||
return computed
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-result memo for {@link renderFetchOutput}, keyed first on the frozen
|
||||
* result value so a garbage-collected result drops its entry, then on the output
|
||||
* cap (a deployment constant per registration). Collapses the registry's twin
|
||||
* `render`/`presentationMeta` calls into one HTML→markdown conversion.
|
||||
*/
|
||||
const renderCache = new WeakMap<WebFetchResult, Map<number, RenderedFetch>>()
|
||||
|
||||
/**
|
||||
* The uncached conversion behind {@link renderFetchOutput}. Separated so the
|
||||
* memo wraps exactly one call site and the conversion logic stays pure.
|
||||
*
|
||||
* @param result - the seam's fetch outcome.
|
||||
* @param maxOutputChars - cap on the complete returned string.
|
||||
* @returns the bounded text and effective truncation.
|
||||
*/
|
||||
function computeFetchOutput(result: WebFetchResult, maxOutputChars: number): RenderedFetch {
|
||||
const header = `Fetched ${result.url} (HTTP ${result.statusCode})\n\n`
|
||||
const rendered = renderBody(result.body, maxOutputChars)
|
||||
const prefix = `${header}${rendered.text}`
|
||||
const truncated = result.truncated || rendered.sourceTruncated || prefix.length > maxOutputChars
|
||||
const full = `${prefix}${truncated ? TRUNCATION_FOOTER : ''}`
|
||||
if (full.length <= maxOutputChars) return full
|
||||
if (maxOutputChars < TRUNCATION_FOOTER.length) return full.slice(0, maxOutputChars)
|
||||
return `${prefix.slice(0, maxOutputChars - TRUNCATION_FOOTER.length)}${TRUNCATION_FOOTER}`
|
||||
if (full.length <= maxOutputChars) return { text: full, truncated }
|
||||
if (maxOutputChars < TRUNCATION_FOOTER.length) return { text: full.slice(0, maxOutputChars), truncated }
|
||||
return { text: `${prefix.slice(0, maxOutputChars - TRUNCATION_FOOTER.length)}${TRUNCATION_FOOTER}`, truncated }
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a fetch result as one model-facing text block, bounded as a whole.
|
||||
*
|
||||
* @param result - the seam's fetch outcome.
|
||||
* @param maxOutputChars - cap on the complete returned string.
|
||||
* @returns the complete text from {@link renderFetchOutput}.
|
||||
*/
|
||||
export function formatFetchOutput(result: WebFetchResult, maxOutputChars: number): string {
|
||||
return renderFetchOutput(result, maxOutputChars).text
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -284,33 +341,34 @@ export function presentFetchCall(args: { url: string }): GenericCallView {
|
||||
* header line. Attached opaquely (as `JsonValue`) on the tool result and
|
||||
* persisted with the session log, so `presentResult` reproduces the fetch card
|
||||
* on replay. The body itself is already markdown in the result content, so it is
|
||||
* not duplicated here.
|
||||
* not duplicated here. `truncated` is the effective truncation the render text
|
||||
* reflects, which a client cannot recompute (it does not know the deployment's
|
||||
* `fetchMaxOutputChars`); this is why fetch meta is carried, not derived from the
|
||||
* header line (see the web-result-card Agent Note).
|
||||
*/
|
||||
export interface WebFetchMeta {
|
||||
/** The final URL after allowed redirects. */
|
||||
url: string
|
||||
/** HTTP status code of the fetched response. */
|
||||
statusCode: number
|
||||
/** True when the provider or the output cap cut the content. */
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
/** The `web_fetch` canonical output value projected into presentation meta. */
|
||||
type WebFetchValue = {
|
||||
url: string
|
||||
statusCode: number
|
||||
/** True when the provider, a source cut, or the output cap trimmed the content. */
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a validated `web_fetch` output value into its replayable presentation
|
||||
* meta ({@link WebFetchMeta} as opaque JSON).
|
||||
* meta ({@link WebFetchMeta} as opaque JSON). `truncated` is the effective
|
||||
* truncation the model-facing text reflects (via {@link renderFetchOutput}), not
|
||||
* the provider-only `WebFetchResult.truncated`, so the fetch card never disagrees
|
||||
* with the returned text.
|
||||
*
|
||||
* @param value - the canonical `web_fetch` output value.
|
||||
* @returns the URL, status code, and truncation flag.
|
||||
* @param value - the canonical `web_fetch` output value (the seam's result shape).
|
||||
* @param maxOutputChars - the deployment's output cap, the same one
|
||||
* {@link formatFetchOutput} applies to the render text.
|
||||
* @returns the URL, status code, and effective truncation flag.
|
||||
*/
|
||||
export function fetchMetaFromValue(value: WebFetchValue): JsonValue {
|
||||
return { url: value.url, statusCode: value.statusCode, truncated: value.truncated }
|
||||
export function fetchMetaFromValue(value: WebFetchResult, maxOutputChars: number): JsonValue {
|
||||
return { url: value.url, statusCode: value.statusCode, truncated: renderFetchOutput(value, maxOutputChars).truncated }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -330,23 +388,27 @@ export function fetchMetaFromResult(meta: unknown): WebFetchMeta | undefined {
|
||||
|
||||
/**
|
||||
* Completed-call presentation: a `web` fetch card carrying the retrieval summary
|
||||
* from `meta` alongside the already-markdown body as fallback content.
|
||||
* from `meta`. It sets no `content` copy — a UI without the `web` capability
|
||||
* falls back to the raw `tool/result` content, the already-markdown body (see the
|
||||
* web-result-card Agent Note).
|
||||
*
|
||||
* @param args - the raw tool arguments; `url` becomes the result-state title so a
|
||||
* window-truncated replay that dropped the call head still has one.
|
||||
* @param result - the final model-facing tool result; `meta` carries the summary.
|
||||
* @returns the fetch result view, or `undefined` (generic card) on failure or
|
||||
* malformed meta.
|
||||
*/
|
||||
export function presentFetchResult(result: ToolResult): WebFetchResultView | undefined {
|
||||
export function presentFetchResult(args: { url: string }, result: ToolResult): WebFetchResultView | undefined {
|
||||
if (result.isError) return undefined
|
||||
const meta = fetchMetaFromResult(result.meta)
|
||||
if (meta === undefined) return undefined
|
||||
return {
|
||||
card: 'web',
|
||||
kind: 'fetch',
|
||||
title: args.url,
|
||||
url: meta.url,
|
||||
statusCode: meta.statusCode,
|
||||
truncated: meta.truncated,
|
||||
content: result.content,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,7 +467,7 @@ export function applyWebFetchTool(ctx: Context, timeoutMs: number, maxOutputChar
|
||||
},
|
||||
},
|
||||
render: (_args, value) => [{ type: 'text', text: formatFetchOutput(value, maxOutputChars) }],
|
||||
presentationMeta: (_args, value) => fetchMetaFromValue(value),
|
||||
presentationMeta: (_args, value) => fetchMetaFromValue(value, maxOutputChars),
|
||||
},
|
||||
timeoutMs,
|
||||
// Provider reads do not mutate parent-agent state.
|
||||
@@ -424,6 +486,6 @@ export function applyWebFetchTool(ctx: Context, timeoutMs: number, maxOutputChar
|
||||
}
|
||||
},
|
||||
presentCall: presentFetchCall,
|
||||
presentResult: (_args, result) => presentFetchResult(result),
|
||||
presentResult: (args, result) => presentFetchResult(args, result),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { applyWebFetchTool } from './fetch.ts'
|
||||
|
||||
export { WEB_SEARCH_MAX_RESULTS, applyWebSearchTool, formatSearchOutput, parseSearchArgs, presentSearchCall, presentSearchResult, searchMetaFromValue, searchMetaFromResult } from './search.ts'
|
||||
export type { WebSearchMeta } from './search.ts'
|
||||
export { applyWebFetchTool, formatFetchOutput, parseFetchArgs, presentFetchCall, presentFetchResult, fetchMetaFromValue, fetchMetaFromResult } from './fetch.ts'
|
||||
export { applyWebFetchTool, formatFetchOutput, renderFetchOutput, parseFetchArgs, presentFetchCall, presentFetchResult, fetchMetaFromValue, fetchMetaFromResult } from './fetch.ts'
|
||||
export type { WebFetchMeta } from './fetch.ts'
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
|
||||
@@ -88,36 +88,27 @@ export function presentSearchCall(args: { query: string }): GenericCallView {
|
||||
* The `web_search` tool's private `tool/result` `meta` payload: the structured
|
||||
* sources, the optional provider answer, and the truncation flag. Attached
|
||||
* opaquely (as `JsonValue`) on the tool result and persisted with the session
|
||||
* log, so `presentResult` reproduces the search card on replay. The render text
|
||||
* is lossy — its markdown source list collapses each source's title, snippet,
|
||||
* and date into one free-text line labelled by title OR hostname — so reparsing
|
||||
* that text cannot recover the per-source fields; this projection is the only
|
||||
* faithful route to them.
|
||||
* log, so `presentResult` reproduces the search card on replay. This projection
|
||||
* is the only faithful route to the per-source fields, which the lossy render
|
||||
* text cannot carry (the owning rationale is the web-result-card Agent Note).
|
||||
*/
|
||||
export interface WebSearchMeta {
|
||||
/** The faithful structured sources, in result order. */
|
||||
sources: WebSource[]
|
||||
/** True when the tool cut the source list to its result cap. */
|
||||
/** True when the seam cut the source list to honor the result cap. */
|
||||
truncated: boolean
|
||||
/** The provider-generated answer text, when any. */
|
||||
answer?: string
|
||||
}
|
||||
|
||||
/** The `web_search` canonical output value projected into presentation meta. */
|
||||
type WebSearchValue = {
|
||||
content?: string
|
||||
sources: readonly WebSource[]
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a validated `web_search` output value into its replayable
|
||||
* presentation meta ({@link WebSearchMeta} as opaque JSON).
|
||||
*
|
||||
* @param value - the canonical `web_search` output value.
|
||||
* @param value - the canonical `web_search` output value (the seam's result shape).
|
||||
* @returns the structured sources, the truncation flag, and the answer when present.
|
||||
*/
|
||||
export function searchMetaFromValue(value: WebSearchValue): JsonValue {
|
||||
export function searchMetaFromValue(value: WebSearchResult): JsonValue {
|
||||
return {
|
||||
sources: value.sources.map(source => ({
|
||||
url: source.url,
|
||||
@@ -163,24 +154,27 @@ export function searchMetaFromResult(meta: unknown): WebSearchMeta | undefined {
|
||||
|
||||
/**
|
||||
* Completed-call presentation: a `web` search card carrying the faithful
|
||||
* structured sources from `meta` alongside the model-facing text as fallback
|
||||
* content.
|
||||
* structured sources from `meta`. It sets no `content` copy — a UI without the
|
||||
* `web` capability falls back to the raw `tool/result` content, which is the
|
||||
* same text (see the web-result-card Agent Note).
|
||||
*
|
||||
* @param args - the raw tool arguments; `query` becomes the result-state title so
|
||||
* a window-truncated replay that dropped the call head still has one.
|
||||
* @param result - the final model-facing tool result; `meta` carries the sources.
|
||||
* @returns the search result view, or `undefined` (generic card) on failure or
|
||||
* malformed meta.
|
||||
*/
|
||||
export function presentSearchResult(result: ToolResult): WebSearchResultView | undefined {
|
||||
export function presentSearchResult(args: { query: string }, result: ToolResult): WebSearchResultView | undefined {
|
||||
if (result.isError) return undefined
|
||||
const meta = searchMetaFromResult(result.meta)
|
||||
if (meta === undefined) return undefined
|
||||
return {
|
||||
card: 'web',
|
||||
kind: 'search',
|
||||
title: args.query,
|
||||
sources: meta.sources,
|
||||
truncated: meta.truncated,
|
||||
...meta.answer !== undefined ? { answer: meta.answer } : {},
|
||||
content: result.content,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,6 +248,6 @@ export function applyWebSearchTool(ctx: Context, maxResults: number, timeoutMs:
|
||||
}
|
||||
},
|
||||
presentCall: presentSearchCall,
|
||||
presentResult: (_args, result) => presentSearchResult(result),
|
||||
presentResult: (args, result) => presentSearchResult(args, result),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -140,35 +140,36 @@ describe('web_search presentation meta and result view', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('presents a completed search as a web/search card carrying the structured sources and fallback content', () => {
|
||||
it('presents a completed search as a web/search card carrying the structured sources, titled by the query', () => {
|
||||
const meta = searchMetaFromValue({
|
||||
content: 'an answer', truncated: true,
|
||||
sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }],
|
||||
})
|
||||
expect(presentSearchResult(toolResult(meta, 'rendered'))).toEqual({
|
||||
expect(presentSearchResult({ query: 'q' }, toolResult(meta, 'rendered'))).toEqual({
|
||||
card: 'web',
|
||||
kind: 'search',
|
||||
title: 'q',
|
||||
answer: 'an answer',
|
||||
truncated: true,
|
||||
sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }],
|
||||
content: [{ type: 'text', text: 'rendered' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('omits the answer from the view when meta carries none', () => {
|
||||
const meta = searchMetaFromValue({ truncated: false, sources: [{ url: 'https://a.test' }] })
|
||||
const view = presentSearchResult(toolResult(meta))
|
||||
const view = presentSearchResult({ query: 'q' }, toolResult(meta))
|
||||
expect(view).toBeDefined()
|
||||
expect(view && 'answer' in view).toBe(false)
|
||||
expect(view && 'content' in view).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to the generic card on an error result', () => {
|
||||
const meta = searchMetaFromValue({ truncated: false, sources: [{ url: 'https://a.test' }] })
|
||||
expect(presentSearchResult(toolResult(meta, 'body', true))).toBeUndefined()
|
||||
expect(presentSearchResult({ query: 'q' }, toolResult(meta, 'body', true))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to the generic card on absent or malformed meta', () => {
|
||||
expect(presentSearchResult(toolResult(undefined))).toBeUndefined()
|
||||
expect(presentSearchResult({ query: 'q' }, toolResult(undefined))).toBeUndefined()
|
||||
expect(searchMetaFromResult(undefined)).toBeUndefined()
|
||||
expect(searchMetaFromResult(null)).toBeUndefined()
|
||||
expect(searchMetaFromResult('nope')).toBeUndefined()
|
||||
@@ -358,30 +359,75 @@ describe('fetch formatting', () => {
|
||||
})
|
||||
|
||||
describe('web_fetch presentation meta and result view', () => {
|
||||
it('projects url, status, and truncation into meta', () => {
|
||||
expect(fetchMetaFromValue({ url: 'https://a.test', statusCode: 404, truncated: true }))
|
||||
const NO_CAP = 1_000_000
|
||||
|
||||
it('projects url, status, and the provider truncation into meta', () => {
|
||||
expect(fetchMetaFromValue({ url: 'https://a.test', statusCode: 404, truncated: true, body: { kind: 'text', content: 'x' } }, NO_CAP))
|
||||
.toEqual({ url: 'https://a.test', statusCode: 404, truncated: true })
|
||||
})
|
||||
|
||||
it('presents a completed fetch as a web/fetch card carrying the summary and the markdown body as fallback content', () => {
|
||||
const meta = fetchMetaFromValue({ url: 'https://a.test', statusCode: 200, truncated: false })
|
||||
expect(presentFetchResult(toolResult(meta, '# Title'))).toEqual({
|
||||
it('projects truncated: true when the output cap cut a body the provider did not, matching the render footer', () => {
|
||||
// The provider reports truncated: false, but conversion outgrows the cap, so
|
||||
// the render text carries the truncation footer. The meta must agree.
|
||||
const value = {
|
||||
url: 'https://a.test', statusCode: 200, truncated: false,
|
||||
body: { kind: 'html' as const, content: `<p>${'_'.repeat(1000)}</p>` },
|
||||
}
|
||||
const meta = fetchMetaFromValue(value, 500) as { truncated: boolean }
|
||||
expect(meta.truncated).toBe(true)
|
||||
expect(formatFetchOutput(value, 500)).toContain('Content truncated')
|
||||
})
|
||||
|
||||
it('projects truncated: false when neither the provider nor the cap cut the body', () => {
|
||||
const value = {
|
||||
url: 'https://a.test', statusCode: 200, truncated: false,
|
||||
body: { kind: 'text' as const, content: 'short' },
|
||||
}
|
||||
const meta = fetchMetaFromValue(value, NO_CAP) as { truncated: boolean }
|
||||
expect(meta.truncated).toBe(false)
|
||||
expect(formatFetchOutput(value, NO_CAP)).not.toContain('Content truncated')
|
||||
})
|
||||
|
||||
it('converts one HTML body once across the render and meta projections of the same result', () => {
|
||||
// The registry calls output.render and output.presentationMeta with the same
|
||||
// frozen result value; the memo must collapse them into one turndown walk so
|
||||
// a large or deeply nested page is not parsed and converted twice. A second
|
||||
// cap on the same result is a distinct entry, so it converts again.
|
||||
const spy = vi.spyOn(TurndownService.prototype, 'turndown')
|
||||
const value = {
|
||||
url: 'https://a.test', statusCode: 200, truncated: false,
|
||||
body: { kind: 'html' as const, content: '<p>hello</p>' },
|
||||
}
|
||||
try {
|
||||
formatFetchOutput(value, NO_CAP)
|
||||
fetchMetaFromValue(value, NO_CAP)
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
formatFetchOutput(value, NO_CAP - 1)
|
||||
expect(spy).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('presents a completed fetch as a web/fetch card carrying the summary, titled by the url, without content', () => {
|
||||
const meta = fetchMetaFromValue({ url: 'https://a.test', statusCode: 200, truncated: false, body: { kind: 'text', content: '# Title' } }, NO_CAP)
|
||||
expect(presentFetchResult({ url: 'https://a.test' }, toolResult(meta, '# Title'))).toEqual({
|
||||
card: 'web',
|
||||
kind: 'fetch',
|
||||
title: 'https://a.test',
|
||||
url: 'https://a.test',
|
||||
statusCode: 200,
|
||||
truncated: false,
|
||||
content: [{ type: 'text', text: '# Title' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the generic card on an error result', () => {
|
||||
const meta = fetchMetaFromValue({ url: 'https://a.test', statusCode: 200, truncated: false })
|
||||
expect(presentFetchResult(toolResult(meta, 'body', true))).toBeUndefined()
|
||||
const meta = fetchMetaFromValue({ url: 'https://a.test', statusCode: 200, truncated: false, body: { kind: 'text', content: 'ok' } }, NO_CAP)
|
||||
expect(presentFetchResult({ url: 'https://a.test' }, toolResult(meta, 'body', true))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to the generic card on absent or malformed meta', () => {
|
||||
expect(presentFetchResult(toolResult(undefined))).toBeUndefined()
|
||||
expect(presentFetchResult({ url: 'https://a.test' }, toolResult(undefined))).toBeUndefined()
|
||||
expect(fetchMetaFromResult(undefined)).toBeUndefined()
|
||||
expect(fetchMetaFromResult(null)).toBeUndefined()
|
||||
expect(fetchMetaFromResult('nope')).toBeUndefined()
|
||||
|
||||
Reference in New Issue
Block a user