Merge pull request #4451 from deepseek-harness/feat/developer-tools-preference
feat(client): add shared developer tools setting
This commit is contained in:
commit
215226753d
121 files changed
+1073
-177
No files matched your search
+2
-2
@@ -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 .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md
|
||||
2026-09-08-document-preview-operations.md: 980e2e443db13d6a956d554172bc765a5c11b435
|
||||
2026-09-08-document-preview-operations.zh.md: 364ef9ced6f8d98313f9bc287588c98daa408837
|
||||
2026-09-08-document-preview-operations.md: be3a835a6296cb28d9a90c5c5e8b9bf7367598c2
|
||||
2026-09-08-document-preview-operations.zh.md: efb87e16cc126001c1a6eb2bf938124d7e00044a
|
||||
@@ -18,7 +18,7 @@ Readable files use `dsh-resource://file/session/<sessionId>/<path>`. The path ma
|
||||
|
||||
[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.md) owns format selection and loading policy. Metadata registers with `ctx.documentPreviews`; components register separately into the keyed `sidebar.right.tab.document` Slot. Extension registrations precede builtins, then longer suffixes and registration order decide. The toolbar lists supported alternatives and remembers a manual choice per tab. Plain text remains available for unknown extensions and text-compatible renderers. Registered binary suffixes determine text compatibility independently of loading mode; HTML and SVG remain outside those suffixes and retain source viewing. A single candidate renders no viewer control. Registered binary suffixes omit plain text; known binary suffixes without a renderer show an unsupported state without reading the file ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The child receives accumulated text or complete native bytes, the original resource address, and the standard `useResource` and `useTabInfo` hooks. Preview calls existing `read`, `readAll`, and `readRelated` through ordinary injection and decodes bytes in its own `rpc.ts`. Refresh remains per tab, with no resource reload, shared `changed` acknowledgement, extra resource wrapper, or content Session.
|
||||
|
||||
Markdown and code reuse the incremental primitives with cumulative paged text. HTML, PDF, and images read complete `Uint8Array<ArrayBuffer>` data; Host transport remains base64. Published buffers are borrowed read-only and never persist into layout or Session JSON. PDF.js runs in an owned Worker with version-matched bundled font and decoder data, and copies input before transfer to preserve Preview's retained buffer. HTML runs in a Blob iframe with `sandbox="allow-scripts"`, without same-origin, popup, form, download, or top-navigation privileges. The browser retains its normal external-network rules. Bounded static local JS/CSS reads stay in the parent; the opaque frame creates its own asset Blobs, because it cannot load parent-origin Blobs. PNG, JPEG, GIF, WebP, BMP, ICO, and SVG use image-specific Blob URLs in an `<img>` static-image context. An image wider than the pane scales down to its width at its aspect ratio; a smaller image keeps its intrinsic CSS-pixel dimensions centred by auto margins, and a taller image extends the shared scroller's vertical range ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The renderer provides no zoom or drag-to-pan. SVG markup never enters the application DOM or an iframe, so scripts remain inert and cannot reach the parent page. Replacing HTML or an image revokes its root Blob URL.
|
||||
Markdown and code reuse the incremental primitives with cumulative paged text. HTML, PDF, and images read complete `Uint8Array<ArrayBuffer>` data; Host transport remains base64. Published buffers are borrowed read-only and never persist into layout or Session JSON. PDF.js runs in an owned Worker with version-matched bundled font and decoder data, and copies input before transfer to preserve Preview's retained buffer. With [Developer tools](../feature/2026-09-17-developer-tools-settings.md) enabled, HTML runs in a Blob iframe with `sandbox="allow-scripts"`, without same-origin, popup, form, download, or top-navigation privileges. The browser retains its normal external-network rules. Bounded static local JS/CSS reads stay in the parent; the opaque frame creates its own asset Blobs, because it cannot load parent-origin Blobs. PNG, JPEG, GIF, WebP, BMP, ICO, and SVG use image-specific Blob URLs in an `<img>` static-image context. An image wider than the pane scales down to its width at its aspect ratio; a smaller image keeps its intrinsic CSS-pixel dimensions centred by auto margins, and a taller image extends the shared scroller's vertical range ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The renderer provides no zoom or drag-to-pan. SVG markup never enters the application DOM or an iframe, so scripts remain inert and cannot reach the parent page. Replacing HTML or an image revokes its root Blob URL.
|
||||
|
||||
PDF.js's official TextLayerBuilder owns selection boundaries and copy normalization over the width-fitted canvas, with shared page cleanup and a component-owned resize observer. Responsive sizing uses the CSS `scale` property independently of PDF.js's page rotation and translation transforms. Its end-of-content marker and stacking rules constrain selection in blank regions; line-break highlighting is suppressed. Per-page cancellation uses the builder's cleanup rather than aborting the first page's signal, because the official selection listeners are shared across pages.
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Document Preview 将资源观察与内容读取分开。[资源模型](2026-09-0
|
||||
|
||||
[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md) 负责格式选择和加载策略。元数据通过 `ctx.documentPreviews` 注册;组件单独注册到 keyed `sidebar.right.tab.document` Slot。扩展注册优先于内置注册,其次比较后缀长度和注册顺序。工具栏列出受支持的候选,按 tab 记住手动选择。未知扩展名及文本兼容的渲染器仍可使用纯文本。文本兼容性由注册的二进制后缀决定,与加载方式无关;HTML 和 SVG 不属于二进制后缀,保留源码查看。只有一个候选时不显示查看器控件。注册声明为二进制的后缀不提供纯文本;已知二进制后缀没有注册渲染器时不发起读取,并显示不支持预览的空态([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。子组件收到累积文本或完整原生字节、原始资源地址,以及标准 `useResource` 和 `useTabInfo` 钩子。Preview 经普通注入调用既有 `read`、`readAll` 与 `readRelated`,在自己的 `rpc.ts` 解码字节。刷新仍按 tab 独立进行,不引入资源 reload、共享 `changed` 确认、额外资源包装层或内容 Session。
|
||||
|
||||
Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML、PDF 和图片读取完整 `Uint8Array<ArrayBuffer>` 数据;Host 传输保持 base64。发布后的缓冲区只读借用,绝不持久化进布局或 Session JSON。PDF.js 在自有 Worker 中运行,字体和解码数据以相同版本随包发布,转移输入前先复制,以保留 Preview 的缓冲区。HTML 在 Blob iframe 中运行,设置 `sandbox="allow-scripts"`,不授予同源、弹窗、表单、下载或顶层导航权限。浏览器保持正常的外部网络规则。有上限的静态本地 JS/CSS 读取由父页面负责;不透明源 iframe 创建自己的资源 Blob,因为它不能加载父源创建的 Blob。PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 使用图片专用 Blob URL,在 `<img>` 静态图片上下文中渲染。比面板宽的图片按纵横比缩小到面板宽度;较小的图片保留固有 CSS 像素尺寸并由 auto margin 居中,较高的图片扩展共享滚动区的纵向范围([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。渲染器不提供缩放或拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此脚本保持不可执行,也无法访问父页面。替换 HTML 或图片时会撤销其根 Blob URL。
|
||||
Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML、PDF 和图片读取完整 `Uint8Array<ArrayBuffer>` 数据;Host 传输保持 base64。发布后的缓冲区只读借用,绝不持久化进布局或 Session JSON。PDF.js 在自有 Worker 中运行,字体和解码数据以相同版本随包发布,转移输入前先复制,以保留 Preview 的缓冲区。开启[开发者工具](../feature/2026-09-17-developer-tools-settings.zh.md)时,HTML 在 Blob iframe 中运行,设置 `sandbox="allow-scripts"`,不授予同源、弹窗、表单、下载或顶层导航权限。浏览器保持正常的外部网络规则。有上限的静态本地 JS/CSS 读取由父页面负责;不透明源 iframe 创建自己的资源 Blob,因为它不能加载父源创建的 Blob。PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 使用图片专用 Blob URL,在 `<img>` 静态图片上下文中渲染。比面板宽的图片按纵横比缩小到面板宽度;较小的图片保留固有 CSS 像素尺寸并由 auto margin 居中,较高的图片扩展共享滚动区的纵向范围([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。渲染器不提供缩放或拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此脚本保持不可执行,也无法访问父页面。替换 HTML 或图片时会撤销其根 Blob URL。
|
||||
|
||||
PDF.js 官方 TextLayerBuilder 在适配宽度的 canvas 上负责选择边界和复制规范化,共享页面清理,并使用由组件拥有的 resize observer。响应式尺寸适配使用独立的 CSS `scale` 属性,与 PDF.js 的页面旋转和平移变换组合。其内容结束标记和堆叠规则限制空白区域中的选择;换行高亮被抑制。逐页取消使用 builder 的清理操作,而不 abort 第一页的信号,因为官方选择监听器跨页面共享。
|
||||
|
||||
|
||||
+2
-2
@@ -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 .agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.md
|
||||
2026-09-09-workspace-file-read-authority.md: 35ac15f5e463910be3b3a0cfe7b80dc69c129ad3
|
||||
2026-09-09-workspace-file-read-authority.zh.md: 559a59af0bfa988ff880fad22470397e4e74f296
|
||||
2026-09-09-workspace-file-read-authority.md: d1a5b9d9634381cb74b17651c67f2b701402ff1c
|
||||
2026-09-09-workspace-file-read-authority.zh.md: f8b0fae4325a90548c6b9dc33ac46b17248b04e7
|
||||
@@ -14,7 +14,7 @@ Workspace Files serves both file content and workspace navigation. Applying work
|
||||
|
||||
`list` and `changes` remain workspace-scoped because they expose workspace navigation and observation rather than a named file read. `list` rejects a directory outside the root, and `changes` filters observations through the backend's workspace-containment predicate.
|
||||
|
||||
`readRelated` resolves a relative path from the base file's directory. A `..` path may therefore read JavaScript or CSS outside the workspace when the Session backend permits it. Document Preview packages bounded, statically declared local scripts and stylesheets into an HTML Blob iframe with `sandbox="allow-scripts"`; the opaque origin blocks parent access, but the browser retains normal network access. This exposure is an intentional security trade-off for rendering static generated HTML.
|
||||
`readRelated` resolves a relative path from the base file's directory. A `..` path may therefore read JavaScript or CSS outside the workspace when the Session backend permits it. With [Developer tools](../feature/2026-09-17-developer-tools-settings.md) enabled, Document Preview packages bounded, statically declared local scripts and stylesheets into an HTML Blob iframe with `sandbox="allow-scripts"`; the opaque origin blocks parent access, but the browser retains normal network access. This exposure is an intentional security trade-off for rendering static generated HTML.
|
||||
|
||||
The [Workspace Files service](2026-09-05-workspace-files-service.md) owns paging, file checks, listing, and observation. [Document Preview](2026-09-08-document-preview-operations.md) owns which related files are packaged and the iframe sandbox.
|
||||
|
||||
@@ -26,4 +26,4 @@ The [Workspace Files service](2026-09-05-workspace-files-service.md) owns paging
|
||||
|
||||
## Consequences
|
||||
|
||||
Any caller holding a valid Session file address can receive bytes from every regular file that the Session filesystem backend permits it to read, including files outside the workspace. A previewed HTML document can execute packaged local JavaScript and make network requests. Outside files do not produce `changes` frames, so their previews require explicit refresh to observe updates.
|
||||
Any caller holding a valid Session file address can receive bytes from every regular file that the Session filesystem backend permits it to read, including files outside the workspace. With Developer tools enabled, a previewed HTML document can execute packaged local JavaScript and make network requests. Outside files do not produce `changes` frames, so their previews require explicit refresh to observe updates.
|
||||
+2
-2
@@ -14,7 +14,7 @@ Workspace Files 同时提供文件内容与工作区导航。对所有操作应
|
||||
|
||||
`list` 与 `changes` 仍限于工作区,因为它们暴露工作区导航和观察,而不是读取一个具名文件。`list` 拒绝根外目录,`changes` 通过后端的工作区包含判定过滤观察。
|
||||
|
||||
`readRelated` 从基准文件所在目录解析相对路径。因此,只要 Session 后端允许,`..` 路径就可以读取工作区外的 JavaScript 或 CSS。Document Preview 把有界、静态声明的本地脚本与样式表打包进带 `sandbox="allow-scripts"` 的 HTML Blob iframe;不透明源阻止访问父应用,但浏览器保留正常网络访问。这种暴露是为渲染静态生成 HTML 而有意接受的安全取舍。
|
||||
`readRelated` 从基准文件所在目录解析相对路径。因此,只要 Session 后端允许,`..` 路径就可以读取工作区外的 JavaScript 或 CSS。开启[开发者工具](../feature/2026-09-17-developer-tools-settings.zh.md)时,Document Preview 把有界、静态声明的本地脚本与样式表打包进带 `sandbox="allow-scripts"` 的 HTML Blob iframe;不透明源阻止访问父应用,但浏览器保留正常网络访问。这种暴露是为渲染静态生成 HTML 而有意接受的安全取舍。
|
||||
|
||||
[Workspace Files 服务](2026-09-05-workspace-files-service.zh.md)负责分页、文件检查、列举和观察。[Document Preview](2026-09-08-document-preview-operations.zh.md)负责选择要打包的关联文件及 iframe sandbox。
|
||||
|
||||
@@ -26,4 +26,4 @@ Workspace Files 同时提供文件内容与工作区导航。对所有操作应
|
||||
|
||||
## Consequences
|
||||
|
||||
持有有效 Session 文件地址的调用方可以接收 Session 文件系统后端允许读取的每个普通文件的字节,包括工作区外文件。预览的 HTML 文档可以执行已打包的本地 JavaScript,并发起网络请求。工作区外文件不会产生 `changes` 帧,因此其预览需要显式刷新才能观察更新。
|
||||
持有有效 Session 文件地址的调用方可以接收 Session 文件系统后端允许读取的每个普通文件的字节,包括工作区外文件。开启开发者工具时,预览的 HTML 文档可以执行已打包的本地 JavaScript,并发起网络请求。工作区外文件不会产生 `changes` 帧,因此其预览需要显式刷新才能观察更新。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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 .agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md
|
||||
2026-09-17-developer-tools-settings.md: e76cb7f367faff71ef916d8a263dc523583f1cd2
|
||||
2026-09-17-developer-tools-settings.zh.md: 5a7d6d59db686f57ef4945b572ea65ffe54b7f9c
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: Shared developer-tool settings
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-17-developer-tools-settings.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Diagnostic views, preset selection and changed-file summaries add complexity to routine tasks. Scripted HTML also grants capabilities that a basic document preview does not need. Desktop and Web share these renderers, so independent switches would let the same setting produce different results across clients.
|
||||
|
||||
## Decision
|
||||
|
||||
The settings-owned `ctx.settingsScope.developerTools` preference binds a namespace scope and exposes one accepted `ui-developer-tools.enabled` preference, defaulting off. General Settings offers the same switch on Web and desktop. The existing settings transport owns validation, ordered writes, persistence and recovery; loopback Web and desktop follow Host acceptance. Remote Web owns one browser-local observable shared by all consumers until reload, with no Host writes.
|
||||
|
||||
Off exposes only Chat, hides tool Inspect actions when no visible View declares tool-call inspection, removes the conversation View tab bar, hides the new-session preset chip, and omits the changed-files card and its summary read. Turning it off from another View activates Chat. The preset composition, saved default, Session events and explicit delivery cards remain unchanged. This preference is presentation policy, not Host authorization.
|
||||
|
||||
Plugin assembly translates the shared preference into component-specific capabilities (`showCodeDiff`, `showPresetPicker`, and `interactivePreview`). Conversation supplies the inspection callback using the visible target's `toolCallFocus`; Chat does not name the target. The builtin HTML renderer uses DOMPurify for complete-document sanitization and parser-mutation defenses. Off preserves root styling, removes active documents, resource hints, SVG animation and all `href` attributes, and mounts an iframe with no sandbox permissions and a CSP denying scripts and external resources. It reads no related files. On retains the opaque scripted Blob renderer and its bounded related-file reads. Switching modes replaces the browsing context and disposes pending reads. The [document-preview decision](../architecture/2026-09-08-document-preview-operations.md) and [filesystem-read authority](../architecture/2026-09-09-workspace-file-read-authority.md) remain authoritative for advanced rendering and Host read access.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Limit the switch to desktop.** The selected requirement contains no desktop-only restriction, and the affected UI is shared. A desktop check would leave the Web renderer with inconsistent visibility and preview behavior.
|
||||
|
||||
**Hide controls without changing the active View or iframe.** Previously selected diagnostic content and already-running scripts would survive the toggle. Filtering the available View roster and remounting the HTML context enforce the selected presentation immediately.
|
||||
|
||||
## Consequences
|
||||
|
||||
Basic HTML sacrifices JavaScript, external and related resources, and link navigation; inline styles and data images remain available. Advanced mode retains normal browser networking and does not gain parent-origin, popup, form or top-navigation privileges. The setting does not constrain third-party preview implementations or revoke Host filesystem authority.
|
||||
|
||||
Component and settings tests cover defaulting, schema rejection, accepted-state updates, chip disclosure, changed-file visibility and iframe disposal. The recorded-session [document preview](../../../../apps/web/tests/document-preview.e2e.ts) and [changed-files](../../../../apps/web/tests/changed-files-turn.e2e.ts) scenarios exercise the real settings control and the shared renderer. The browser scaffold enables developer tools by default. Only settings chrome, document preview, changed-files and developer-tools scenarios explicitly retain the shipped default; together they cover hidden diagnostic controls, static previews, and switching back to Chat. Refused Host writes recover accepted state and show the localized retry message. Native Electron execution remains unverified.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: 共享开发者工具设置
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-17-developer-tools-settings.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
诊断视图、预设选择和改动文件摘要增加了日常任务的复杂度。支持脚本的 HTML 还授予了基础文档预览不需要的能力。桌面端和 Web 共享这些渲染器,分别设置开关会让同一设置在不同客户端产生不同结果。
|
||||
|
||||
## Decision
|
||||
|
||||
设置域内的 `ctx.settingsScope.developerTools` 偏好对象绑定命名空间 scope,提供一个已接受的 `ui-developer-tools.enabled` 偏好,默认关闭。Web 和桌面端的通用设置提供同一个开关。现有设置传输负责验证、有序写入、持久化和恢复;回环 Web 和桌面端跟随 Host 接受的状态。远程 Web 持有一个所有消费者共享的浏览器本地可观察值,刷新后重置,不发送 Host 写入。
|
||||
|
||||
关闭时仅展示 Chat,在没有可见 View 声明工具调用检查能力时隐藏 Inspect 操作,移除会话 View 标签栏,隐藏新会话预设入口,并省略改动文件卡片及其摘要读取。在其他 View 中关闭开关会激活 Chat。预设组合、保存的默认值、Session 事件和显式交付卡片保持不变。此偏好是展示策略,不是 Host 授权。
|
||||
|
||||
插件组装层将共享偏好转换为组件自身的能力(`showCodeDiff`、`showPresetPicker` 和 `interactivePreview`)。Conversation 根据可见目标的 `toolCallFocus` 提供检查回调;Chat 不再指定目标名称。内置 HTML 渲染器使用 DOMPurify 清理完整文档并防护解析变异。关闭时保留根节点样式,移除主动文档、资源提示、SVG 动画及所有 `href` 属性,再放入不授予沙箱权限的 iframe,并通过 CSP 禁止脚本和外部资源。不读取关联文件。开启时保留不透明源的脚本 Blob 渲染器及其有界关联文件读取。切换模式会替换浏览上下文并清理待处理读取。[文档预览决策](../architecture/2026-09-08-document-preview-operations.zh.md)和[文件系统读取权限](../architecture/2026-09-09-workspace-file-read-authority.zh.md)继续定义高级渲染与 Host 读取访问。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**仅在桌面端提供开关。** 所选需求没有仅限桌面的约束,相关 UI 也是共享的。桌面端条件判断会让 Web 渲染器的展示和预览行为不一致。
|
||||
|
||||
**隐藏控件但不改变激活的 View 或 iframe。** 之前选择的诊断内容和已经运行的脚本会在切换后继续存在。过滤可用 View 并重新挂载 HTML 上下文,可以立即执行所选展示策略。
|
||||
|
||||
## Consequences
|
||||
|
||||
基础 HTML 放弃 JavaScript、外部与关联资源和链接导航;行内样式与 data 图片仍可用。高级模式保留正常浏览器网络能力,不增加父源、弹窗、表单或顶层导航权限。此设置不约束第三方预览实现,也不撤销 Host 文件系统读取权限。
|
||||
|
||||
组件和设置测试覆盖默认值、schema 拒绝、已接受状态更新、入口折叠、改动文件展示和 iframe 清理。基于已记录 Session 的[文档预览](../../../../apps/web/tests/document-preview.e2e.ts)与[改动文件](../../../../apps/web/tests/changed-files-turn.e2e.ts)场景验证真实设置控件及共用渲染器。浏览器脚手架默认开启开发者工具,仅设置外壳、文档预览、改动文件和开发者工具场景显式保留产品默认值;这些场景共同覆盖诊断控件隐藏、静态预览和切回 Chat。Host 拒绝写入后会恢复已接受状态,并显示本地化重试提示。原生 Electron 执行尚未验证。
|
||||
@@ -76,6 +76,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
|
||||
| [`commander`](https://github.com/tj/commander.js) | MIT |
|
||||
| [`compression`](https://github.com/expressjs/compression) | MIT |
|
||||
| [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause |
|
||||
| [`dompurify`](https://github.com/cure53/DOMPurify) | (MPL-2.0 OR Apache-2.0) |
|
||||
| [`electron-updater`](https://github.com/electron-userland/electron-builder) | MIT |
|
||||
| [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT |
|
||||
| [`execa`](https://github.com/sindresorhus/execa) | MIT |
|
||||
|
||||
@@ -71,7 +71,14 @@ interface ControlBaseline {
|
||||
|
||||
interface CapturedFixture {
|
||||
readonly sessionList: { readonly ok: true; readonly value: { readonly items: readonly SessionSummary[] } }
|
||||
readonly settingsDescribe: unknown
|
||||
readonly settingsDescribe: {
|
||||
readonly ok: true
|
||||
readonly value: {
|
||||
readonly writable: boolean
|
||||
readonly hasDocument: boolean
|
||||
readonly namespaces: readonly unknown[]
|
||||
}
|
||||
}
|
||||
readonly credentialsDescribe: unknown
|
||||
readonly modelCatalog: unknown
|
||||
readonly agentPresets: unknown
|
||||
@@ -90,6 +97,8 @@ interface CapturedFixture {
|
||||
}
|
||||
|
||||
export interface AssembledRemoteOptions {
|
||||
/** Supply an enabled Host preference for diagnostic View scenarios. */
|
||||
readonly developerTools?: boolean
|
||||
/** Return the fixture's image-dimension admission error from Session prompt. */
|
||||
readonly rejectPrompt?: boolean
|
||||
}
|
||||
@@ -131,7 +140,16 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass
|
||||
const mock = RemoteMock.create().load(remoteDefaultResponses)
|
||||
mock.load({
|
||||
unary: {
|
||||
'settings/describe': structuredClone(fixture.settingsDescribe),
|
||||
'settings/describe': options.developerTools === true
|
||||
? ok({
|
||||
...fixture.settingsDescribe.value,
|
||||
namespaces: [...fixture.settingsDescribe.value.namespaces, {
|
||||
ns: 'ui-developer-tools',
|
||||
schema: { type: 'object', dict: { enabled: { type: 'boolean' } } },
|
||||
value: { enabled: true }, applies: 'live', secrets: [], revision: 0,
|
||||
}],
|
||||
})
|
||||
: structuredClone(fixture.settingsDescribe),
|
||||
'credentials/describe': structuredClone(fixture.credentialsDescribe),
|
||||
'session/modelCatalog': structuredClone(fixture.modelCatalog),
|
||||
'agentPresets/list': structuredClone(fixture.agentPresets),
|
||||
|
||||
@@ -51,6 +51,7 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
|
||||
await writeFile(replayOverride, JSON.stringify(script).replaceAll('{{cwd}}', JSON.stringify(cwdToken).slice(1, -1)))
|
||||
}
|
||||
scaffold = await launchWebScaffold({
|
||||
developerTools: false,
|
||||
compareReplaySession: true,
|
||||
extraOverlayPath: fileURLToPath(new URL('./changed-files-turn.overlay.yml', import.meta.url)),
|
||||
...(replayOverride === undefined ? {} : { replayFixture: FIXTURE, replayOverride }),
|
||||
@@ -106,6 +107,18 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
|
||||
expect(await readFile(join(cwd, 'notes.txt'), 'utf8')).toBe('start\ndone\n')
|
||||
|
||||
const card = page.locator('[data-changed-files]')
|
||||
expect(await card.count()).toBe(0)
|
||||
const header = page.locator('header').filter({ has: page.locator('[data-conversation-header-leading]') })
|
||||
expect(await header.getByRole('tablist').count()).toBe(0)
|
||||
const compactHeight = await header.evaluate(element => element.getBoundingClientRect().height)
|
||||
expect(compactHeight).toBeLessThan(60)
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
const settings = page.getByRole('dialog', { name: '设置' })
|
||||
await settings.getByRole('switch', { name: '开发者工具' }).click()
|
||||
await expect.poll(() => settings.getByRole('switch', { name: '开发者工具' }).getAttribute('aria-checked')).toBe('true')
|
||||
await settings.getByRole('button', { name: '关闭', exact: true }).click()
|
||||
await header.getByRole('tablist').waitFor({ state: 'visible' })
|
||||
expect(await header.evaluate(element => element.getBoundingClientRect().height)).toBeGreaterThan(compactHeight)
|
||||
await card.waitFor({ state: 'visible' })
|
||||
expect(await card.getByText('已编辑 4 个文件', { exact: true }).count()).toBe(1)
|
||||
expect(await card.getByRole('listitem').count()).toBe(3)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/** Shared settings are off on a fresh Host and survive browser reload through the ordinary settings UI. */
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { chromium } from 'playwright'
|
||||
import { expect, it, onTestFinished } from 'vitest'
|
||||
import { launchWebScaffold } from './scaffold.ts'
|
||||
import { newEnglishPage } from './support.ts'
|
||||
|
||||
it('persists developer tools in the Host settings document and restores the accepted choice', async () => {
|
||||
const scaffold = await launchWebScaffold({ developerTools: false })
|
||||
onTestFinished(() => scaffold.close())
|
||||
const browser = await chromium.launch()
|
||||
onTestFinished(() => browser.close())
|
||||
const page = await newEnglishPage(browser)
|
||||
await page.goto(scaffold.authenticatedUrl)
|
||||
await page.getByRole('button', { name: 'Settings', exact: true }).click()
|
||||
const toggle = page.getByRole('switch', { name: 'Developer tools' })
|
||||
expect(await toggle.getAttribute('aria-checked')).toBe('false')
|
||||
expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).not.toContain('ui-developer-tools:')
|
||||
await toggle.click()
|
||||
await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true')
|
||||
expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toContain('ui-developer-tools:\n enabled: true')
|
||||
await page.reload()
|
||||
await page.getByRole('button', { name: 'Settings', exact: true }).click()
|
||||
await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true')
|
||||
await toggle.click()
|
||||
await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false')
|
||||
expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toContain('ui-developer-tools:\n enabled: false')
|
||||
})
|
||||
@@ -86,7 +86,9 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
|
||||
|
||||
beforeAll(async () => {
|
||||
outsideRoot = await mkdtemp(join(tmpdir(), 'dsh-preview-outside-'))
|
||||
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false, extraOverlayPath: PAGING_PATCH })
|
||||
scaffold = await launchWebScaffold({
|
||||
developerTools: false, replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false, extraOverlayPath: PAGING_PATCH,
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
@@ -111,17 +113,30 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
|
||||
await mkdir(SHOT_DIR, { recursive: true })
|
||||
await saveFailureShot(page, `screenshots/0908-document-preview/smoke-${process.pid}`)
|
||||
})
|
||||
expect(await page.locator('[data-slot="conversation.hero.agentPreset"] button').count()).toBe(0)
|
||||
expect(scaffold.ctx.settings.get('ui-developer-tools')).toEqual({ enabled: false })
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
const input = page.locator('[data-composer-input]').first()
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
const sessionId = await settled
|
||||
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 })
|
||||
expect(await page.getByRole('tablist').count()).toBe(0)
|
||||
const cwd = scaffold.ctx.agents.get(sessionId)?.session.header.cwd
|
||||
if (cwd === undefined) throw new Error('settled Session has no workspace cwd')
|
||||
if (outsideRoot === undefined) throw new Error('outside fixture directory is unavailable')
|
||||
const outsideScript = join(outsideRoot, 'outside.js')
|
||||
const outsideReference = relative(cwd, outsideScript).replace(/\\/g, '/')
|
||||
let previewNetworkRequests = 0
|
||||
await page.route('https://preview.invalid/developer-tools.png', async (route) => {
|
||||
previewNetworkRequests += 1
|
||||
await route.fulfill({ status: 200, contentType: 'image/png', body: TINY_PNG })
|
||||
})
|
||||
const blockedRequests: string[] = []
|
||||
await page.route('https://blocked-preview.invalid/**', async (route) => {
|
||||
blockedRequests.push(route.request().url())
|
||||
await route.fulfill({ status: 200, contentType: 'text/html', body: 'ESCAPED' })
|
||||
})
|
||||
const markdownText = [
|
||||
'# Markdown smoke', '', 'Rendered from the workspace.', '',
|
||||
...Array.from({ length: (PAGE_LINES - 4) / 2 }, (_, index) => [`Paragraph ${index + 1}: ${'visible prefix '.repeat(20)}`, '']).flat(),
|
||||
@@ -132,12 +147,24 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
|
||||
'const tail = "CODE_TAIL";',
|
||||
]
|
||||
await Promise.all([
|
||||
writeFile(join(cwd, 'hostile.html'), `<!doctype html><html lang="en" class="dark"><head>
|
||||
<link rel="preconnect" href="https://blocked-preview.invalid">
|
||||
<noscript><meta http-equiv="refresh" content="0;url=https://blocked-preview.invalid/refresh"></noscript>
|
||||
</head><body style="margin:0"><h1>Static adversarial preview</h1>
|
||||
<a id="plain-link" href="https://blocked-preview.invalid/plain">Plain link</a>
|
||||
<div><template shadowrootmode="open"><a id="shadow-link" href="https://blocked-preview.invalid/shadow">Shadow link</a><template><iframe src="https://blocked-preview.invalid/frame"></iframe></template></template></div>
|
||||
<svg><a id="svg-link" href="https://blocked-preview.invalid/svg"><text y="20">SVG link</text><set attributeName="href" to="https://blocked-preview.invalid/set"/><animate attributeName="href" values="https://blocked-preview.invalid/animate"/></a></svg>
|
||||
<img src="https://blocked-preview.invalid/image"><iframe src="https://blocked-preview.invalid/direct-frame"></iframe>
|
||||
<math id="math-link" href="https://blocked-preview.invalid/math"><mi>x</mi></math>
|
||||
<form><math><mtext></form><form><mglyph><style></math><a id="mutation-link" href="https://blocked-preview.invalid/mutation">Mutation link</a>
|
||||
</body></html>`),
|
||||
writeFile(join(cwd, 'smoke.md'), markdownText),
|
||||
writeFile(join(cwd, 'pages.ts'), codeLines.join('\n')),
|
||||
writeFile(join(cwd, 'notes.unknown'), 'UNKNOWN_SUFFIX\nPlain fallback.'),
|
||||
writeFile(join(cwd, 'smoke.html'), [
|
||||
'<!doctype html><link rel="stylesheet" href="./local.css">',
|
||||
'<h1>HTML smoke</h1><p id="result">pending</p><p id="local-result">pending</p><p id="parent-result">pending</p>',
|
||||
'<img src="https://preview.invalid/developer-tools.png" width="1" height="1" alt="">',
|
||||
'<p id="outside-result">pending</p>',
|
||||
'<script>document.getElementById("result").textContent="INLINE_OK";',
|
||||
'try{parent.document.documentElement.setAttribute("data-document-preview-escape","true");document.getElementById("parent-result").textContent="parent-accessible"}',
|
||||
@@ -263,10 +290,51 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
|
||||
`- Same tab: ${String(await markdownTab.getAttribute('data-dockkit-tab') === markdownTabId)}`,
|
||||
].join('\n'))
|
||||
|
||||
await openFile('hostile.html')
|
||||
const hostileFrame = page.frameLocator('[data-html-preview]')
|
||||
await hostileFrame.getByRole('heading', { name: 'Static adversarial preview' }).waitFor()
|
||||
expect(await hostileFrame.locator('html').getAttribute('class')).toBe('dark')
|
||||
expect(await hostileFrame.locator('body').evaluate(node => getComputedStyle(node).margin)).toBe('0px')
|
||||
for (const id of ['plain-link', 'svg-link', 'math-link']) {
|
||||
const link = hostileFrame.locator(`#${id}`)
|
||||
expect(await link.getAttribute('href')).toBeNull()
|
||||
await link.click()
|
||||
}
|
||||
// DOMPurify leaves ordinary templates inert and removes declarative shadow roots.
|
||||
expect(await hostileFrame.locator('#shadow-link').count()).toBe(0)
|
||||
expect(await hostileFrame.locator('[href], [xlink\\:href]').count()).toBe(0)
|
||||
const mutationLink = hostileFrame.locator('#mutation-link')
|
||||
if (await mutationLink.count() > 0) await mutationLink.click()
|
||||
expect(await hostileFrame.locator('noscript, link, set, animate, iframe').count()).toBe(0)
|
||||
expect(blockedRequests).toEqual([])
|
||||
await hostileFrame.getByRole('heading', { name: 'Static adversarial preview' }).waitFor()
|
||||
|
||||
await openFile('smoke.html')
|
||||
await expect.poll(() => viewer.innerText()).toBe('HTML')
|
||||
const iframe = preview.locator('[data-html-preview]')
|
||||
await iframe.waitFor({ timeout: 15_000 })
|
||||
expect(await iframe.getAttribute('sandbox')).toBe('')
|
||||
const basicHtml = page.frameLocator('[data-html-preview]')
|
||||
await basicHtml.getByRole('heading', { name: 'HTML smoke', exact: true }).waitFor()
|
||||
expect(await basicHtml.locator('#result').innerText()).toBe('pending')
|
||||
expect(await basicHtml.locator('#local-result').innerText()).toBe('pending')
|
||||
expect(previewNetworkRequests).toBe(0)
|
||||
await successShot(page, 'html-basic')
|
||||
sections.push([
|
||||
'## Basic HTML', '',
|
||||
'- Developer tools: off by default on Web and desktop',
|
||||
'- Sandbox: no permissions',
|
||||
`- Inline script: ${await basicHtml.locator('#result').innerText()}`,
|
||||
`- Local script: ${await basicHtml.locator('#local-result').innerText()}`,
|
||||
`- Network requests: ${previewNetworkRequests}`,
|
||||
].join('\n'))
|
||||
await page.getByRole('button', { name: 'Settings', exact: true }).click()
|
||||
const settings = page.getByRole('dialog', { name: 'Settings' })
|
||||
await settings.getByRole('switch', { name: 'Developer tools' }).click()
|
||||
await expect.poll(() => settings.getByRole('switch', { name: 'Developer tools' }).getAttribute('aria-checked')).toBe('true')
|
||||
await successShot(page, 'developer-tools-setting')
|
||||
await settings.getByRole('button', { name: 'Close', exact: true }).click()
|
||||
await expect.poll(() => iframe.getAttribute('sandbox')).toBe('allow-scripts')
|
||||
expect(await iframe.getAttribute('sandbox')).toBe('allow-scripts')
|
||||
expect(await iframe.evaluate((node) => {
|
||||
const host = node.closest('[data-textpreview-body]')
|
||||
@@ -283,12 +351,21 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
|
||||
const html = page.frameLocator('[data-html-preview]')
|
||||
await html.getByRole('heading', { name: 'HTML smoke', exact: true }).waitFor({ timeout: 15_000 })
|
||||
await expect.poll(() => html.locator('#result').innerText()).toBe('INLINE_OK')
|
||||
await expect.poll(() => previewNetworkRequests).toBe(1)
|
||||
await expect.poll(() => html.locator('#local-result').innerText()).toBe('LOCAL_JS_OK')
|
||||
await expect.poll(() => html.locator('#outside-result').innerText()).toBe('OUTSIDE_JS_OK')
|
||||
await expect.poll(() => html.locator('#local-result').evaluate(node => getComputedStyle(node).color)).toBe('rgb(12, 34, 56)')
|
||||
await expect.poll(() => html.locator('#parent-result').innerText()).toBe('parent-blocked')
|
||||
expect(await html.locator('#parent-result').getAttribute('data-error')).toBe('SecurityError')
|
||||
expect(await page.locator('html').getAttribute('data-document-preview-escape')).toBeNull()
|
||||
await page.getByRole('tab', { name: /Trajectory/ }).click()
|
||||
await scaffold.ctx.settings.update('ui-developer-tools', { enabled: false })
|
||||
await expect.poll(() => page.getByRole('tab', { name: /Trajectory/ }).count()).toBe(0)
|
||||
await expect.poll(() => iframe.getAttribute('sandbox')).toBe('')
|
||||
expect(await page.getByText('LIGHTHOUSE', { exact: true }).count()).toBeGreaterThan(0)
|
||||
await scaffold.ctx.settings.update('ui-developer-tools', { enabled: true })
|
||||
await expect.poll(() => iframe.getAttribute('sandbox')).toBe('allow-scripts')
|
||||
await expect.poll(() => html.locator('#result').innerText()).toBe('INLINE_OK')
|
||||
await successShot(page, 'html')
|
||||
sections.push([
|
||||
'## HTML', '',
|
||||
|
||||
@@ -21,5 +21,7 @@
|
||||
- button "Decrease font size"
|
||||
- text: px Conversation display Controls process content in completed turns
|
||||
- button "Compact"
|
||||
- text: Developer tools Show tools and information for debugging and troubleshooting
|
||||
- switch "Developer tools"
|
||||
- text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior
|
||||
- button "Queue"
|
||||
@@ -21,5 +21,7 @@
|
||||
- button "减小字号"
|
||||
- text: px 对话显示 控制已完成轮次的过程内容
|
||||
- button "紧凑"
|
||||
- text: 开发者工具 显示用于调试和排查问题的工具与信息
|
||||
- switch "开发者工具"
|
||||
- text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为
|
||||
- button "排队发送"
|
||||
@@ -301,6 +301,8 @@ export interface WebScaffold {
|
||||
|
||||
/** Options for {@link launchWebScaffold}. */
|
||||
export interface LaunchOptions {
|
||||
/** The scaffold enables developer tools unless false preserves the shipped default. */
|
||||
developerTools?: boolean
|
||||
/** Profile resolver backend used by this test Host; defaults to runtime coverage. */
|
||||
profileResolutionMode?: Extract<ProfileResolutionMode, 'dual' | 'runtime'>
|
||||
/** Enable the real Open In rows with deterministic launch-environment facts. */
|
||||
@@ -787,6 +789,9 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
}
|
||||
await ctx.loader.await()
|
||||
await auditStartupEntries(ctx, 'web e2e scaffold')
|
||||
if (options.developerTools !== false) {
|
||||
await ctx.settings.update('ui-developer-tools', { enabled: true })
|
||||
}
|
||||
if (options.welcomeNoticePending !== true) {
|
||||
await ctx.settings.mutate(WELCOME_NOTICE_SETTINGS_NAMESPACE, [{
|
||||
op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION,
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
scaffold = await launchWebScaffold({ developerTools: false })
|
||||
browser = await chromium.launch()
|
||||
// Chinese browser: the shared page asserts the localized settings surface
|
||||
// the client derives from it (the English default has its own spec below).
|
||||
@@ -384,7 +384,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
// A second live Host binds another ephemeral port but shares the same
|
||||
// user-settings home. Its fresh origin has no theme localStorage and still
|
||||
// converges to dark before the settings dialog opens.
|
||||
const second = await launchWebScaffold({ harnessHome: scaffold.harnessHome })
|
||||
const second = await launchWebScaffold({ developerTools: false, harnessHome: scaffold.harnessHome })
|
||||
const secondPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
const secondTripwire = watchConsole(secondPage)
|
||||
try {
|
||||
@@ -549,7 +549,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
const reloaded = page.getByRole('dialog', { name: '设置' })
|
||||
await reloaded.getByRole('button', { name: '插话发送' }).waitFor({ timeout: 10_000 })
|
||||
|
||||
const second = await launchWebScaffold({ harnessHome: scaffold.harnessHome })
|
||||
const second = await launchWebScaffold({ developerTools: false, harnessHome: scaffold.harnessHome })
|
||||
const secondPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
const secondTripwire = watchConsole(secondPage)
|
||||
try {
|
||||
@@ -615,7 +615,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
|
||||
// A Chinese browser on another port still receives the explicit English
|
||||
// preference from the shared Host settings document.
|
||||
const second = await launchWebScaffold({ harnessHome: scaffold.harnessHome })
|
||||
const second = await launchWebScaffold({ developerTools: false, harnessHome: scaffold.harnessHome })
|
||||
const secondPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
const secondTripwire = watchConsole(secondPage)
|
||||
try {
|
||||
@@ -649,7 +649,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
// browser. English is also FALLBACK_LOCALE, so this scenario alone cannot
|
||||
// distinguish detection from the default — the zh scenarios above supply
|
||||
// the discriminating half (a Chinese browser must NOT land on the default).
|
||||
const fresh = await launchWebScaffold({})
|
||||
const fresh = await launchWebScaffold({ developerTools: false })
|
||||
const enPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'en-US' })
|
||||
const enTripwire = watchConsole(enPage)
|
||||
onTestFailed(() => saveFailureShot(enPage, 'web-e2e-settings-browser-language'))
|
||||
@@ -688,7 +688,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
// The product default for "no usable signal": a French browser ships
|
||||
// neither zh nor en, so resolution falls to FALLBACK_LOCALE (en) rather
|
||||
// than to Chinese.
|
||||
const fresh = await launchWebScaffold({})
|
||||
const fresh = await launchWebScaffold({ developerTools: false })
|
||||
const frPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'fr-FR' })
|
||||
const frTripwire = watchConsole(frPage)
|
||||
onTestFailed(() => saveFailureShot(frPage, 'web-e2e-settings-unshipped-language'))
|
||||
|
||||
@@ -81,7 +81,7 @@ it('renders durable record images in the Trajectory details panel from the share
|
||||
configurable: true,
|
||||
value: () => {},
|
||||
})
|
||||
mountAssembledApp()
|
||||
mountAssembledApp({ remote: { developerTools: true } })
|
||||
await openFixtureSession()
|
||||
const chatSrc = document.querySelector('[data-align="end"] img')?.getAttribute('src')
|
||||
if (chatSrc === null || chatSrc === undefined) throw new Error('chat gallery image missing')
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
"tests/client-plugin-live.e2e.ts",
|
||||
"tests/settings-chrome.e2e.ts",
|
||||
"tests/desktop-updates.e2e.ts",
|
||||
"tests/developer-tools-settings.e2e.ts",
|
||||
"tests/models-settings.e2e.ts",
|
||||
"tests/deepseek-messages-settings.e2e.ts",
|
||||
"tests/deepseek-messages-chat.e2e.ts",
|
||||
|
||||
@@ -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 docs/subsystems/conversation.md
|
||||
conversation.md: 61e76e1b057be7b47150bd623b3e04400b50e394
|
||||
conversation.zh.md: 5b54625fcfe9ac451e79a42c387a5cf0836ee39a
|
||||
conversation.md: 4d6324e71b4383dfc640c1873dd9ef31023a1098
|
||||
conversation.zh.md: 49f2d7dbdc67a926f88ca72ed8ae6a0622c54fb3
|
||||
@@ -24,7 +24,7 @@ Chat and Trajectory may recognize the same durable event family, but each keeps
|
||||
|
||||
Each Session keeps a monotonic set of active targets. Creating or reading a target source does not activate it. The shell explicitly activates its persisted or newly selected View, while another consumer activates a target through its first source subscription. First activation creates that target's builder and calls `replace()` once from the current target-indexed Contexts. Later flushes call `apply()` for every active target, and unsubscription does not remove one.
|
||||
|
||||
The shell owns View selection and resolves the registered preferred View or Chat fallback before rendering when a binding is created or selected as current, and after View-roster changes. The assembler receives only the resolved target id and does not select Chat or another default target. A third-party View participates through the same selection and activation operations.
|
||||
The shell owns View selection and resolves the registered preferred View or Chat fallback before rendering when a binding is created or selected as current, and after View-roster changes. The assembler receives only the resolved target id and does not select Chat or another default target. A third-party View participates through the same selection and activation operations. A View Definition may expose `toolCallFocus(callId)`; the shell supplies Inspect only for a visible target that declares this capability, and the target maps the call id to its own focus identity.
|
||||
|
||||
## Replayable event families
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ Chat 与 Trajectory 可以识别同一个持久 event family,但各自保留
|
||||
|
||||
每个 Session 都保留单调增长的 active target 集合。创建或读取 target source 不会激活它。shell 会显式激活持久化选择或新选择的 View,其他消费者则通过 target source 的首个订阅激活 target。首次激活会创建该 target 的 builder,并从当前按 target 索引的 Context 调用一次 `replace()`。后续 flush 对每个 active target 调用 `apply()`,取消订阅不会移除 target。
|
||||
|
||||
shell 拥有 View 选择,并在 binding 创建、被选为 current 或 View roster 变化时,于渲染前解析已注册的偏好 View 或 Chat fallback。assembler 只接收解析后的 target id,不自行选择 Chat 或其他默认 target。第三方 View 使用相同的选择与激活操作。
|
||||
shell 拥有 View 选择,并在 binding 创建、被选为 current 或 View roster 变化时,于渲染前解析已注册的偏好 View 或 Chat fallback。assembler 只接收解析后的 target id,不自行选择 Chat 或其他默认 target。第三方 View 使用相同的选择与激活操作。View Definition 可以提供 `toolCallFocus(callId)`;shell 仅为声明了此能力且可见的目标提供 Inspect,由目标将调用 id 映射为自己的焦点标识。
|
||||
|
||||
## 可回放 event family
|
||||
|
||||
|
||||
@@ -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 docs/subsystems/web-client.md
|
||||
web-client.md: c1c3c70d68aedc63da0a6e7aebd0b48d56886853
|
||||
web-client.zh.md: 42adffe958f9034f4d450d99dffcef85bcee19a2
|
||||
web-client.md: 59bf663864b367382aedb1352ee3ca38f3e651ba
|
||||
web-client.zh.md: f38ef5e2c9d2258be765d8a2bb400751d72948c8
|
||||
@@ -53,6 +53,8 @@ This pairing is not a second source of business truth. Host controllers decide d
|
||||
|
||||
## Conversation and presentation
|
||||
|
||||
Web and desktop share the [developer-tool preference](../../packages/client/ui-settings/README.md#use-this-package). It controls diagnostic Views, new-session preset selection, changed-file cards, and the builtin HTML preview policy without changing Session records.
|
||||
|
||||
`ui-session` installs the Session scope adapter and publishes `useSessions`, `useSessionStatus`, `useSessionRetainInfo`, `useSession`, `sessionId`, and `useProjection`. `SessionProvider` inherits an outer binding or binds an explicit `SessionReference`, so concurrent subtrees can target different Sessions. Domain adapters add further standard sources without putting React hooks on the model objects.
|
||||
|
||||
`ui-conversation` binds once to each `SessionBinding.eventSource`. Its event registry correlates durable Session events and Client-only `assistant/live-chunk` updates into stable business Contexts, and its view registry materializes target snapshots. Chat Assistant, Trajectory Assistant, and Turn Tail interpret both live chunks and the compact streams embedded in durable settlements, so reconnect and paged history reproduce the same Assistant state without durable token rows. `ui-chat` and `ui-trajectory` register separate Definitions and builders: they may interpret the same event family, but they do not import or share each other's final display model. The shell selects a registered view and passes its snapshot through standard hooks and Slots. [Conversation](conversation.md) defines Context identity, replay, Location data, target builders, and keyed renderers.
|
||||
|
||||
@@ -53,6 +53,8 @@ Connection 拥有 request correlation、`/api` carrier、trust check、精确 Fe
|
||||
|
||||
## Conversation 与 presentation
|
||||
|
||||
Web 和桌面端共享[开发者工具偏好](../../packages/client/ui-settings/README.zh.md#use-this-package)。它控制诊断 View、新会话预设选择、改动文件卡片和内置 HTML 预览策略,不改变 Session 记录。
|
||||
|
||||
`ui-session` 安装 Session scope adapter,并提供 `useSessions`、`useSessionStatus`、`useSessionRetainInfo`、`useSession`、`sessionId` 和 `useProjection`。`SessionProvider` 可以继承外围 binding,也可以绑定显式 `SessionReference`,因此并存子树可以指向不同 Session。领域 adapter 可以继续添加标准 source,但不会把 React hook 放进 model object。
|
||||
|
||||
`ui-conversation` 对每个 `SessionBinding.eventSource` 只绑定一次。它的 event registry 把持久 Session event 与 Client-only `assistant/live-chunk` update 关联成稳定的业务 Context,view registry 则 materialize target snapshot。Chat Assistant、Trajectory Assistant 与 Turn Tail 同时解释 live chunk 和持久 settlement 中嵌入的紧凑 stream,因此重连与分页历史无需持久 token 行即可复现相同 Assistant 状态。`ui-chat` 与 `ui-trajectory` 分别注册自己的 Definition 和 builder:它们可以解释同一 event family,但不会导入或共享彼此的最终 display model。Shell 选择一个已注册 view,再通过标准 hook 与 Slot 交付其 snapshot。[Conversation](conversation.zh.md)定义 Context identity、replay、Location data、target builder 与 keyed renderer。
|
||||
|
||||
@@ -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-agent-preset/README.md
|
||||
README.md: 476d0a351896835b4f92372c4a62854f2162a6e1
|
||||
README.zh.md: fa5a81b4af6299ae2253cf43809f07478d66885c
|
||||
README.md: a76a265c7af54f88fc12540598d6552a74c8dc50
|
||||
README.zh.md: de66174015b383f8d0c883bc2a9e544b30621c5e
|
||||
@@ -25,6 +25,8 @@ Use this package to choose the agent preset for a new Web GUI session, see the a
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
The new-session picker additionally requires Developer tools to be enabled in General Settings. This presentation preference does not alter the selected preset, saved default, Host composition, or the management section’s independent picker policy.
|
||||
|
||||
Mount this plugin alongside the settings and conversation packages; the management section then shows a visibility switch that is on by default. While it is off, the new-session chip is absent and the Host composes an unnamed session from the deployment default (`standard` in the shipped Web bundle). Turning it on restores the saved user default, or uses the deployment default when none has been saved, and carries that default to the current blank task; a chip pick itself is staged only once for the next blank session. Turning the picker off again returns the current blank task to the deployment default the same way and discards an unconsumed stage; started and historical sessions keep their labels, compositions, and recorded history.
|
||||
|
||||
### Managing the roster
|
||||
|
||||
@@ -25,6 +25,8 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
新会话选择器还要求在通用设置中开启开发者工具。此展示偏好不会改变已选预设、保存的默认值、Host 组合或管理区域独立的选择器策略。
|
||||
|
||||
与设置和对话包一起挂载本插件;管理分区随后显示一个默认开启的可见性开关。关闭期间,新建会话 chip 不出现,宿主会依据部署默认值(随附 Web bundle 中为 `standard`)组装未指名会话。开启时会恢复已保存的用户默认值;尚未保存时则使用部署默认值;该默认值会同时带到当前空白任务上。chip 中的选择本身只为下一个空白会话暂存一次。再次关闭选择器会以同样方式把当前空白任务带回部署默认值,并丢弃尚未使用的暂存选择;已开始及历史会话的标签、组装与已记录历史均保持不变。
|
||||
|
||||
### 管理名单
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
IconAgentPresetOutlineRegular, IconChevronDownOutlineRegular, IconWarningOutlineRegular, Menu, Toast,
|
||||
@@ -27,6 +27,8 @@ import css from './AgentPresetSeat.module.css'
|
||||
/** Registration-side business face for the hero chip. */
|
||||
export interface AgentPresetSeatInjected {
|
||||
hooks: {
|
||||
/** Whether this entry offers preset selection. */
|
||||
showPresetPicker: ObservableSnapshot<boolean>
|
||||
/** Seat snapshot bound by the renderer as useAgentPresetSeat. */
|
||||
agentPresetSeat: SnapshotStore<AgentPresetSeatState>
|
||||
}
|
||||
@@ -83,8 +85,9 @@ export type AgentPresetSeatProps =
|
||||
* @returns the chip, or null when the deployment composes no presets.
|
||||
*/
|
||||
export function AgentPresetSeat({
|
||||
sessionId, useSessionRetainInfo, load, select, introduced, useAgentPresetSeat, t,
|
||||
sessionId, useSessionRetainInfo, load, select, introduced, useAgentPresetSeat, useShowPresetPicker, t,
|
||||
}: AgentPresetSeatProps) {
|
||||
const showPresetPicker = useShowPresetPicker(value => value)
|
||||
const state = useAgentPresetSeat(snapshot => snapshot)
|
||||
const main = useSessionRetainInfo(info => sessionId === undefined
|
||||
|| (info?.retainedBy.mainView ?? 0) > 0)
|
||||
@@ -93,8 +96,9 @@ export function AgentPresetSeat({
|
||||
// it rather than leaving the first one silently in place.
|
||||
const toastSeq = useRef(0)
|
||||
const [toast, setToast] = useState<{ seq: number; text: string } | null>(null)
|
||||
const pickerVisible = useRef(state.showPicker)
|
||||
pickerVisible.current = state.showPicker
|
||||
const visible = showPresetPicker && state.showPicker
|
||||
const pickerVisible = useRef(visible)
|
||||
pickerVisible.current = visible
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
@@ -104,10 +108,10 @@ export function AgentPresetSeat({
|
||||
// state explicitly; otherwise an external off/on edit can revive an old
|
||||
// menu or refusal banner.
|
||||
useEffect(() => {
|
||||
if (state.showPicker) return
|
||||
if (visible) return
|
||||
setOpen(false)
|
||||
setToast(null)
|
||||
}, [state.showPicker])
|
||||
}, [visible])
|
||||
|
||||
const chosen = state.options.find(option => option.id === state.current)
|
||||
const chosenText = chosen === undefined ? undefined : presetDisplayText(chosen, t)
|
||||
@@ -136,7 +140,7 @@ export function AgentPresetSeat({
|
||||
|
||||
// Nothing to choose between: the deployment composes no presets and every
|
||||
// session shares the host composition.
|
||||
if (!main || !state.showPicker || !ready) return null
|
||||
if (!main || !visible || !ready) return null
|
||||
|
||||
// One wrapper span: the chip is a flex row with a gap, so loose character
|
||||
// spans would each pick up the gap between them.
|
||||
|
||||
@@ -57,7 +57,7 @@ export { AGENT_PRESET_SETTINGS_NS, writeDefaultPreset } from './settings-store.t
|
||||
|
||||
/** Required services (cordis fiber inject). */
|
||||
export const inject = [
|
||||
'slots', 'sessions', 'locale', 'remote', 'remote.agentPresets', 'remote.settings',
|
||||
'slots', 'sessions', 'locale', 'remote', 'remote.agentPresets', 'remote.settings', 'settingsScope',
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -137,7 +137,7 @@ export function apply(ctx: ClientContext): void {
|
||||
const binding = sessionId === undefined ? undefined : scope.sessions.binding(sessionId)
|
||||
const seat = binding === undefined ? unboundSeat : seatFor(scope, binding)
|
||||
return {
|
||||
hooks: { agentPresetSeat: seat.store },
|
||||
hooks: { agentPresetSeat: seat.store, showPresetPicker: ctx.settingsScope.developerTools.enabled },
|
||||
load: () => seat.load(),
|
||||
select: (id: string) => seat.select(id),
|
||||
introduced: () => { seat.introduced() },
|
||||
|
||||
@@ -244,7 +244,7 @@ describe('ui-agent-preset apply', () => {
|
||||
|
||||
it('declares the services it uses', () => {
|
||||
expect(inject).toEqual([
|
||||
'slots', 'sessions', 'locale', 'remote', 'remote.agentPresets', 'remote.settings',
|
||||
'slots', 'sessions', 'locale', 'remote', 'remote.agentPresets', 'remote.settings', 'settingsScope',
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -56,17 +56,19 @@ function renderSeat(
|
||||
session?: { id: string; retainInfo: SessionRetainInfo | undefined },
|
||||
) {
|
||||
const store = createSnapshotStore<AgentPresetSeatState>({ ...SEAT_READY, ...state })
|
||||
const developerTools = createSnapshotStore(true)
|
||||
const actions = { load: vi.fn(() => Promise.resolve()), select: vi.fn(select), introduced: vi.fn() }
|
||||
render(<AgentPresetSeat {...({
|
||||
...actions,
|
||||
sessionId: session === undefined ? undefined : SessionId(session.id),
|
||||
useShowPresetPicker: bindSnapshotSelector(developerTools),
|
||||
useAgentPresetSeat: bindSnapshotSelector(store),
|
||||
useSessionRetainInfo: session === undefined
|
||||
? useSessionRetainInfo
|
||||
: <Selected,>(selector: (value: SessionRetainInfo | undefined) => Selected) => selector(session.retainInfo),
|
||||
t: translate,
|
||||
} as unknown as AgentPresetSeatProps)} />)
|
||||
return actions
|
||||
return { ...actions, developerTools }
|
||||
}
|
||||
|
||||
function renderLabel(
|
||||
@@ -129,6 +131,19 @@ describe('the new-session chip', () => {
|
||||
expect(screen.getByText('mine')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('closes the picker immediately when developer tools turn off without changing the staged preset', () => {
|
||||
const actions = renderSeat()
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(screen.getByText(en.presetStandardDescription)).toBeTruthy()
|
||||
act(() => { actions.developerTools.set(false) })
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
expect(screen.queryByText(en.presetStandardDescription)).toBeNull()
|
||||
expect(actions.select).not.toHaveBeenCalled()
|
||||
act(() => { actions.developerTools.set(true) })
|
||||
expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.getByRole('button').textContent).toContain(en.presetStandardName)
|
||||
})
|
||||
|
||||
it('falls back to the id when the staged preset published no name', () => {
|
||||
renderSeat({ current: 'mine' })
|
||||
|
||||
|
||||
@@ -219,7 +219,7 @@ const ChatNodeList = memo(function ChatNodeList({ order, ...seatProps }: ChatNod
|
||||
*/
|
||||
export function ChatView({
|
||||
useSession, useChat, useChatNode, useChatNodeProcess, useSessions, useStore, actions, renderSlot,
|
||||
sessionId, openFile, openSkill, openExternalLink, loadOlder, loadThrough, loadImage, openView, chatScroll, forkAt, fileMentions,
|
||||
sessionId, openFile, openSkill, openExternalLink, loadOlder, loadThrough, loadImage, inspectCall, chatScroll, forkAt, fileMentions,
|
||||
useTranscriptView, useProjection, t,
|
||||
}: ChatViewSlotProps) {
|
||||
const order = useChat(s => s.order)
|
||||
@@ -245,9 +245,6 @@ export function ChatView({
|
||||
const hasMore = useSession(s => s.hasMore)
|
||||
const loadingOlder = useSession(s => s.loadingOlder)
|
||||
const compactTranscript = useTranscriptView(mode => mode === 'compact')
|
||||
const inspectCall = useCallback((callId: string) => {
|
||||
openView('trajectory', callId)
|
||||
}, [openView])
|
||||
const [fileOpenError, setFileOpenError] = useState<{ path: string; message: string } | null>(null)
|
||||
const [fileOpenBusy, setFileOpenBusy] = useState(false)
|
||||
// Close/retry must ignore a settlement that started before the latest
|
||||
|
||||
@@ -82,7 +82,7 @@ export interface ChatNodeOwnerProps {
|
||||
/** Open the current source file of a skill referenced by a sent message. */
|
||||
openSkill: (name: string) => void
|
||||
openFile: (path: string, options?: OpenFileOptions) => void
|
||||
inspectCall: (callId: ToolCallId) => void
|
||||
inspectCall: ((callId: ToolCallId) => void) | undefined
|
||||
forkAt: (seq: number) => void
|
||||
/**
|
||||
* Session-authorized image loader, down-threaded from the Chat view so a
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Chat inject factories exercised over independently mounted Conversation and Chat plugins. */
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ISession, SessionReference } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
@@ -48,7 +49,7 @@ function sessionFakeFor() {
|
||||
|
||||
async function bench() {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
runtime.ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) }, bind: () => stubSettingsScope().scope } as never)
|
||||
const layout = { closeRightbar: vi.fn(), openRightbar: vi.fn() }
|
||||
runtime.ctx.provide('layout', layout as never)
|
||||
const sidebarRight = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { act, render } from '@testing-library/react'
|
||||
import {
|
||||
@@ -38,6 +39,7 @@ async function bench() {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
const chatSettings = stubSettingsScope<ChatSettings>()
|
||||
runtime.ctx.provide('settingsScope', {
|
||||
developerTools: { enabled: createSnapshotStore(true) },
|
||||
bind: ({ namespace }: { namespace: string }) => namespace === CHAT_SETTINGS_NAMESPACE
|
||||
? chatSettings.scope
|
||||
: stubSettingsScope().scope,
|
||||
|
||||
@@ -400,6 +400,7 @@ function makeHarness(
|
||||
useTranscriptView: bindSnapshotSelector(transcriptView),
|
||||
renderSlot,
|
||||
SessionProvider: SessionProviderStub,
|
||||
inspectCall: (callId: string) => { openView('trajectory', callId) },
|
||||
viewRequest: null,
|
||||
openView,
|
||||
completeViewRequest: () => {},
|
||||
@@ -1320,12 +1321,20 @@ describe('ChatView', () => {
|
||||
expect(view.queryByText('本轮运行失败')).toBeNull()
|
||||
})
|
||||
|
||||
it('removes Inspect when trajectory is unavailable and restores it with the view', () => {
|
||||
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} inspectCall={undefined} />)
|
||||
expect(h.toolOwners.at(-1)?.inspectCall).toBeUndefined()
|
||||
view.rerender(<h.ChatView {...h.props} />)
|
||||
expect(h.toolOwners.at(-1)?.inspectCall).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it('hands the trajectory callback to the Tool seat', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [toolResult(3, 'a')],
|
||||
})
|
||||
render(<h.ChatView {...h.props} />)
|
||||
h.toolOwners[0]?.inspectCall('a')
|
||||
h.toolOwners[0]?.inspectCall?.('a')
|
||||
expect(h.openView).toHaveBeenCalledWith('trajectory', 'a')
|
||||
})
|
||||
|
||||
@@ -2233,7 +2242,7 @@ describe('ChatView', () => {
|
||||
expect(owner.openFile).not.toBe(h.openFile)
|
||||
owner.openFile('src/a.ts')
|
||||
expect(h.openFile).toHaveBeenCalledWith('src/a.ts')
|
||||
owner.inspectCall('a')
|
||||
owner.inspectCall?.('a')
|
||||
expect(h.openView).toHaveBeenCalledWith('trajectory', 'a')
|
||||
})
|
||||
|
||||
|
||||
@@ -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: 3bd965a13402b37ef2f45380dc51facfef58e0f2
|
||||
README.zh.md: f348ed31200801e726ed5383a3e0c968f84ca2d6
|
||||
README.md: d3d829bdb6d19166e649c6d2032fe106d9491d69
|
||||
README.zh.md: 7d2e54a9001cf55920eb0040db3b98f529c3d5d2
|
||||
@@ -25,7 +25,7 @@ English | [中文](README.zh.md)
|
||||
<a id="conversation-assembly"></a>
|
||||
## Conversation assembly
|
||||
|
||||
`UiConversation.events` is the single registry for event Definitions, and `UiConversation.views` is the single registry for target snapshot builders. Both registries reject duplicate keys, preserve registration order, return idempotent disposers, and rebuild existing bindings when their contribution roster changes. `UiConversation.binding(bindingOrSessionId)` returns one identity-stable Conversation binding for the current Session Controller binding. It does not open another event source.
|
||||
`UiConversation.events` is the single registry for event Definitions, and `UiConversation.views` is the single registry for target snapshot builders. Both registries reject duplicate keys, preserve registration order, return idempotent disposers, and rebuild existing bindings when their contribution roster changes. `UiConversation.binding(bindingOrSessionId)` returns one identity-stable Conversation binding for the current Session Controller binding. It does not open another event source. A View Definition may declare `toolCallFocus` to translate a tool-call id into its focus identity. Conversation supplies an Inspect callback only while such a target has a visible View entry; Chat consumes this callback without selecting a target.
|
||||
|
||||
The adapter passes each `SessionEventLikeEntry` directly to the assembler. Its outer `type` distinguishes durable events from Client-only transient events, while its inner `event` always exposes `type`, `seq`, `time`, and `data`; Definitions receive that inner `SessionEventLike`. Replacement windows may include both entry variants, while historical prepends carry durable entries and live appends may carry either. Every Definition uses the same `match` and `update` methods for both event forms, while `start` receives only a durable event and the assembler rejects a transient start. Definitions that do not consume Assistant deltas return `null` for `assistant/live-chunk`. Replacement windows and revision gaps rebuild from the complete loaded window; contiguous append, prepend, and Assistant-settlement revisions use incremental assembly. Settlement removes only the named attempt's transient matches, applies its optional durable entry, and replays the affected Contexts and dependents without replacing unrelated target nodes. The assembler owns Context matching, Turn/Step locations, target node materialization, target activity, and stable target sources. `ConversationSnapshot` contains only target-neutral views and active-target facts; Session lifecycle state remains in `SessionSnapshot`.
|
||||
|
||||
@@ -40,6 +40,8 @@ The shared image slot props keep display choices separate from durable reference
|
||||
|
||||
The context-occupancy button shows a ring and percentage below the input card, after the Session statistics. Clicking it opens the token breakdown in a panel kept inside the viewport, including when no statistics are shown; the button stays hidden until context usage and capacity are available.
|
||||
|
||||
With Developer tools off, the shell exposes only Chat and hides the View tab bar. Turning it off while another View is active returns to Chat; stored View preferences and recorded Session data remain intact. Turning it on makes registered Views available again. View owners receive the available roster so their navigation actions can follow the same visibility.
|
||||
|
||||
The composer registers the File command action and owns its label, availability, and native file-dialog callback. Menu availability and invocation both consult the mounted composer's current attachment-intake policy. Unmounting or locking the composer disables that action; disposing the plugin removes its registration. The callback binding stays inside the input module.
|
||||
|
||||
`SessionInputShell` owns one Lexical editor per Session through its private [DraftEditorRuntime](src/client/input/editor/runtime.ts), while retaining submission, attachment selection, and recovery decisions. [DraftEditor](src/client/input/editor/DraftEditor.tsx) renders the borrowed editor; InputBar retains its Hooks and refs and installs DOM behavior through [view-binding](src/client/input/editor/view-binding.ts). Editor-facing types live in [draft-editor.ts](src/client/contract/draft-editor.ts), with shared input and submission types in [input.ts](src/client/contract/input.ts). This separation does not support simultaneous editable roots for one Session; [the two-stage isolation proposal](../../../.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.md) defines the remaining work.
|
||||
@@ -50,7 +52,7 @@ Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and c
|
||||
|
||||
The package occupies the root-scoped `main` key `conversation`. Its `main.conversation` shell keeps the strict Session Header outside the optional-Session `conversation.content` Component Factory. The Factory owns the shared body and Composer, reads the current Session through its standard Hook, and exposes strict-Session `views` plus root-scoped `widthControls` local positions. Its default adapter renders the existing `conversation.session` entry, while the main occurrence selects the width handles; an embedded occurrence can replace `views` and omit those handles without rendering the main Header. The shared body and Composer register the queue and Todo docks. The Todo dock uses the shared panel elevation above the composer; its rows use the shared idle, ongoing, and done markers for pending, in-progress, and completed items. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop.
|
||||
|
||||
A blank Session retains the header's leading and corner controls, including the right-sidebar opener, while hiding its title, actions, utilities, and View tabs. Selecting a Workspace creates the Session needed by these controls; the first message is not required. Without a selected Session, the strict header is absent. Sidebar entries retain their own data and execution prerequisites.
|
||||
A blank Session retains the header's leading and corner controls, including the right-sidebar opener, while hiding its title, actions, utilities, and View tabs. Selecting a Workspace creates the Session needed by these controls; the first message is not required. Without a selected Session, the strict header is absent. Sidebar entries retain their own data and execution prerequisites. A started Session header uses one row when fewer than two Views are available; the tab row reserves space only while rendered.
|
||||
|
||||
View selection is deterministic: a registered persisted selection wins, otherwise registered `chat` wins, otherwise no View renders. It never chooses the first registered View. Shell phase combines Session lifecycle with the active-target set; no target-specific snapshot is read by the shell.
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ kind: "package-reference"
|
||||
<a id="conversation-assembly"></a>
|
||||
## Conversation 组装
|
||||
|
||||
`UiConversation.events` 是 event Definition 的唯一 registry,`UiConversation.views` 是 target snapshot builder 的唯一 registry。两者都拒绝重复 key、保持注册顺序、返回幂等 disposer,并在 contribution roster 变化时重建现有 binding。`UiConversation.binding(bindingOrSessionId)` 为当前 Session Controller binding 返回 identity 稳定的 Conversation binding,不会另开事件源。
|
||||
`UiConversation.events` 是 event Definition 的唯一 registry,`UiConversation.views` 是 target snapshot builder 的唯一 registry。两者都拒绝重复 key、保持注册顺序、返回幂等 disposer,并在 contribution roster 变化时重建现有 binding。`UiConversation.binding(bindingOrSessionId)` 为当前 Session Controller binding 返回 identity 稳定的 Conversation binding,不会另开事件源。 View Definition 可以声明 `toolCallFocus`,将工具调用 id 转换为自身的焦点标识。仅当此目标拥有可见的 View 条目时,Conversation 才提供 Inspect 回调;Chat 直接使用回调,不选择目标。
|
||||
|
||||
适配器把每个 `SessionEventLikeEntry` 直接交给 assembler。外层 `type` 区分持久事件与 Client-only transient event,内部 `event` 则统一公开 `type`、`seq`、`time` 与 `data`;Definition 接收这个内部 `SessionEventLike`。replacement window 可以包含两种 entry,历史 prepend 携带持久 entry,实时 append 则可以携带任一种。两种事件都使用 Definition 的同一组 `match` 与 `update` 方法,`start` 只接收持久 event,assembler 会拒绝 transient start。不消费 Assistant delta 的 Definition 对 `assistant/live-chunk` 返回 `null`。replace window 或 revision 断档从完整已加载窗口重建;连续 revision 的 append、prepend 与 Assistant settlement 使用增量组装。settlement 只删除具名 attempt 的 transient match,应用可选持久 entry,并重放受影响的 Context 及其 dependent,不替换无关 target node。assembler 拥有 Context 匹配、Turn/Step location、target node 物化、target activity 和稳定 target source。`ConversationSnapshot` 只包含与 target 无关的 View 与 active-target 事实;Session lifecycle 状态仍属于 `SessionSnapshot`。
|
||||
|
||||
@@ -40,6 +40,8 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map,
|
||||
|
||||
上下文占用按钮在输入卡片下方、会话统计右侧显示圆环和百分比。点击按钮可在视口内的面板查看 token 构成,没有统计项时面板也不会越界;上下文用量和容量尚不可用时,按钮保持隐藏。
|
||||
|
||||
关闭开发者工具时,外壳仅展示 Chat 并隐藏 View 标签栏。在其他 View 激活时关闭开关会返回 Chat;已保存的 View 偏好和 Session 记录保持不变。开启后,已注册的 View 恢复可用。View 所有者接收可用列表,使导航操作跟随相同的可见性。
|
||||
|
||||
输入框注册「文件」命令动作,负责其标题、可用性和原生文件选择器回调。菜单可用性与实际调用都读取已挂载输入框当前的附件接收策略。输入框卸载或锁定后该动作不可用,插件 dispose(资源释放)时移除注册。回调绑定留在输入模块内部。
|
||||
|
||||
`SessionInputShell` 通过私有 [DraftEditorRuntime](src/client/input/editor/runtime.ts) 为每个 Session 持有一个 Lexical editor,同时保留提交、附件选择和恢复决策。[DraftEditor](src/client/input/editor/DraftEditor.tsx) 呈现借用的 editor;InputBar 保留钩子与 refs,并通过 [view-binding](src/client/input/editor/view-binding.ts) 安装 DOM 行为。编辑器类型位于 [draft-editor.ts](src/client/contract/draft-editor.ts),共享输入和提交类型位于 [input.ts](src/client/contract/input.ts)。这一拆分不支持同一 Session 同时挂载多个可编辑 root;[两阶段隔离提案](../../../.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.zh.md) 定义剩余工作。
|
||||
@@ -50,7 +52,7 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map,
|
||||
|
||||
本包占据 root 作用域 `main` 中的 `conversation` key。其 `main.conversation` shell 将 strict Session Header 保留在 optional-Session `conversation.content` Component Factory 外。Factory 拥有共享正文与 Composer,通过其标准 Hook 读取当前 Session,并公开 strict-Session `views` 与 root-scoped `widthControls` 两个局部位置。默认 adapter 渲染现有 `conversation.session` entry,主 occurrence 选择宽度拖拽条;嵌入式 occurrence 可以替换 `views`、省略拖拽条,且不渲染主 Header。共享正文与 Composer 注册 queue dock 和 Todo dock。Todo dock 在 composer 上方使用共享面板 elevation;其中的行分别以共享 idle、ongoing 与 done 标记表示待处理、进行中与已完成。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。
|
||||
|
||||
blank Session 保留 header 的 leading 与 corner 控件,包括右侧栏展开入口,同时隐藏标题、actions、utilities 和 View tabs。选择 Workspace 会创建这些控件所需的 Session,无需先发送消息。没有选中 Session 时,strict header 不挂载。侧栏各入口仍遵循自身的数据与执行环境要求。
|
||||
blank Session 保留 header 的 leading 与 corner 控件,包括右侧栏展开入口,同时隐藏标题、actions、utilities 和 View tabs。选择 Workspace 会创建这些控件所需的 Session,无需先发送消息。没有选中 Session 时,strict header 不挂载。侧栏各入口仍遵循自身的数据与执行环境要求。 已开始的 Session 在可用 View 少于两个时使用单行标题栏,仅在渲染标签行时保留其高度。
|
||||
|
||||
View 选择规则固定:有效且已注册的持久化选择优先,其次是已注册的 `chat`,否则不渲染 View;绝不选择第一个已注册 View。Shell phase 只组合 Session lifecycle 与 active-target set,不读取任何 target-specific 快照。
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ import { ConversationPanel } from './skeleton/ConversationPanel.tsx'
|
||||
import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx'
|
||||
import { InputBar } from './skeleton/InputBar.tsx'
|
||||
import { todoDockEntry } from './skeleton/TodoPanel.tsx'
|
||||
import { resolveActiveView } from './view-selection.ts'
|
||||
import { DEFAULT_VIEW_ID, resolveActiveView } from './view-selection.ts'
|
||||
import { en, NS, zh, type ConversationKey } from './locales.ts'
|
||||
import { CONVERSATION_SETTINGS_NAMESPACE, type ConversationSettings } from '../submission-settings.ts'
|
||||
|
||||
@@ -158,6 +158,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
||||
for (const entry of slots.entries('conversation.view')) {
|
||||
/* v8 ignore next -- list registration validates id at load. */
|
||||
if (entry.options.id === undefined) continue
|
||||
if (!ctx.settingsScope.developerTools.enabled.getSnapshot() && entry.options.id !== DEFAULT_VIEW_ID) continue
|
||||
tabs.push({
|
||||
id: entry.options.id,
|
||||
label: resolveSlotLabel(entry.options.label) ?? entry.options.id,
|
||||
@@ -195,7 +196,9 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
||||
ctx.effect(() => {
|
||||
const disposeViews = slots.subscribe('conversation.view', refreshViews)
|
||||
const disposeLocale = ctx.locale.subscribe(refreshViews)
|
||||
const disposeDeveloperTools = ctx.settingsScope.developerTools.enabled.subscribe(refreshViews)
|
||||
return () => {
|
||||
disposeDeveloperTools()
|
||||
disposeLocale()
|
||||
disposeViews()
|
||||
}
|
||||
@@ -293,14 +296,36 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
||||
'conversation.view': { kind: 'list', scope: 'session' },
|
||||
},
|
||||
store: conversationStore,
|
||||
inject: (sessionId: SessionId, actions: BoundActions<typeof conversationStore>): ConversationSessionInjected => ({
|
||||
hooks: { conversationViews },
|
||||
bindDraftMirror: write => inputHub.shell(sessionId).bindMirror(write),
|
||||
openView: (view, focus) => {
|
||||
inject: (sessionId: SessionId, actions: BoundActions<typeof conversationStore>): ConversationSessionInjected => {
|
||||
const openView = (view: string, focus: string): void => {
|
||||
if (!viewTabs().some(tab => tab.id === view)) return
|
||||
activateView(sessionId, view)
|
||||
actions.openView(view, focus)
|
||||
},
|
||||
}),
|
||||
}
|
||||
const inspectionTarget = () => uiConversation.views.entries().find(definition =>
|
||||
definition.toolCallFocus !== undefined
|
||||
&& conversationViews.getSnapshot().some(view => view.id === definition.target),
|
||||
)
|
||||
const inspectCall = (callId: string): void => {
|
||||
const target = inspectionTarget()
|
||||
if (target?.toolCallFocus !== undefined) openView(target.target, target.toolCallFocus(callId))
|
||||
}
|
||||
return {
|
||||
hooks: {
|
||||
conversationViews,
|
||||
inspectCall: {
|
||||
getSnapshot: () => inspectionTarget() === undefined ? undefined : inspectCall,
|
||||
subscribe: (listener) => {
|
||||
const disposeViews = conversationViews.subscribe(listener)
|
||||
const disposeDefinitions = uiConversation.views.subscribe(listener)
|
||||
return () => { disposeViews(); disposeDefinitions() }
|
||||
},
|
||||
},
|
||||
},
|
||||
bindDraftMirror: write => inputHub.shell(sessionId).bindMirror(write),
|
||||
openView,
|
||||
}
|
||||
},
|
||||
}, ConversationSession)
|
||||
|
||||
const registerConversationHeader = () => slots.register({
|
||||
|
||||
@@ -276,6 +276,12 @@ export interface ConversationViewBuilder<Node extends ConversationViewNode = Con
|
||||
/** Registry contribution that creates an isolated builder when a Session first uses this target. */
|
||||
export interface ConversationViewDefinition<Node extends ConversationViewNode = ConversationViewNode, Snapshot = unknown> {
|
||||
readonly target: string
|
||||
/**
|
||||
* Address a tool call in this target's inspector; absent for non-inspection views.
|
||||
* @param callId - tool-call identity from the Session.
|
||||
* @returns the target's opaque focus identity.
|
||||
*/
|
||||
toolCallFocus?(callId: string): string
|
||||
/** @returns a new Session-owned incremental builder. */
|
||||
create(): ConversationViewBuilder<Node, Snapshot>
|
||||
/**
|
||||
|
||||
@@ -299,6 +299,8 @@ export interface InputZone {
|
||||
|
||||
/** Conversation View entries obtain their data from registered standard hooks. */
|
||||
export interface ConvViewOwnerProps {
|
||||
/** Open a tool call's inspector when an inspection target is available. */
|
||||
inspectCall: ((callId: string) => void) | undefined
|
||||
/** Focus request addressed to the selected View. */
|
||||
viewRequest: import('./views.ts').ConversationViewRequest | null
|
||||
/** Select a View and address one opaque focus identity to it. */
|
||||
@@ -321,7 +323,10 @@ export interface ConversationInjected {
|
||||
/** Business callbacks injected into the strict Session body. */
|
||||
export interface ConversationSessionInjected {
|
||||
/** Package-owned View roster source bound only for the Conversation body. */
|
||||
readonly hooks: { readonly conversationViews: ObservableSnapshot<readonly ViewTab[]> }
|
||||
readonly hooks: {
|
||||
readonly conversationViews: ObservableSnapshot<readonly ViewTab[]>
|
||||
readonly inspectCall: ObservableSnapshot<ConvViewOwnerProps['inspectCall']>
|
||||
}
|
||||
/** Bind input draft persistence to the Session-owned store instance. */
|
||||
bindDraftMirror: (write: (text: string) => void) => () => void
|
||||
/** Select and activate one View while addressing an opaque focus request to it. */
|
||||
|
||||
@@ -20,9 +20,15 @@
|
||||
border-bottom: 0.5px solid var(--dsw-alias-border-l3);
|
||||
}
|
||||
|
||||
.headerCompact {
|
||||
min-height: 0;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Blank Sessions retain navigation controls without conversation title or tabs. */
|
||||
.headerBlank {
|
||||
min-height: 0;
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -67,8 +67,9 @@ export function ConversationSessionHeader({
|
||||
const session = useSession(s => s)
|
||||
const conversation = useConversation(s => s)
|
||||
const hideChrome = session.blank && conversationPhase(session, conversation) === 'blank'
|
||||
const showTabs = !hideChrome && tabs.length > 1
|
||||
return (
|
||||
<header className={clsx(css.header, hideChrome && css.headerBlank)}>
|
||||
<header className={clsx(css.header, !showTabs && css.headerCompact, hideChrome && css.headerBlank)}>
|
||||
<div className={css.titleRow}>
|
||||
<div className={css.headerLeading} data-conversation-header-leading="">
|
||||
{renderSlot('conversation.session.header.leading', {})}
|
||||
@@ -138,7 +139,7 @@ export function ConversationSessionHeader({
|
||||
{renderSlot('conversation.session.header.corner', {})}
|
||||
</div>
|
||||
</div>
|
||||
{!hideChrome && tabs.length > 1 && (
|
||||
{showTabs && (
|
||||
<div className={css.tabs} role="tablist">
|
||||
{tabs.map(viewTab => (
|
||||
<button
|
||||
|
||||
@@ -12,9 +12,10 @@ import css from './ConversationRoot.module.css'
|
||||
*/
|
||||
export function DefaultConversationViews({
|
||||
view, useSession, useConversation, useConversationViews, useInput, inputActions, useStore, actions,
|
||||
renderSlot, bindDraftMirror, openView,
|
||||
renderSlot, bindDraftMirror, openView, useInspectCall,
|
||||
}: ConversationSessionSlotProps) {
|
||||
const tabs = useConversationViews(value => value)
|
||||
const inspectCall = useInspectCall(value => value)
|
||||
const selectedId = useStore(s => s.view)
|
||||
const active = resolveActiveView(tabs, selectedId)
|
||||
const session = useSession(s => s)
|
||||
@@ -36,6 +37,7 @@ export function DefaultConversationViews({
|
||||
return (
|
||||
<div className={css.viewArea}>
|
||||
{viewId !== undefined && renderSlot('conversation.view', {
|
||||
inspectCall,
|
||||
viewRequest,
|
||||
openView,
|
||||
completeViewRequest: actions.completeViewRequest,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ViewTab } from './contract/views.ts'
|
||||
|
||||
const DEFAULT_VIEW_ID = 'chat'
|
||||
/** Chat remains available when developer tools are disabled. */
|
||||
export const DEFAULT_VIEW_ID = 'chat'
|
||||
|
||||
/**
|
||||
* Resolve a preferred registered View, then Chat, without choosing another View.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import type { CommandContribution, CommandUiContract } from '@deepseek-ai/dsh-client-ui-commands/client'
|
||||
import type { ISession, SessionReference } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
@@ -47,7 +48,14 @@ async function bench() {
|
||||
if (upload === undefined) throw new Error('test file upload has no Session fixture')
|
||||
return upload(...args)
|
||||
}
|
||||
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
const developerTools = createSnapshotStore(true)
|
||||
runtime.ctx.provide('settingsScope', {
|
||||
developerTools: {
|
||||
enabled: developerTools,
|
||||
setEnabled: async (enabled: boolean) => { developerTools.set(enabled) },
|
||||
},
|
||||
bind: () => stubSettingsScope().scope,
|
||||
} as never)
|
||||
const connectWorkspace = vi.fn(async () => ROOT)
|
||||
const references = new Map<SessionId, SessionReference>()
|
||||
const opened = vi.fn<(id: SessionId) => void>()
|
||||
@@ -185,6 +193,45 @@ describe('Conversation inject API', () => {
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('offers Inspect only while a registered tool-call inspector is visible', async () => {
|
||||
const b = await bench()
|
||||
const body = b.conversationApi(ROOT)
|
||||
const source = body.injected.hooks.inspectCall
|
||||
const changed = vi.fn()
|
||||
const unsubscribe = source.subscribe(changed)
|
||||
expect(source.getSnapshot()).toBeUndefined()
|
||||
const removeDefinition = b.runtime.ctx.uiConversation.views.register({
|
||||
target: 'custom-inspector',
|
||||
toolCallFocus: callId => `tool:${callId}`,
|
||||
create: () => ({ empty: null, replace: () => null, apply: () => null }),
|
||||
})
|
||||
expect(source.getSnapshot()).toBeUndefined()
|
||||
const removeView = b.slots.register(
|
||||
{ name: 'conversation.view', id: 'custom-inspector' }, (() => null) as never,
|
||||
)
|
||||
await b.runtime.flush()
|
||||
const inspect = source.getSnapshot()!
|
||||
expect(inspect).toBeTypeOf('function')
|
||||
expect(source.getSnapshot()).toBe(inspect)
|
||||
inspect('call-1')
|
||||
expect(body.instance.store.getSnapshot().viewRequest).toEqual({ view: 'custom-inspector', focus: 'tool:call-1' })
|
||||
await b.runtime.ctx.settingsScope.developerTools.setEnabled(false)
|
||||
expect(source.getSnapshot()).toBeUndefined()
|
||||
await b.runtime.ctx.settingsScope.developerTools.setEnabled(true)
|
||||
expect(source.getSnapshot()).toBe(inspect)
|
||||
removeView()
|
||||
await b.runtime.flush()
|
||||
expect(source.getSnapshot()).toBeUndefined()
|
||||
inspect('stale-call')
|
||||
expect(body.instance.store.getSnapshot().viewRequest?.focus).not.toBe('tool:stale-call')
|
||||
expect(changed).toHaveBeenCalled()
|
||||
unsubscribe()
|
||||
changed.mockClear()
|
||||
removeDefinition()
|
||||
expect(changed).not.toHaveBeenCalled()
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('activates a target before committing an explicit View selection', async () => {
|
||||
const b = await bench()
|
||||
const binding = b.runtime.ctx.uiConversation.binding(ROOT)
|
||||
@@ -214,6 +261,11 @@ describe('Conversation inject API', () => {
|
||||
expect(header.instance.store.getSnapshot().view).toBe('chat')
|
||||
|
||||
removeTrajectory()
|
||||
await b.runtime.flush()
|
||||
activate.mockClear()
|
||||
body.injected.openView('trajectory', 'hidden-call')
|
||||
expect(activate).not.toHaveBeenCalled()
|
||||
expect(body.instance.store.getSnapshot().view).toBe('chat')
|
||||
removeChat()
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
SlotTestRuntime, stubSettingsScope, usePinnedBrowserLanguages,
|
||||
} from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { apply, inject, type ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
@@ -14,13 +14,14 @@ const SID = 'session-1' as SessionId
|
||||
|
||||
async function bench(options: { declareConversation?: boolean } = {}) {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
const developerTools = createSnapshotStore(true)
|
||||
runtime.ctx.provide('uiWorkspace', {
|
||||
openWorkspace: vi.fn(async (_workspaceId: unknown, beforeOpen: (id: SessionId) => void) => {
|
||||
beforeOpen(SID)
|
||||
}),
|
||||
openSession: vi.fn(),
|
||||
} as never)
|
||||
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
runtime.ctx.provide('settingsScope', { developerTools: { enabled: developerTools }, bind: () => stubSettingsScope().scope } as never)
|
||||
const locale = new LocaleRuntime(runtime.ctx)
|
||||
runtime.ctx.provide('locale', locale)
|
||||
runtime.slots.installLocale(locale)
|
||||
@@ -32,7 +33,7 @@ async function bench(options: { declareConversation?: boolean } = {}) {
|
||||
}
|
||||
const feature = await runtime.mount({ inject: [...inject], apply })
|
||||
if (options.declareConversation !== false) runtime.renderRoot()
|
||||
return { runtime, feature }
|
||||
return { runtime, feature, developerTools }
|
||||
}
|
||||
|
||||
function entry(
|
||||
@@ -43,6 +44,20 @@ function entry(
|
||||
}
|
||||
|
||||
describe('target-neutral Conversation apply wiring', () => {
|
||||
it('offers only Chat while developer tools are disabled and restores registered views when enabled', async () => {
|
||||
const b = await bench()
|
||||
const header = b.runtime.slots.entries('conversation.session.header')[0]!
|
||||
const source = (header.inject!() as { hooks: { conversationViews: ObservableSnapshot<readonly ViewTab[]> } }).hooks.conversationViews
|
||||
for (const id of ['chat', 'trajectory']) {
|
||||
b.runtime.slots.register({ name: 'conversation.view', id, label: id }, (() => null) as never)
|
||||
}
|
||||
await vi.waitFor(() => { expect(source.getSnapshot().map(tab => tab.id)).toEqual(['chat', 'trajectory']) })
|
||||
b.developerTools.set(false)
|
||||
expect(source.getSnapshot().map(tab => tab.id)).toEqual(['chat'])
|
||||
b.developerTools.set(true)
|
||||
expect(source.getSnapshot().map(tab => tab.id)).toEqual(['chat', 'trajectory'])
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
it('waits for the layout-owned conversation declaration before registering its subtree', async () => {
|
||||
const b = await bench({ declareConversation: false })
|
||||
expect(b.runtime.slots.entries('main')).toHaveLength(0)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Conversation assembly acceptance independent of Tool presentation. */
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, waitFor, within } from '@testing-library/react'
|
||||
import { useState } from 'react'
|
||||
@@ -79,7 +80,7 @@ function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) {
|
||||
async function bench(opts?: { blank?: boolean }) {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
const openSession = provideWorkspaceNavigation(runtime)
|
||||
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
runtime.ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) }, bind: () => stubSettingsScope().scope } as never)
|
||||
const locale = new LocaleRuntime(runtime.ctx)
|
||||
runtime.ctx.provide('locale', locale)
|
||||
runtime.slots.installLocale(locale)
|
||||
@@ -102,7 +103,7 @@ describe('resident composer', () => {
|
||||
it('renders the locked view state while no session exists at all', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
provideWorkspaceNavigation(runtime)
|
||||
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
runtime.ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) }, bind: () => stubSettingsScope().scope } as never)
|
||||
const locale = new LocaleRuntime(runtime.ctx)
|
||||
runtime.ctx.provide('locale', locale)
|
||||
runtime.slots.installLocale(locale)
|
||||
@@ -129,7 +130,7 @@ describe('resident composer', () => {
|
||||
it('keeps the complete Hero tree mounted when the first Workspace session appears', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
const openSession = provideWorkspaceNavigation(runtime)
|
||||
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
runtime.ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) }, bind: () => stubSettingsScope().scope } as never)
|
||||
const locale = new LocaleRuntime(runtime.ctx)
|
||||
runtime.ctx.provide('locale', locale)
|
||||
runtime.slots.installLocale(locale)
|
||||
@@ -196,7 +197,7 @@ describe('prompt rejection through the assembled composer', () => {
|
||||
it('renders the promptError alert strip and keeps the draft in the machine', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
const openSession = provideWorkspaceNavigation(runtime)
|
||||
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
runtime.ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) }, bind: () => stubSettingsScope().scope } as never)
|
||||
const locale = new LocaleRuntime(runtime.ctx)
|
||||
runtime.ctx.provide('locale', locale)
|
||||
runtime.slots.installLocale(locale)
|
||||
|
||||
@@ -233,6 +233,7 @@ function mount(
|
||||
useSession={useSession}
|
||||
useConversation={useConversation}
|
||||
useConversationViews={useConversationViews}
|
||||
useInspectCall={selector => selector(undefined)}
|
||||
useChat={useChat}
|
||||
useTrajectory={useTrajectory}
|
||||
useSessions={props.useSessions}
|
||||
|
||||
@@ -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-deliverables/README.md
|
||||
README.md: 8a2da4e763d3682dcf45c4a6f9529804c5d1bcc8
|
||||
README.zh.md: 7af5571e0fa5aeda323424fd54df9fa7e3878365
|
||||
README.md: 393fc8919bf64d71f5b61628871358aac0fc53d5
|
||||
README.zh.md: 1deeb28a04d7bfe07403559c14eb19da021b7e65
|
||||
@@ -36,6 +36,8 @@ The `present` tool row retains the delivery-document glyph while showing running
|
||||
|
||||
### The changed-files card
|
||||
|
||||
Developer tools must be enabled to show this card or request its summary. Turning the preference off removes the card immediately while explicit delivery cards, inline file links, and recorded workspace changes remain available.
|
||||
|
||||
The card renders the summary the Host serves for the turn's latest `workspace/changes` announcement, read once per announcement through the authenticated summary route; while the read is pending, once the Host answers that the summary is gone, or when it lists no file, there is no card. Its header names the complete changed-file count with the summed added and deleted lines, and each row shows one file's display path with its own counts, “binary” for a binary file, or “too large” for a file the Host did not capture. Rows appear in the recorded display order, so repository files above the working directory and files outside it sort first. Three rows show before a fold; a control below reveals every recorded file and, once expanded, collapses the list again from the bottom. Each row opens the turn's review in the right Sidebar on that file, and the header opens it on the first file. The first file section starts 20px below the closing prose, a following explicit-delivery section starts 16px below the card, and the action footer starts 20px below the last file section. Final file deliveries still require `present`.
|
||||
|
||||
### The review tab
|
||||
|
||||
@@ -36,6 +36,8 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交
|
||||
|
||||
### 改动文件卡片
|
||||
|
||||
只有开启开发者工具才展示此卡片并请求其摘要。关闭该偏好会立即隐藏卡片,同时保留显式交付卡片、行内文件链接和已记录的工作区改动。
|
||||
|
||||
卡片渲染 Host 为本轮最新一条 `workspace/changes` 宣告提供的摘要,每条宣告通过经过认证的摘要路由读取一次;读取尚未完成、Host 答复摘要已不存在,或摘要没有列出任何文件时,没有卡片。标题给出改动文件总数与增删行数合计,每一行显示一个文件的展示路径及其增删行数,二进制文件显示“二进制”,Host 没有捕获的文件显示“过大”。行按记录的展示顺序排列,因此仓库内位于工作目录之上的文件与工作区外的文件排在最前。折叠前显示三行;下方的控件展开全部记录文件,展开后同一位置的控件从底部收起列表。每一行在右侧 Sidebar 中打开本轮的 review 并选中该文件,标题则在第一个文件上打开它。首个文件区块位于收尾正文下方 20px,后续显式交付区块位于卡片下方 16px,操作页脚位于最后一个文件区块下方 20px。最终文件交付仍需调用 `present`。
|
||||
|
||||
### review tab
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
"@deepseek-ai/dsh-client-ui-conversation",
|
||||
"@deepseek-ai/dsh-client-ui-renderer",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar-right",
|
||||
"@deepseek-ai/dsh-client-ui-tool"
|
||||
"@deepseek-ai/dsh-client-ui-tool",
|
||||
"@deepseek-ai/dsh-client-ui-settings"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
@@ -77,7 +78,8 @@
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^"
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
@@ -21,6 +21,7 @@ const COLLAPSED_PRESENTED_COUNT = 4
|
||||
/** Summary reads, native-open callbacks, and shared gesture status supplied by the plugin. */
|
||||
export interface DeliverablesInjected {
|
||||
hooks: {
|
||||
showCodeDiff: ObservableSnapshot<boolean>
|
||||
presentedOpen: ObservableSnapshot<ReturnType<PresentedOpenController['state']['getSnapshot']>>
|
||||
presentedHost: ObservableSnapshot<ReturnType<PresentedOpenController['host']['getSnapshot']>>
|
||||
changesSummary: ObservableSnapshot<ReturnType<ChangesSummaryStore['state']['getSnapshot']>>
|
||||
@@ -63,15 +64,16 @@ export function DeliverablesTail(props: PropsRuntime<'conversation.chat.turnTail
|
||||
*/
|
||||
export function Deliverables({
|
||||
matched, openFile, t, sessionId, useSessions, openPresented, openChangesReview, usePresentedOpen, usePresentedHost,
|
||||
useChangesSummary, reloadPresentedHost, loadChangesSummary,
|
||||
useChangesSummary, reloadPresentedHost, loadChangesSummary, useShowCodeDiff,
|
||||
}: Pick<TurnTailOwnerProps, 'openFile'> & {
|
||||
matched: DeliverablesMatch
|
||||
} & PropsLocale<typeof NS> & Pick<SessionStandardProps, 'sessionId'> & Pick<GlobalStandardProps, 'useSessions'> & InjectFace<DeliverablesInjected>) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const showCodeDiff = useShowCodeDiff(value => value)
|
||||
const cwd = useSessions(state => state.byId[sessionId]?.cwd)
|
||||
const states = usePresentedOpen(value => value)
|
||||
const host = usePresentedHost(value => value)
|
||||
const announced = matched.changes
|
||||
const announced = showCodeDiff ? matched.changes : null
|
||||
const summary = useChangesSummary(value => announced === null ? undefined : value[changesSummaryUrl(sessionId, announced.seq)])
|
||||
useEffect(() => {
|
||||
if (announced !== null && summary === undefined) void loadChangesSummary(sessionId, announced.seq)
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import { changesReviewAddress } from '../changes.ts'
|
||||
import { ChangesDiffStore } from './changes-diff.ts'
|
||||
import { ChangesSummaryStore } from './changes-summary.ts'
|
||||
@@ -38,7 +39,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
}
|
||||
|
||||
/** Required services for the tail-slot and tab-type registrations and their dictionaries. */
|
||||
export const inject = ['slots', 'locale', 'uiConversation', 'remote', 'remote.session', 'sidebarRightTabs', 'sidebarRight']
|
||||
export const inject = ['slots', 'locale', 'uiConversation', 'remote', 'remote.session', 'sidebarRightTabs', 'sidebarRight', 'settingsScope']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the dictionaries, the turn-tail entry, and the comparison tab type.
|
||||
@@ -63,7 +64,8 @@ export function apply(ctx: ClientContext): void {
|
||||
id: '@deepseek-ai/dsh-client-ui-deliverables',
|
||||
locale: NS,
|
||||
inject: (): DeliverablesInjected => ({
|
||||
hooks: { presentedOpen: opener.state, presentedHost: opener.host, changesSummary: summaries.state },
|
||||
hooks: { presentedOpen: opener.state, presentedHost: opener.host, changesSummary: summaries.state,
|
||||
showCodeDiff: ctx.settingsScope.developerTools.enabled },
|
||||
reloadPresentedHost: () => opener.loadHost(),
|
||||
loadChangesSummary: (sessionId, seq) => summaries.load(sessionId, seq),
|
||||
openPresented: (sessionId, seq, index, action) => opener.open(sessionId, seq, index, action),
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* registrations' fiber-teardown removal (HMR safety) against the real
|
||||
* SlotRegistry.
|
||||
*/
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { cleanup, fireEvent, render, within } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -40,6 +41,7 @@ function openProps(controller = new PresentedOpenController(), summaries = new C
|
||||
controller.host.set({ name: 'desktop', available: true, fileManager: 'finder' })
|
||||
const sessions: SessionListState = { ids: [], byId: {}, phase: 'ready', subagentsByParent: {}, jobsBySession: {} }
|
||||
return {
|
||||
useShowCodeDiff: <T,>(select: (value: boolean) => T): T => select(true),
|
||||
useSessions: <T,>(select: (state: SessionListState) => T): T => select(sessions),
|
||||
reloadPresentedHost: vi.fn(() => controller.loadHost()),
|
||||
useChangesSummary: <T,>(select: (state: ReturnType<typeof summaries.state.getSnapshot>) => T): T =>
|
||||
@@ -494,6 +496,19 @@ describe('ChangedFiles card', () => {
|
||||
return { props, openFile, view }
|
||||
}
|
||||
|
||||
it('hides changed files and avoids summary reads when developer tools are off', () => {
|
||||
const props = openProps(new PresentedOpenController(), servedStore())
|
||||
const base = { ...props, matched: { changes, presented: [] }, openFile: vi.fn(), sessionId: SessionId('child-session'), t: makeTranslate(en) }
|
||||
const off = { useShowCodeDiff: <T,>(select: (enabled: boolean) => T): T => select(false) }
|
||||
const view = render(<Deliverables {...base} {...off} />)
|
||||
expect(view.container.querySelector('[data-changed-files]')).toBeNull()
|
||||
expect(props.loadChangesSummary).not.toHaveBeenCalled()
|
||||
view.rerender(<Deliverables {...base} />)
|
||||
expect(view.container.querySelector('[data-changed-files]')).not.toBeNull()
|
||||
view.rerender(<Deliverables {...base} {...off} />)
|
||||
expect(view.container.querySelector('[data-changed-files]')).toBeNull()
|
||||
})
|
||||
|
||||
it('reads the announced summary once and renders nothing while it loads, when it is gone, or when it lists no file', async () => {
|
||||
const summaries = new ChangesSummaryStore()
|
||||
const fetchMock = vi.fn(async (input: string | URL | Request) => {
|
||||
@@ -701,7 +716,7 @@ describe('plugin registration', () => {
|
||||
session,
|
||||
} as never)
|
||||
ctx.provide('remote.session', session as never)
|
||||
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) }, bind: () => stubSettingsScope().scope } as never)
|
||||
await ctx.plugin({ inject: localeInject, apply: applyLocale }).await()
|
||||
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
"src/css-modules.d.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../ui-settings"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* registrations against the real SlotRegistry (with fiber teardown proving
|
||||
* removal — HMR safety), and the inert node entry.
|
||||
*/
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
@@ -34,7 +35,7 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType<Context['plugi
|
||||
// and the forwarded-event port.
|
||||
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
ctx.provide('remote', { $on: () => () => {} } as never)
|
||||
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) }, bind: () => stubSettingsScope().scope } as never)
|
||||
await ctx.plugin({ inject: localeInject, apply: applyLocale }).await()
|
||||
// These specs assert the shipped Chinese copy. There is no jsdom `window` in
|
||||
// this lane, so browser-language detection never runs and the locale comes
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { Context, type Fiber } from '@deepseek-ai/cordis'
|
||||
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -60,7 +61,7 @@ async function bench() {
|
||||
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
// ui-theme's Appearance row binds a durable scope through these two.
|
||||
ctx.provide('remote', { $on: () => () => {} } as never)
|
||||
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) }, bind: () => stubSettingsScope().scope } as never)
|
||||
await ctx.plugin({ inject: themeInject, apply: themeApply }).await()
|
||||
await slotsFiber.await()
|
||||
const slots = ctx.get('slots') as SlotRegistry
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
@@ -20,7 +21,7 @@ async function baseContext(): Promise<Context> {
|
||||
await ctx.plugin(SlotRegistry).await()
|
||||
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
ctx.provide('remote', { $on: () => () => {} } as never)
|
||||
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) }, bind: () => stubSettingsScope().scope } as never)
|
||||
await ctx.plugin({ inject: localeInject, apply: applyLocale }).await()
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -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-settings-general/README.md
|
||||
README.md: c8f9781a22d1ded66b9b5120bd75983e01a0a4b4
|
||||
README.zh.md: c17bb44223d9d87ffc7989ef585541c50e94b5d0
|
||||
README.md: cf8bb5a75aa90842108646b57ceec67ada8ae711
|
||||
README.zh.md: 7df3eee13df7bd041cc5f09288d1b7fe28acded6
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Use this package to give the dsh web client a Settings panel, connection-recovery control, feature-contributed navigation, and sequential first-run onboarding. Users can open it from the sidebar, retry a failed connection immediately, and access a local configuration file when the Host makes one available on a loopback browser. Feature packages supply their own settings rows, sections, and onboarding steps; this package supplies their shared presentation and does not add onboarding copy or built-in General rows.
|
||||
Use this package to give the dsh web client a Settings panel, connection-recovery control, feature-contributed navigation, and sequential first-run onboarding. Users can open it from the sidebar, retry a failed connection immediately, and access a local configuration file when the Host makes one available on a loopback browser. Feature packages supply their own settings rows, sections, and onboarding steps; this package supplies their shared presentation and the Developer tools switch without adding onboarding copy.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -31,7 +31,9 @@ In Desktop, the account-row update control shows availability, progress, verific
|
||||
|
||||
### The General section
|
||||
|
||||
The General section holds rows registered into `settings.general.item` by feature packages — it has no built-in rows. Feature plugins own the row copy and behavior; the shell only provides the section and its slot. The Appearance row, for example, lives in ui-theme.
|
||||
The Developer tools switch controls the shared preference described by [ui-settings](../ui-settings/README.md#use-this-package). It is available in both Web and desktop, follows accepted changes immediately, and disables duplicate input while a write settles. A failed write displays localized retry guidance.
|
||||
|
||||
The General section holds the built-in Developer tools row and rows registered into `settings.general.item` by feature packages. Each registrant owns its row copy and behavior. The Appearance row, for example, lives in ui-theme.
|
||||
|
||||
### Opening the configuration file
|
||||
|
||||
@@ -49,7 +51,7 @@ The onboarding ledger projects in ascending order and mounts exactly one step at
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
The shell owns the chrome and the projections; every piece of content and copy belongs to a registrant.
|
||||
The shell owns the chrome and the projections; it contributes the Developer tools row, while feature registrants own their additional content and copy.
|
||||
|
||||
### Ledger projections
|
||||
|
||||
@@ -100,7 +102,7 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
These limits define what the shell itself provides versus what features must supply; they are current package constraints.
|
||||
|
||||
- **The General section has no built-in rows** — each row appears only when its owning feature plugin is mounted; the shell cannot fill the section alone.
|
||||
- **Additional General rows require their feature plugins** — the shell supplies Developer tools; feature plugins supply the remaining preferences.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
@@ -9,7 +9,7 @@ kind: "package-reference"
|
||||
|
||||
## 概述
|
||||
|
||||
使用本包可为 dsh Web 客户端提供 Settings 面板、连接恢复控件、由功能包贡献的导航,以及依次进行的首次运行引导。用户可以从侧边栏打开面板、立即重试失败的连接,并在宿主为回环浏览器提供本地配置文件时访问该文件。各功能包提供自己的设置行、分区和引导步骤;本包提供共享的界面展示,但不添加引导文案或「通用」分区的内置行。
|
||||
使用本包可为 dsh Web 客户端提供 Settings 面板、连接恢复控件、由功能包贡献的导航,以及依次进行的首次运行引导。用户可以从侧边栏打开面板、立即重试失败的连接,并在宿主为回环浏览器提供本地配置文件时访问该文件。各功能包提供自己的设置行、分区和引导步骤;本包提供共享的界面展示和开发者工具开关,但不添加引导文案。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -31,7 +31,9 @@ kind: "package-reference"
|
||||
|
||||
### 「通用」分区
|
||||
|
||||
「通用」分区承载由功能包注册进 `settings.general.item` 的行——它没有内置行。功能插件拥有行文案与行为;外壳只提供分区及其 slot。例如「外观」行位于 ui-theme。
|
||||
开发者工具开关控制 [ui-settings](../ui-settings/README.zh.md#use-this-package) 定义的共享偏好。Web 和桌面端均提供此开关,立即跟随已接受的变更,并在写入完成前禁用重复输入。写入失败时显示本地化的重试提示。
|
||||
|
||||
「通用」分区承载内置的开发者工具行和功能包注册进 `settings.general.item` 的行。每个注册方拥有自己的行文案与行为。例如「外观」行位于 ui-theme。
|
||||
|
||||
### 打开配置文件
|
||||
|
||||
@@ -49,7 +51,7 @@ kind: "package-reference"
|
||||
<details>
|
||||
<summary>实现细节——点击展开</summary>
|
||||
|
||||
外壳拥有界面框架与投影;每段内容与文案都属于某个注册方。
|
||||
外壳拥有界面框架与投影,并提供开发者工具行;功能注册方拥有其余内容与文案。
|
||||
|
||||
### 账本投影
|
||||
|
||||
@@ -100,7 +102,7 @@ kind: "package-reference"
|
||||
|
||||
这些限制说明外壳自身提供什么、功能包必须提供什么;它们是当前包约束。
|
||||
|
||||
- **「通用」分区没有内置行**:每一行仅在其所属功能插件挂载时出现;外壳单独无法填满该分区。
|
||||
- **额外的通用设置行需要相应功能插件**:外壳提供开发者工具;功能插件提供其他偏好。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 16px 0;
|
||||
border-bottom: 0.5px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-top: 4px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/** General Settings control for shared developer-tool visibility and previews. */
|
||||
import { useState } from 'react'
|
||||
import { Switch } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import css from './DeveloperToolsRow.module.css'
|
||||
|
||||
/** Accepted setting and ordered mutation supplied by the settings owner. */
|
||||
export interface DeveloperToolsRowInjected {
|
||||
hooks: { developerTools: ObservableSnapshot<boolean> }
|
||||
setEnabled(enabled: boolean): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the developer-tool toggle.
|
||||
* @param props - accepted preference, writer and localized copy.
|
||||
* @returns the General Settings row.
|
||||
*/
|
||||
export function DeveloperToolsRow({ useDeveloperTools, setEnabled, t }:
|
||||
PropsRuntime<'settings.general.item'> & PropsLocale<'settings'> & InjectFace<DeveloperToolsRowInjected>) {
|
||||
const enabled = useDeveloperTools(value => value)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [failed, setFailed] = useState(false)
|
||||
return <div className={css.row}>
|
||||
<div>
|
||||
<div className={css.title}>{t('developerTools.title')}</div>
|
||||
<div className={css.description}>{t('developerTools.description')}</div>
|
||||
{failed && <div role="alert">{t('developerTools.error')}</div>}
|
||||
</div>
|
||||
<Switch checked={enabled} disabled={busy} label={t('developerTools.title')}
|
||||
onChange={(next) => {
|
||||
setFailed(false)
|
||||
setBusy(true)
|
||||
void setEnabled(next).catch(() => { setFailed(true) }).finally(() => { setBusy(false) })
|
||||
}} />
|
||||
</div>
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import type { DesktopUpdateBridge } from './desktop-update-bridge.ts'
|
||||
import { DesktopUpdateSource } from './desktop-update-source.ts'
|
||||
import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx'
|
||||
import { GeneralSection } from './GeneralSection.tsx'
|
||||
import { DeveloperToolsRow, type DeveloperToolsRowInjected } from './DeveloperToolsRow.tsx'
|
||||
import { SettingsDocumentAction } from './SettingsDocumentAction.tsx'
|
||||
import type { SettingsDocumentActionInjected } from './SettingsDocumentAction.tsx'
|
||||
import { SettingsDocumentStore } from './settings-document-store.ts'
|
||||
@@ -68,6 +69,13 @@ export const inject = ['slots', 'locale', 'connection', 'remote', 'remote.settin
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
|
||||
name: 'settings.general.item', id: 'developer-tools', order: 15, locale: NS,
|
||||
inject: (): DeveloperToolsRowInjected => ({
|
||||
hooks: { developerTools: ctx.settingsScope.developerTools.enabled },
|
||||
setEnabled: enabled => ctx.settingsScope.developerTools.setEnabled(enabled),
|
||||
}),
|
||||
}, DeveloperToolsRow))
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-general: dictionaries')
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const carrier = (globalThis as typeof globalThis & { dshDesktop?: { protocolVersion: number; updates?: DesktopUpdateBridge } }).dshDesktop
|
||||
|
||||
@@ -26,6 +26,9 @@ export const zh = {
|
||||
'openDocument': '打开配置文件',
|
||||
'openDocument.error': '无法打开配置文件',
|
||||
'general.nav': '通用设置',
|
||||
'developerTools.title': '开发者工具',
|
||||
'developerTools.error': '保存失败,请重试',
|
||||
'developerTools.description': '显示用于调试和排查问题的工具与信息',
|
||||
'connection.error': '连接异常,刷新重试',
|
||||
'connection.connecting': '重新连接中',
|
||||
'connection.connected': '连接成功',
|
||||
@@ -62,6 +65,9 @@ export const en = {
|
||||
'openDocument': 'Open configuration file',
|
||||
'openDocument.error': 'Could not open configuration file',
|
||||
'general.nav': 'General',
|
||||
'developerTools.title': 'Developer tools',
|
||||
'developerTools.error': 'Could not save. Please try again.',
|
||||
'developerTools.description': 'Show tools and information for debugging and troubleshooting',
|
||||
'connection.error': 'Disconnected',
|
||||
'connection.connecting': 'Reconnecting',
|
||||
'connection.connected': 'Connected',
|
||||
|
||||
@@ -13,6 +13,7 @@ import { createClientTest, type TestClient, webApp } from '@deepseek-ai/dsh-clie
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { LOCALE_SETTINGS_NAMESPACE, LocaleSettingsSchema } from '@deepseek-ai/dsh-client-locale/src/locale-settings.ts'
|
||||
import { inject } from '../src/client/index.ts'
|
||||
import type { DeveloperToolsRowInjected } from '../src/client/DeveloperToolsRow.tsx'
|
||||
import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx'
|
||||
import { GeneralSection } from '../src/client/GeneralSection.tsx'
|
||||
import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx'
|
||||
@@ -100,9 +101,16 @@ describe('ui-settings-general apply', () => {
|
||||
// The nav label is a locale-following thunk; owners resolve at read time.
|
||||
expect(generalLabel(c)).toBe('通用设置')
|
||||
expect(c.ctx.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
|
||||
// The General items and the onboarding steps are feature-owned rows; this plugin seats none of its own.
|
||||
expect(c.ctx.slots.entries('settings.general.item').filter(row => row.locale === NS)).toEqual([])
|
||||
// The shared developer-tool control belongs to General; onboarding remains feature-owned.
|
||||
expect(c.ctx.slots.entries('settings.general.item').filter(row => row.locale === NS).map(row => row.options.id)).toEqual(['developer-tools'])
|
||||
expect(c.ctx.slots.entries('settings.onboarding').filter(row => row.locale === NS)).toEqual([])
|
||||
const developerRow = c.ctx.slots.entries('settings.general.item').find(row => row.options.id === 'developer-tools')!
|
||||
const developer = (developerRow.inject as unknown as () => DeveloperToolsRowInjected)()
|
||||
expect(developer.hooks.developerTools).toBe(c.ctx.settingsScope.developerTools.enabled)
|
||||
expect(developer.hooks.developerTools.getSnapshot()).toBe(false)
|
||||
const setEnabled = vi.spyOn(c.ctx.settingsScope.developerTools, 'setEnabled').mockResolvedValue(undefined)
|
||||
await developer.setEnabled(true)
|
||||
expect(setEnabled).toHaveBeenCalledExactlyOnceWith(true)
|
||||
const { controller, hooks } = actionInjectedOf(c)
|
||||
expect(controller.store.getSnapshot().status).toBe('idle')
|
||||
expect(hooks.snapshot).toBe(controller.store)
|
||||
@@ -205,7 +213,7 @@ describe('ui-settings-general apply', () => {
|
||||
expect(ownEntries(c, name)[0]).not.toBe(before[index])
|
||||
})
|
||||
expect(c.ctx.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
|
||||
expect(c.ctx.slots.entries('settings.general.item').filter(row => row.locale === NS)).toEqual([])
|
||||
expect(c.ctx.slots.entries('settings.general.item').filter(row => row.locale === NS).map(row => row.options.id)).toEqual(['developer-tools'])
|
||||
// The recovered registrations still ride the locale path.
|
||||
const english = localeView('en', 1)
|
||||
const chinese = localeView('zh', 2)
|
||||
|
||||
@@ -8,6 +8,8 @@ import { GeneralSection } from '../src/client/GeneralSection.tsx'
|
||||
import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx'
|
||||
import type { TriggerContentProps } from '../src/client/chrome.tsx'
|
||||
import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx'
|
||||
import { DeveloperToolsRow } from '../src/client/DeveloperToolsRow.tsx'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
|
||||
import { SettingsDocumentStore } from '../src/client/settings-document-store.ts'
|
||||
|
||||
@@ -71,6 +73,23 @@ describe('Desktop collapsed update badge', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('toggles developer tools using the accepted setting and disables duplicate writes', async () => {
|
||||
const state = createSnapshotStore(false)
|
||||
let finish!: () => void
|
||||
const setEnabled = vi.fn((enabled: boolean) => new Promise<void>((resolve) => {
|
||||
finish = () => { state.set(enabled); resolve() }
|
||||
}))
|
||||
render(<DeveloperToolsRow {...kit} t={t} useDeveloperTools={bindSnapshotSelector(state)} setEnabled={setEnabled} />)
|
||||
const toggle = screen.getByRole('switch', { name: 'Developer tools' })
|
||||
expect(toggle.getAttribute('aria-checked')).toBe('false')
|
||||
fireEvent.click(toggle)
|
||||
expect(setEnabled).toHaveBeenCalledWith(true)
|
||||
expect(toggle.hasAttribute('disabled')).toBe(true)
|
||||
finish()
|
||||
await waitFor(() => { expect(toggle.getAttribute('aria-checked')).toBe('true') })
|
||||
expect(toggle.hasAttribute('disabled')).toBe(false)
|
||||
})
|
||||
|
||||
describe('chrome content', () => {
|
||||
it('TriggerContent renders the icon with the label in the wide column', () => {
|
||||
const { container } = render(<TriggerContent {...kit} wide t={t} />)
|
||||
@@ -189,3 +208,17 @@ describe('SettingsDocumentAction', () => {
|
||||
expect(screen.getByRole('button', { name: 'Open configuration file' })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a failed developer-tool write and allows retry', async () => {
|
||||
const state = createSnapshotStore(false)
|
||||
const setEnabled = vi.fn().mockRejectedValueOnce(new Error('offline')).mockImplementation(async (enabled: boolean) => { state.set(enabled) })
|
||||
render(<DeveloperToolsRow {...kit} t={t} useDeveloperTools={bindSnapshotSelector(state)} setEnabled={setEnabled} />)
|
||||
const toggle = screen.getByRole('switch', { name: 'Developer tools' })
|
||||
fireEvent.click(toggle)
|
||||
expect((await screen.findByRole('alert')).textContent).toBe('Could not save. Please try again.')
|
||||
expect(toggle.hasAttribute('disabled')).toBe(false)
|
||||
expect(toggle.getAttribute('aria-checked')).toBe('false')
|
||||
fireEvent.click(toggle)
|
||||
await waitFor(() => { expect(toggle.getAttribute('aria-checked')).toBe('true') })
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
@@ -775,6 +775,7 @@ describe('SubagentModelSelectionCardController', () => {
|
||||
enabled: enabled?.op === 'set' ? enabled.value as boolean : false,
|
||||
allowedModels: allowedModels?.op === 'set' ? allowedModels.value as never[] : [],
|
||||
} })
|
||||
return true
|
||||
})
|
||||
const controller = new SubagentModelSelectionCardController({ ...host.scope, mutate }, catalog.ctx)
|
||||
const face = controller.inject()
|
||||
@@ -1100,6 +1101,7 @@ describe('shared Subagent card actions', () => {
|
||||
const set = vi.spyOn(limits.scope, 'set').mockImplementationOnce(async () => {
|
||||
await pending.promise
|
||||
limits.publish({ value: { maxDepth: 2, maxActiveSubagents: 8 }, user: { maxDepth: 2 } })
|
||||
return true
|
||||
})
|
||||
face.editLimit('maxDepth', '2')
|
||||
face.toggleEnabled()
|
||||
|
||||
@@ -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-settings/README.md
|
||||
README.md: 760b4a4567481b561ce319926149815119018177
|
||||
README.zh.md: f8e2128d734b650b7cd7a10c180f90ea01b77ab5
|
||||
README.md: d70cb35878d77c1d3e928aa222a2c7e96874ea2c
|
||||
README.zh.md: 87687ac3ccc7f19d7b34f5724eba260a02e92044
|
||||
@@ -29,7 +29,9 @@ Feature plugins use this package to store and edit their preferences without re-
|
||||
|
||||
### Binding a namespace
|
||||
|
||||
A feature calls `ctx.settingsScope.bind(spec)` with a per-namespace spec and gets a scope derived from the shared document mirror. The scope snapshot carries the resolved section, composition `base`, raw `user`, revision, writability, and host/memory mode; a field is overridden when it is present in `user`, even when its value equals `base`, and `unset` clears that override. Writes go through the scope: `set` and `unset` submit one operation, while `mutate` submits several ordered operations atomically. Each write is fenced by the namespace revision as `expectedRevision`, so a concurrent write from another surface is refused instead of silently overwritten. A staged editor can supply the revision where its draft began as a fixed fence; otherwise the scope uses the latest queued or mirrored revision.
|
||||
`ctx.settingsScope.developerTools` owns the shared Web and desktop preference `ui-developer-tools.enabled`, defaulting to `false`. Its `enabled` observable publishes accepted choices and `setEnabled` uses the same ordered settings writes. Desktop and loopback Web persist to the Host document; remote Web keeps this choice in one browser-local observable until reload without issuing Host writes. This controls presentation and HTML preview permissions, not Host authorization or Session recording.
|
||||
|
||||
A feature calls `ctx.settingsScope.bind(spec)` with a per-namespace spec and gets a scope derived from the shared document mirror. The scope snapshot carries the resolved section, composition `base`, raw `user`, revision, writability, and host/memory mode; a field is overridden when it is present in `user`, even when its value equals `base`, and `unset` clears that override. Writes go through the scope: `set` and `unset` submit one operation, while `mutate` submits several ordered operations atomically. These methods return `true` for Host acceptance and `false` for refusal or skipped writes; transport failures reject. Each write is fenced by the namespace revision as `expectedRevision`, so a concurrent write from another surface is refused instead of silently overwritten. A staged editor can supply the revision where its draft began as a fixed fence; otherwise the scope uses the latest queued or mirrored revision.
|
||||
|
||||
### Filling the settings slots
|
||||
|
||||
@@ -51,7 +53,7 @@ The package realizes one ownership rule: the browser keeps one shared mirror of
|
||||
|
||||
### The describe mirror
|
||||
|
||||
The plugin injects `remote` with its `settings` namespace, resolves Host persistence once from the fixed `remote.$host` facts, and owns the one `settings.describe` reader in the browser: a shared mirror refreshed on every forwarded `settings/document-updated` event and on `connection/reset` (the first connection included, closing the window where a commit lands between the eager read and the SSE subscription). Cross-namespace surfaces read it through `ctx.settingsScope.describe()`, a read/fold face (`getSnapshot`/`subscribe`/`ensure`, plus `acceptView` folding a write answer in).
|
||||
The Host half registers the `ui-developer-tools` schema and its default-off value with settings. The Client plugin injects `remote` with its `settings` namespace, resolves Host persistence once from the fixed `remote.$host` facts, and owns the one `settings.describe` reader in the browser: a shared mirror refreshed on every forwarded `settings/document-updated` event and on `connection/reset` (the first connection included, closing the window where a commit lands between the eager read and the SSE subscription). Cross-namespace surfaces read it through `ctx.settingsScope.describe()`, a read/fold face (`getSnapshot`/`subscribe`/`ensure`, plus `acceptView` folding a write answer in).
|
||||
|
||||
### Scope derivation
|
||||
|
||||
@@ -94,7 +96,7 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
These limits define where the settings transport cannot reach; they are current package constraints.
|
||||
|
||||
- **Non-loopback pages get no durable settings** — this Client keeps Host persistence disabled there, so a scope starts `unavailable` and never crosses the wire; every row it backs is inert even though Connection authentication covers the API.
|
||||
- **Non-loopback pages get no durable settings** — this Client keeps Host persistence disabled there, so a scope starts `unavailable` and never crosses the wire; generic scope writes are inert even though Connection authentication covers the API. The shared Developer tools preference instead provides browser-local changes.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
@@ -29,7 +29,9 @@ kind: "package-reference"
|
||||
|
||||
### 绑定命名空间
|
||||
|
||||
功能调用 `ctx.settingsScope.bind(spec)` 并传入按命名空间的 spec,得到一个由共享文档镜像派生的 scope。scope 快照携带解析后的分区、组合 `base`、原始 `user`、revision、可写性以及 host/内存模式;字段只要出现在 `user` 中即视为覆盖,即使其值与 `base` 相等,`unset` 会清除该覆盖。写入经 scope 进行:`set` 与 `unset` 提交一个操作,`mutate` 则原子提交多个有序操作。每次写入都以命名空间 revision 作为 `expectedRevision` 围栏,因此来自另一界面的并发写入会被拒绝,而不是被静默覆盖。暂存编辑器可以把开始草拟时读取的 revision 作为固定围栏传入;否则 scope 使用最新排队或镜像 revision。
|
||||
`ctx.settingsScope.developerTools` 管理 Web 和桌面端共享的偏好 `ui-developer-tools.enabled`,默认为 `false`。其 `enabled` 可观察值发布已接受的选择,`setEnabled` 使用相同的有序设置写入。桌面端和回环 Web 将设置持久化到 Host 文档;远程 Web 将此选择保存在单个浏览器本地可观察值中,刷新后重置,不发送 Host 写入。此设置控制界面展示和 HTML 预览权限,不控制 Host 授权或 Session 记录。
|
||||
|
||||
功能调用 `ctx.settingsScope.bind(spec)` 并传入按命名空间的 spec,得到一个由共享文档镜像派生的 scope。scope 快照携带解析后的分区、组合 `base`、原始 `user`、revision、可写性以及 host/内存模式;字段只要出现在 `user` 中即视为覆盖,即使其值与 `base` 相等,`unset` 会清除该覆盖。写入经 scope 进行:`set` 与 `unset` 提交一个操作,`mutate` 则原子提交多个有序操作。这些方法在 Host 接受时返回 `true`,拒绝或跳过写入时返回 `false`;传输失败会 reject。每次写入都以命名空间 revision 作为 `expectedRevision` 围栏,因此来自另一界面的并发写入会被拒绝,而不是被静默覆盖。暂存编辑器可以把开始草拟时读取的 revision 作为固定围栏传入;否则 scope 使用最新排队或镜像 revision。
|
||||
|
||||
### 填充设置 slot
|
||||
|
||||
@@ -51,7 +53,7 @@ kind: "package-reference"
|
||||
|
||||
### Describe 镜像
|
||||
|
||||
插件注入 `remote` 及其 `settings` 命名空间,从固定的 `remote.$host` 事实一次性解析 Host 持久化模式,并持有浏览器中唯一的 `settings.describe` 读取方:一面共享镜像,在每次转发的 `settings/document-updated` 事件与 `connection/reset` 时刷新(首次连接也包含在内,关闭「提交落在急切读取与 SSE 订阅之间」的窗口)。跨命名空间表面通过 `ctx.settingsScope.describe()` 读它,这是一个读取/折叠面(`getSnapshot`/`subscribe`/`ensure`,另有把写应答折入的 `acceptView`)。
|
||||
Host 部分向 settings 注册 `ui-developer-tools` schema 及其默认关闭值。Client 插件注入 `remote` 及其 `settings` 命名空间,从固定的 `remote.$host` 事实一次性解析 Host 持久化模式,并持有浏览器中唯一的 `settings.describe` 读取方:一面共享镜像,在每次转发的 `settings/document-updated` 事件与 `connection/reset` 时刷新(首次连接也包含在内,关闭「提交落在急切读取与 SSE 订阅之间」的窗口)。跨命名空间表面通过 `ctx.settingsScope.describe()` 读它,这是一个读取/折叠面(`getSnapshot`/`subscribe`/`ensure`,另有把写应答折入的 `acceptView`)。
|
||||
|
||||
### Scope 派生
|
||||
|
||||
@@ -94,7 +96,7 @@ kind: "package-reference"
|
||||
|
||||
这些限制说明设置传输层够不到的地方;它们是当前包约束。
|
||||
|
||||
- **非 loopback 页面没有持久化设置**:本 Client 在那里禁用 Host 持久化,因此 scope 以 `unavailable` 起步且从不跨线路;尽管 Connection 认证覆盖 API,它支撑的每一行仍在那里无效。
|
||||
- **非 loopback 页面没有持久化设置**:本 Client 在那里禁用 Host 持久化,因此 scope 以 `unavailable` 起步且从不跨线路;尽管 Connection 认证覆盖 API,通用 scope 写入仍在那里无效。共享的开发者工具偏好单独提供浏览器本地变更。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
@@ -50,12 +50,14 @@
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"react": "^18.2.0",
|
||||
"@deepseek-ai/schemastery": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-values": "workspace:^"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
]
|
||||
],
|
||||
"dependencies": {
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/** One accepted preference drives every developer-tool consumer. */
|
||||
import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import type { DeveloperToolsSettings } from '../developer-tools-settings.ts'
|
||||
import type { SettingsScope } from './settings-contract.ts'
|
||||
|
||||
/** Shared preference; no accepted value means developer tools are disabled. */
|
||||
export class DeveloperToolsPreference {
|
||||
/** Accepted enablement, observable through renderer-bound hooks. */
|
||||
readonly enabled: ObservableSnapshot<boolean>
|
||||
private readonly local = createSnapshotStore(false)
|
||||
|
||||
/**
|
||||
* @param scope - settings-owned namespace controller.
|
||||
*/
|
||||
constructor(private readonly scope: SettingsScope<DeveloperToolsSettings>) {
|
||||
this.enabled = scope.getSnapshot().mode === 'memory' ? this.local : {
|
||||
getSnapshot: () => scope.getSnapshot().value?.enabled ?? false,
|
||||
subscribe: (listener) => {
|
||||
let previous = this.enabled.getSnapshot()
|
||||
return scope.subscribe(() => {
|
||||
const next = this.enabled.getSnapshot()
|
||||
if (next === previous) return
|
||||
previous = next
|
||||
listener()
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a Host choice with ordered writes, or update the shared browser-local choice.
|
||||
* @param enabled - requested developer-tool mode.
|
||||
* @returns settlement after local publication or Host acceptance; rejects after a refused write recovers.
|
||||
*/
|
||||
async setEnabled(enabled: boolean): Promise<void> {
|
||||
if (this.scope.getSnapshot().mode === 'memory') {
|
||||
this.local.set(enabled)
|
||||
return
|
||||
}
|
||||
if (!await this.scope.set('enabled', enabled)) throw new Error('Developer tools preference was not saved')
|
||||
}
|
||||
}
|
||||
@@ -67,23 +67,26 @@ export interface SettingsScope<T> {
|
||||
* the latest queued or mirrored revision.
|
||||
* @param ops - ordered field operations copied when queued.
|
||||
* @param expectedRevision - optional fixed revision read by the domain editor.
|
||||
* @returns settlement after the mutation and any latest-write recovery read.
|
||||
* @returns true for Host acceptance, false for refusal or skipped writes, after any latest-write recovery.
|
||||
* Transport failures reject.
|
||||
*/
|
||||
mutate(ops: readonly SettingsPathOpView[], expectedRevision?: number): Promise<void>
|
||||
mutate(ops: readonly SettingsPathOpView[], expectedRevision?: number): Promise<boolean>
|
||||
/**
|
||||
* Queue one field write. Rapid writes preserve mutation order, each carries
|
||||
* the latest known namespace revision, and only the latest settlement may
|
||||
* publish; a rejected or failed latest write reloads Host state instead.
|
||||
* @param field - scalar field inside the namespace section.
|
||||
* @param value - JSON-shaped value selected by the user.
|
||||
* @returns settlement after the write and any latest-write recovery read.
|
||||
* @returns true for Host acceptance, false for refusal or skipped writes, after any latest-write recovery.
|
||||
* Transport failures reject.
|
||||
*/
|
||||
set(field: string, value: unknown): Promise<void>
|
||||
set(field: string, value: unknown): Promise<boolean>
|
||||
/**
|
||||
* Queue one field clear, so the field re-inherits the composition layer.
|
||||
* Shares {@link set}'s ordering, revision, and recovery contract.
|
||||
* @param field - scalar field inside the namespace section.
|
||||
* @returns settlement after the clear and any latest-write recovery read.
|
||||
* @returns true for Host acceptance, false for refusal or skipped writes, after any latest-write recovery.
|
||||
* Transport failures reject.
|
||||
*/
|
||||
unset(field: string): Promise<void>
|
||||
unset(field: string): Promise<boolean>
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
* over its snapshot.
|
||||
*/
|
||||
|
||||
import { DeveloperToolsPreference } from './developer-tools.ts'
|
||||
import { DEVELOPER_TOOLS_NAMESPACE } from '../developer-tools-settings.ts'
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
@@ -101,9 +103,9 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
|
||||
* revision, and recovery contract.
|
||||
* @param field - scalar field inside the namespace section.
|
||||
* @param value - JSON-shaped value selected by the user.
|
||||
* @returns settlement after the write and any latest-write recovery read.
|
||||
* @returns whether the Host accepted the write, after any recovery read.
|
||||
*/
|
||||
set(field: string, value: unknown): Promise<void> {
|
||||
set(field: string, value: unknown): Promise<boolean> {
|
||||
return this.mutate([{ op: 'set', path: [field], value: value as JsonValue }])
|
||||
}
|
||||
|
||||
@@ -111,9 +113,9 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
|
||||
* Queue one field clear; see {@link SettingsScope.unset} for the ordering,
|
||||
* revision, and recovery contract.
|
||||
* @param field - scalar field inside the namespace section.
|
||||
* @returns settlement after the clear and any latest-write recovery read.
|
||||
* @returns whether the Host accepted the clear, after any recovery read.
|
||||
*/
|
||||
unset(field: string): Promise<void> {
|
||||
unset(field: string): Promise<boolean> {
|
||||
return this.mutate([{ op: 'unset', path: [field] }])
|
||||
}
|
||||
|
||||
@@ -121,9 +123,9 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
|
||||
* Queue one atomic namespace mutation; see {@link SettingsScope.mutate}.
|
||||
* @param ops - ordered field operations copied when queued.
|
||||
* @param expectedRevision - optional fixed revision read by the domain editor.
|
||||
* @returns settlement after the mutation and any latest-write recovery read.
|
||||
* @returns whether the Host accepted the mutation, after any recovery read.
|
||||
*/
|
||||
mutate(ops: readonly SettingsPathOpView[], expectedRevision?: number): Promise<void> {
|
||||
mutate(ops: readonly SettingsPathOpView[], expectedRevision?: number): Promise<boolean> {
|
||||
const ownedOps = structuredClone(ops) as SettingsPathOpView[]
|
||||
const generation = ++this.writeGeneration
|
||||
return this.enqueue(async () => {
|
||||
@@ -131,15 +133,16 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
|
||||
const response = await this.ctx.remote.settings.mutate(this.spec.namespace, ownedOps, revision)
|
||||
if (!response.ok) {
|
||||
await this.recover(generation)
|
||||
return
|
||||
return false
|
||||
}
|
||||
if (this.disposed) return
|
||||
if (this.disposed) return true
|
||||
if (generation === this.writeGeneration) {
|
||||
this.pendingRevision = undefined
|
||||
this.mirror.acceptView(response.value)
|
||||
} else {
|
||||
this.pendingRevision = response.value.revision
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -162,15 +165,15 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
|
||||
await this.tail
|
||||
}
|
||||
|
||||
private enqueue(operation: () => Promise<void>): Promise<void> {
|
||||
if (this.persistence === 'memory' || this.disposed) return Promise.resolve()
|
||||
private enqueue(operation: () => Promise<boolean>): Promise<boolean> {
|
||||
if (this.persistence === 'memory' || this.disposed) return Promise.resolve(false)
|
||||
const task = this.tail.then(async () => {
|
||||
if (this.disposed) return
|
||||
await operation()
|
||||
if (this.disposed) return false
|
||||
return await operation()
|
||||
})
|
||||
// The returned task carries its own settlement to the caller; the queue
|
||||
// tail is kept fulfilled so one failed subscriber cannot strand later operations.
|
||||
this.tail = task.catch(() => {})
|
||||
this.tail = task.then(() => {}, () => {})
|
||||
return task
|
||||
}
|
||||
|
||||
@@ -230,6 +233,8 @@ declare module '@deepseek-ai/cordis' {
|
||||
* (`packages/client/tsdown.client.ts`).
|
||||
*/
|
||||
export class SettingsScopeBinder extends Service {
|
||||
/** Shared developer-tool preference owned by this settings provider. */
|
||||
readonly developerTools: DeveloperToolsPreference
|
||||
private readonly mirror: SettingsDescribeMirror
|
||||
private readonly schema: SettingsSchemaService
|
||||
private readonly persistence: 'host' | 'memory'
|
||||
@@ -256,6 +261,7 @@ export class SettingsScopeBinder extends Service {
|
||||
this.schema = config.schema
|
||||
this.persistence = config.persistence
|
||||
this.owner = ctx
|
||||
this.developerTools = new DeveloperToolsPreference(this.bind({ namespace: DEVELOPER_TOOLS_NAMESPACE }))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Shared Web and desktop developer-tool preference stored by the Host. */
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
|
||||
/** Namespace for developer UI and HTML preview capabilities. */
|
||||
export const DEVELOPER_TOOLS_NAMESPACE = 'ui-developer-tools'
|
||||
|
||||
/** Persisted developer-tool choice. */
|
||||
export interface DeveloperToolsSettings {
|
||||
/** Enable diagnostic views, preset selection, change summaries and scripted HTML previews. */
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
/** New installations and missing values use the basic interface. */
|
||||
export const DeveloperToolsSettingsSchema: z<DeveloperToolsSettings> = z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
})
|
||||
@@ -1,4 +1,14 @@
|
||||
/** Host loader entry for the browser implementation exported from `./client`. */
|
||||
/** Host registration of shared Web and desktop developer-tool preferences. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-settings'
|
||||
import { DEVELOPER_TOOLS_NAMESPACE, DeveloperToolsSettingsSchema } from './developer-tools-settings.ts'
|
||||
|
||||
/** Host plugin body — no host-side behavior for the settings domain base plugin. */
|
||||
export function apply(): void {}
|
||||
/**
|
||||
* Register the developer-tool preference when user settings are available.
|
||||
* @param ctx - Host plugin context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.inject(['settings'], (scope) => {
|
||||
scope.settings.register(DEVELOPER_TOOLS_NAMESPACE, DeveloperToolsSettingsSchema)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/** Developer-tool choices share settings validation, persistence and accepted-state publication. */
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { SettingsProvider, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { stubSettingsScope, TestRemote, RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply } from '../src/index.ts'
|
||||
import { DEVELOPER_TOOLS_NAMESPACE, DeveloperToolsSettingsSchema, type DeveloperToolsSettings } from '../src/developer-tools-settings.ts'
|
||||
import { DeveloperToolsPreference } from '../src/client/developer-tools.ts'
|
||||
import { apply as clientApply, inject } from '../src/client/index.ts'
|
||||
|
||||
class MemorySettings extends SettingsProvider {
|
||||
readonly writable = true
|
||||
protected load(): Promise<Record<string, unknown>> { return Promise.resolve({}) }
|
||||
protected persist(_ns: SettingsNamespace, _section: Record<string, unknown>): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
describe('developer tools settings', () => {
|
||||
it('reports a refused Host write after recovering accepted state', async () => {
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
const describeCall = vi.fn().mockResolvedValue({ ok: true, value: {
|
||||
writable: true, hasDocument: true, namespaces: [{
|
||||
ns: DEVELOPER_TOOLS_NAMESPACE,
|
||||
schema: DeveloperToolsSettingsSchema.toJSON(),
|
||||
value: { enabled: false }, revision: 1, applies: 'live', secrets: [],
|
||||
}],
|
||||
} })
|
||||
const mutate = vi.fn().mockResolvedValue({
|
||||
ok: false, error: new RemoteError('settings/rejected', 'conflict', { ns: DEVELOPER_TOOLS_NAMESPACE }),
|
||||
})
|
||||
new TestRemote(ctx, { settings: { describe: describeCall, mutate } })
|
||||
await ctx.plugin({ inject, apply: clientApply }).await()
|
||||
await ctx.settingsScope.describe().ensure()
|
||||
await expect(ctx.settingsScope.developerTools.setEnabled(true)).rejects.toThrow('not saved')
|
||||
expect(mutate).toHaveBeenCalledWith(DEVELOPER_TOOLS_NAMESPACE, [{ op: 'set', path: ['enabled'], value: true }], 1)
|
||||
expect(describeCall).toHaveBeenCalledTimes(2)
|
||||
expect(ctx.settingsScope.developerTools.enabled.getSnapshot()).toBe(false)
|
||||
})
|
||||
|
||||
it('shares one remote-browser preference across consumers and disposes it with the plugin', async () => {
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
const describeCall = vi.fn()
|
||||
const remote = new TestRemote(ctx, { settings: { describe: describeCall } })
|
||||
remote.$host = { home: undefined, isLoopback: false }
|
||||
const fiber = ctx.plugin({ inject, apply: clientApply })
|
||||
await fiber.await()
|
||||
const preference = ctx.settingsScope.developerTools
|
||||
expect(fiber.ctx.settingsScope.developerTools.enabled).toBe(preference.enabled)
|
||||
await preference.setEnabled(true)
|
||||
expect(fiber.ctx.settingsScope.developerTools.enabled.getSnapshot()).toBe(true)
|
||||
expect(describeCall).not.toHaveBeenCalled()
|
||||
await fiber.dispose()
|
||||
expect(ctx.get('settingsScope')).toBeUndefined()
|
||||
})
|
||||
it('defaults off, persists valid choices and removes its schema on disposal', async () => {
|
||||
const ctx = new Context()
|
||||
const provider = ctx.plugin(MemorySettings)
|
||||
onTestFinished(() => provider.dispose())
|
||||
await provider.await()
|
||||
const fiber = ctx.plugin({ apply })
|
||||
onTestFinished(() => fiber.dispose())
|
||||
await fiber.await()
|
||||
expect(ctx.settings.get(DEVELOPER_TOOLS_NAMESPACE)).toEqual({ enabled: false })
|
||||
await ctx.settings.update(DEVELOPER_TOOLS_NAMESPACE, { enabled: true })
|
||||
expect(ctx.settings.get(DEVELOPER_TOOLS_NAMESPACE)).toEqual({ enabled: true })
|
||||
await expect(ctx.settings.update(DEVELOPER_TOOLS_NAMESPACE, { enabled: 'yes' })).rejects.toThrow()
|
||||
await fiber.dispose()
|
||||
expect(ctx.settings.describe().map(row => row.ns)).not.toContain(DEVELOPER_TOOLS_NAMESPACE)
|
||||
})
|
||||
|
||||
it('stays off until accepted settings arrive and follows external changes', async () => {
|
||||
const host = stubSettingsScope<DeveloperToolsSettings>()
|
||||
const preference = new DeveloperToolsPreference(host.scope)
|
||||
const notify = vi.fn()
|
||||
const dispose = preference.enabled.subscribe(notify)
|
||||
expect(preference.enabled.getSnapshot()).toBe(false)
|
||||
host.publish({ status: 'ready', value: { enabled: true } })
|
||||
expect(preference.enabled.getSnapshot()).toBe(true)
|
||||
expect(notify).toHaveBeenCalledOnce()
|
||||
await preference.setEnabled(false)
|
||||
expect(host.set).toHaveBeenCalledWith('enabled', false)
|
||||
host.publish({ value: { enabled: false } })
|
||||
expect(preference.enabled.getSnapshot()).toBe(false)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps remote browser choices local and publishes only changed values', async () => {
|
||||
const host = stubSettingsScope<DeveloperToolsSettings>()
|
||||
host.publish({ mode: 'memory' })
|
||||
const preference = new DeveloperToolsPreference(host.scope)
|
||||
const notify = vi.fn()
|
||||
const dispose = preference.enabled.subscribe(notify)
|
||||
expect(preference.enabled.getSnapshot()).toBe(false)
|
||||
await preference.setEnabled(true)
|
||||
expect(preference.enabled.getSnapshot()).toBe(true)
|
||||
await preference.setEnabled(true)
|
||||
expect(notify).toHaveBeenCalledOnce()
|
||||
await preference.setEnabled(false)
|
||||
expect(preference.enabled.getSnapshot()).toBe(false)
|
||||
expect(host.set).not.toHaveBeenCalled()
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('ignores host revisions that do not change enablement', () => {
|
||||
const host = stubSettingsScope<DeveloperToolsSettings>()
|
||||
const preference = new DeveloperToolsPreference(host.scope)
|
||||
const notify = vi.fn()
|
||||
const dispose = preference.enabled.subscribe(notify)
|
||||
host.publish({ revision: 1 })
|
||||
host.publish({ value: { enabled: false } })
|
||||
expect(notify).not.toHaveBeenCalled()
|
||||
host.publish({ value: { enabled: true } })
|
||||
expect(notify).toHaveBeenCalledOnce()
|
||||
dispose()
|
||||
})
|
||||
@@ -16,8 +16,8 @@ function bench() {
|
||||
}
|
||||
|
||||
describe('settings domain base plugin', () => {
|
||||
it('keeps the host Loader entry inert', () => {
|
||||
expect(hostApply).not.toThrow()
|
||||
it('can load before a Host settings provider arrives', () => {
|
||||
expect(() => { hostApply(new Context()) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('mounts the scope service under settingsScope and reads once eagerly', async () => {
|
||||
|
||||
@@ -337,8 +337,8 @@ describe('SettingsScopeController', () => {
|
||||
throw new Error('write subscriber failed')
|
||||
})
|
||||
|
||||
await expect(scope.set('preference', 'dark')).resolves.toBeUndefined()
|
||||
await expect(scope.set('preference', 'light')).resolves.toBeUndefined()
|
||||
await expect(scope.set('preference', 'dark')).resolves.toBe(true)
|
||||
await expect(scope.set('preference', 'light')).resolves.toBe(true)
|
||||
|
||||
expect(mutate).toHaveBeenCalledTimes(2)
|
||||
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 3 })
|
||||
@@ -360,7 +360,7 @@ describe('SettingsScopeController', () => {
|
||||
})
|
||||
|
||||
await expect(scope.set('preference', 'dark')).rejects.toThrow('mirror fold failed')
|
||||
await expect(scope.set('preference', 'light')).resolves.toBeUndefined()
|
||||
await expect(scope.set('preference', 'light')).resolves.toBe(true)
|
||||
|
||||
expect(mutate).toHaveBeenCalledTimes(2)
|
||||
expect(mutate).toHaveBeenNthCalledWith(2,
|
||||
|
||||
@@ -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-sidebar-documentpreview/README.md
|
||||
README.md: f1d187cf4adc3b0e0400fa02babca1d28bffad15
|
||||
README.zh.md: cbdd915fb5e7a307c3c44717d472f63c5b4766b9
|
||||
README.md: 47f59c9561993d598dfe3ab65370412bfa3accf6
|
||||
README.zh.md: c0338a666a6a50aaf0d5203cbfcb2ac04ab2f0e8
|
||||
@@ -43,6 +43,8 @@ A tab uses the Session address built by `fileAddressFor`, carrying a relative or
|
||||
<a id="how-it-reads"></a>
|
||||
## How it reads
|
||||
|
||||
Developer tools selects the HTML preview policy in both Web and desktop. The renderer receives `interactivePreview` from plugin assembly. Off uses a DOMPurify-sanitized complete static document in an iframe with no sandbox permissions: CSP blocks scripts, external resources, connections, forms, and nested frames; all `href` and `xlink:href` attributes, refresh directives, and declarative shadow roots are removed before reparsing. Inline styles and data images remain visible, and related files are not read. On uses the scripted Blob preview described below. A mode change unmounts the previous frame and aborts its pending related-file reads. Other document formats retain their own policies.
|
||||
|
||||
The body reads its record, navigation and lifetime through `useTabInfo().tab`. `useResource<'file'>(tab.contentId)` supplies metadata; ordinary inject callbacks supply content reads:
|
||||
|
||||
- The resource snapshot contains only `status`, `value`, and `failure`; `value` is `WorkspaceFileStat` metadata. Content reads do not wait for the first metadata frame once the provider is available. Observation failures take precedence over Preview's change notice; neither automatically replaces loaded content.
|
||||
@@ -50,7 +52,7 @@ The body reads its record, navigation and lifetime through `useTabInfo().tab`. `
|
||||
- **Complete bytes** — PDF, HTML, and common images use an inject callback to `remote.workspaceFiles.readAll(sessionId, path, signal)`. `rpc.ts` decodes the wire base64 into `data: Uint8Array<ArrayBuffer>` for `{ kind: 'bytes', data }`. The Host's `maxFileBytes` cap rejects oversized files rather than truncating them. PDF copies retained bytes before worker transfer, keeping the Preview buffer usable. Bytes stay in transient view state, never persisted layouts or Session JSONL. Loading-mode changes retire previous results.
|
||||
- **Reload** — only the current Preview tab rereads through its Remote callbacks, preserving its scroll preference and retiring older requests. Its change notice compares the read version and the observation captured at read start with later `resource.value.version`; an already observed version does not become a new change after refresh. Reads neither refresh shared metadata nor clear another tab's notice.
|
||||
|
||||
HTML fills the body edge to edge in a Blob iframe with exactly `sandbox="allow-scripts"`, without `allow-same-origin`; scripts cannot access the parent application's origin or file reader. The renderer loads directly declared relative `.js` classic scripts and `.css` stylesheets through its ordinary inject callback to `remote.workspaceFiles.readRelated`, with fixed safety limits of 4 MiB per asset, 32 MiB total, and 64 distinct assets. Host code resolves the related path; `rpc.ts` decodes the returned bytes. Inside the renderer, base64 is used only to embed the iframe bootstrap payload in script text. A `<base href>` leaves dependency resolution to the browser, as do HTTPS resources. Local module imports, CSS `url()`/`@import`, and dynamic `fetch` do not use Host file access. Read failures, invalid UTF-8, or exceeded limits fail the preview rather than publishing a partial asset package. Replacing or unmounting the document releases its Blob URL.
|
||||
When Developer tools is enabled, HTML fills the body edge to edge in a Blob iframe with exactly `sandbox="allow-scripts"`, without `allow-same-origin`; scripts cannot access the parent application's origin or file reader. The renderer loads directly declared relative `.js` classic scripts and `.css` stylesheets through its ordinary inject callback to `remote.workspaceFiles.readRelated`, with fixed safety limits of 4 MiB per asset, 32 MiB total, and 64 distinct assets. Host code resolves the related path; `rpc.ts` decodes the returned bytes. Inside the renderer, base64 is used only to embed the iframe bootstrap payload in script text. A `<base href>` leaves dependency resolution to the browser, as do HTTPS resources. Local module imports, CSS `url()`/`@import`, and dynamic `fetch` do not use Host file access. Read failures, invalid UTF-8, or exceeded limits fail the preview rather than publishing a partial asset package. Replacing or unmounting the document releases its Blob URL.
|
||||
|
||||
PNG, JPEG, GIF, WebP, BMP, ICO, and SVG render through Blob URLs in an `<img>` static-image context, rounded inside a 12px inset. A wider image scales down to the pane's width at its intrinsic aspect ratio, a smaller image centres at its intrinsic CSS-pixel dimensions, and a taller image scrolls vertically. The renderer provides neither zoom nor drag-to-pan. SVG markup never enters the application DOM or an iframe, so its scripts cannot execute or reach the parent page. Replacing or unmounting the image revokes its Blob URL.
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ tab 使用 `fileAddressFor` 构造的 Session 地址,携带相对或绝对路
|
||||
<a id="how-it-reads"></a>
|
||||
## 怎么读
|
||||
|
||||
Web 和桌面端均通过开发者工具选择 HTML 预览策略。渲染器从插件组装层接收 `interactivePreview`。关闭时,将经 DOMPurify 清理的完整静态文档放入不授予沙箱权限的 iframe:CSP 禁止脚本、外部资源、连接、表单和嵌套框架;所有 `href` 和 `xlink:href` 属性、刷新指令及声明式 Shadow DOM 均在重新解析前被移除。行内样式和 data 图片仍可显示,不读取关联文件。开启时使用下述支持脚本的 Blob 预览。切换模式会卸载之前的框架并中止其待处理关联文件读取。其他文档格式保持各自策略。
|
||||
|
||||
正文通过 `useTabInfo().tab` 读取记录、导航和生命周期。`useResource<'file'>(tab.contentId)` 提供元数据,普通 inject 回调提供内容读取:
|
||||
|
||||
- 资源快照仅包含 `status`、`value` 和 `failure`;`value` 是 `WorkspaceFileStat` 元数据。提供方可用后,内容读取无需等待首个元数据帧。观察失败优先于 Preview 的变更提示显示;两者都不会自动替换已加载内容。
|
||||
@@ -50,7 +52,7 @@ tab 使用 `fileAddressFor` 构造的 Session 地址,携带相对或绝对路
|
||||
- **完整字节** —— PDF、HTML 和常见图片通过 inject 回调调用 `remote.workspaceFiles.readAll(sessionId, path, signal)`。`rpc.ts` 将线路上的 base64 解码为 `data: Uint8Array<ArrayBuffer>`,供 `{ kind: 'bytes', data }` 使用。Host 的 `maxFileBytes` 上限拒绝超大文件,不截断。PDF 在传给 worker 前复制保留的字节,使 Preview 缓冲区仍可使用。字节仅保存在临时视图状态中,绝不进入持久布局或 Session JSONL。加载模式变化会淘汰先前结果。
|
||||
- **重新载入** —— 仅当前 Preview tab 通过自己的 Remote 回调重读,保留滚动偏好并淘汰旧请求。变更提示将读取版本及起读时的观察版本与后续 `resource.value.version` 比较;刷新前已观察到的版本不会被当成新变化。读取既不刷新共享元数据,也不清除其它 tab 的提示。
|
||||
|
||||
HTML 以贴合正文四边的 Blob iframe 运行,沙箱属性严格为 `sandbox="allow-scripts"`,不含 `allow-same-origin`;脚本无法访问父应用的源或文件读取接口。渲染器通过普通 inject 回调调用 `remote.workspaceFiles.readRelated`,加载直接声明的相对 `.js` 经典脚本和 `.css` 样式表;固定安全上限为单个资源 4 MiB、总计 32 MiB、64 个不同资源。Host 代码解析关联路径,`rpc.ts` 解码返回的字节。在渲染器内部,base64 仅用于把 iframe 引导载荷嵌入脚本文本。`<base href>` 将依赖解析交给浏览器,HTTPS 资源也由浏览器处理。本地模块 import、CSS `url()`/`@import` 和动态 `fetch` 不使用 Host 文件访问。读取失败、无效 UTF-8 或超出上限都使预览失败,不发布部分资源包。替换或卸载文档会释放其 Blob URL。
|
||||
开启开发者工具时,HTML 以贴合正文四边的 Blob iframe 运行,沙箱属性严格为 `sandbox="allow-scripts"`,不含 `allow-same-origin`;脚本无法访问父应用的源或文件读取接口。渲染器通过普通 inject 回调调用 `remote.workspaceFiles.readRelated`,加载直接声明的相对 `.js` 经典脚本和 `.css` 样式表;固定安全上限为单个资源 4 MiB、总计 32 MiB、64 个不同资源。Host 代码解析关联路径,`rpc.ts` 解码返回的字节。在渲染器内部,base64 仅用于把 iframe 引导载荷嵌入脚本文本。`<base href>` 将依赖解析交给浏览器,HTTPS 资源也由浏览器处理。本地模块 import、CSS `url()`/`@import` 和动态 `fetch` 不使用 Host 文件访问。读取失败、无效 UTF-8 或超出上限都使预览失败,不发布部分资源包。替换或卸载文档会释放其 Blob URL。
|
||||
|
||||
PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `<img>` 静态图片上下文中渲染,带 12px 内边距和圆角。宽图按固有纵横比缩小到面板宽度,小图按固有 CSS 像素尺寸居中,超高图纵向滚动。渲染器既不提供缩放,也不提供拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此其中的脚本无法执行,也无法访问父页面。替换或卸载图片会撤销其 Blob URL。
|
||||
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
"@deepseek-ai/dsh-client-ui-sidebar-right",
|
||||
"@deepseek-ai/dsh-client-ui-session",
|
||||
"@deepseek-ai/dsh-api-remotes",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-settings"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
@@ -60,19 +61,21 @@
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-office-to-pdf": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-workspace-path": "workspace:^",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/react": "~18.3.1",
|
||||
"@types/react-dom": "~18.3.0",
|
||||
"clsx": "^2.0.0",
|
||||
"dompurify": "^3.4.11",
|
||||
"pdfjs-dist": "6.3.289",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"@deepseek-ai/dsh-office-to-pdf": "workspace:^"
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/** Complete HTML rendered in a script-enabled opaque iframe, without parent application access. */
|
||||
/** Static or interactive HTML in an opaque iframe, without parent application access. */
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import type { DocumentPreviewProps } from '../document/contract.ts'
|
||||
import { LoadingIndicator } from '../LoadingIndicator.tsx'
|
||||
import { createHtmlDocument } from './bootstrap.ts'
|
||||
import { createBasicHtmlDocument } from './basic-document.ts'
|
||||
import { packHtml } from './pack.ts'
|
||||
import type { ReadHtmlRelative } from './pack.ts'
|
||||
import { createReadHtmlRelative } from './read-relative.ts'
|
||||
@@ -14,7 +16,11 @@ import type {} from './locales.ts'
|
||||
import css from './HtmlBody.module.css'
|
||||
|
||||
/** Standard document inputs plus this renderer's dictionary. */
|
||||
export type HtmlBodyProps = DocumentPreviewProps & PropsLocale<'documentHtml'> & {
|
||||
export type HtmlBodyProps = DocumentPreviewProps & PropsLocale<'documentHtml'> & InjectFace<HtmlBodyInjected>
|
||||
|
||||
/** Related-file reader and accepted preview mode supplied by the plugin. */
|
||||
export interface HtmlBodyInjected {
|
||||
hooks: { interactivePreview: ObservableSnapshot<boolean> }
|
||||
/** Ordinary Remote callback bound by this renderer's Slot inject. */
|
||||
readonly readRelated: ReadHtmlRelated
|
||||
}
|
||||
@@ -61,12 +67,28 @@ function HtmlFrame({ data, readRelative, t }: FrameInput & { t: HtmlBodyProps['t
|
||||
* @param props - document bytes, hooks, related-file reader and locale.
|
||||
* @returns an isolated HTML document, or nothing for text delivery.
|
||||
*/
|
||||
export function HtmlBody({ content, resourceAddress, readRelated, useTabInfo, t }: HtmlBodyProps): ReactNode {
|
||||
export function HtmlBody({ content, resourceAddress, readRelated, useTabInfo, useInteractivePreview, t }: HtmlBodyProps): ReactNode {
|
||||
const interactivePreview = useInteractivePreview(value => value)
|
||||
const { tab } = useTabInfo()
|
||||
const readRelative = useMemo(
|
||||
() => createReadHtmlRelative(readRelated, resourceAddress, tab.signal),
|
||||
[readRelated, resourceAddress, tab.signal],
|
||||
)
|
||||
if (content.kind !== 'bytes') return null
|
||||
if (!interactivePreview) return <BasicHtmlFrame data={content.data} t={t} />
|
||||
return <HtmlFrame key={resourceAddress} data={content.data} readRelative={readRelative} t={t} />
|
||||
}
|
||||
|
||||
/** Static preview mounts a separate browsing context so a mode change retires running scripts. */
|
||||
function BasicHtmlFrame({ data, t }: Pick<FrameInput, 'data'> & { t: HtmlBodyProps['t'] }): ReactNode {
|
||||
const html = useMemo(() => {
|
||||
try {
|
||||
return createBasicHtmlDocument(data)
|
||||
} catch {
|
||||
// Malformed UTF-8 files cannot produce a document.
|
||||
return undefined
|
||||
}
|
||||
}, [data])
|
||||
if (html === undefined) return <p className={css.status} role="alert">{t('failed')}</p>
|
||||
return <iframe className={css.frame} srcDoc={html} sandbox="" title={t('frame')} data-html-preview />
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Static HTML preview with no scripts, network resources, forms or nested frames. */
|
||||
import { decodeText } from './bytes.ts'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
/**
|
||||
* Prepare static content before the browser can load any document resources.
|
||||
* @param data - complete UTF-8 HTML bytes.
|
||||
* @returns document with the restrictive CSP first in its head.
|
||||
*/
|
||||
export function createBasicHtmlDocument(data: Uint8Array<ArrayBuffer>): string {
|
||||
const clean = DOMPurify.sanitize(decodeText(data), {
|
||||
WHOLE_DOCUMENT: true,
|
||||
FORBID_TAGS: ['noscript', 'base', 'link', 'meta', 'iframe', 'frame', 'object', 'embed', 'set', 'animate', 'animateMotion', 'animateTransform'],
|
||||
FORBID_ATTR: ['href', 'xlink:href'],
|
||||
})
|
||||
const parsed = new DOMParser().parseFromString(clean, 'text/html')
|
||||
const policy = parsed.createElement('meta')
|
||||
policy.setAttribute('http-equiv', 'Content-Security-Policy')
|
||||
policy.setAttribute('content', "default-src 'none'; script-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:; media-src data:; connect-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'none'")
|
||||
parsed.head.prepend(policy)
|
||||
return `<!doctype html>${parsed.documentElement.outerHTML}`
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import type {} from '../index.ts'
|
||||
import type { DocumentPreviewDefinition } from '../document/registry.ts'
|
||||
import { hostFileOf } from '../rpc.ts'
|
||||
import { HtmlBody } from './HtmlBody.tsx'
|
||||
import type { HtmlBodyProps } from './HtmlBody.tsx'
|
||||
import type { HtmlBodyInjected } from './HtmlBody.tsx'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import { en, zh } from './locales.ts'
|
||||
|
||||
/** HTML implementation identity, shared by metadata and the keyed slot. */
|
||||
@@ -30,7 +31,8 @@ export function apply(ctx: Context): void {
|
||||
ctx.effect(() => ctx.slots.inject('sidebar.right.tab.document', () => ctx.slots.register(
|
||||
{
|
||||
name: 'sidebar.right.tab.document', key: HTML_BODY_ID, locale: 'documentHtml',
|
||||
inject: (): Pick<HtmlBodyProps, 'readRelated'> => ({
|
||||
inject: (): HtmlBodyInjected => ({
|
||||
hooks: { interactivePreview: ctx.settingsScope.developerTools.enabled },
|
||||
readRelated: (address, relativePath, signal) => {
|
||||
const file = hostFileOf(address)
|
||||
return ctx.remote.workspaceFiles.readRelated(file.sessionId, file.path, relativePath, signal)
|
||||
|
||||
@@ -78,7 +78,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
* Required browser services: the tab registry, the slot registry, copy, and the
|
||||
* Remote carrier with its `workspaceFiles` namespace.
|
||||
*/
|
||||
export const inject = ['slots', 'locale', 'sidebarRightTabs', 'remote', 'remote.workspaceFiles']
|
||||
export const inject = ['slots', 'locale', 'sidebarRightTabs', 'remote', 'remote.workspaceFiles', 'settingsScope']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the type, its dictionaries, its body, and its chip title.
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* and face — and that every registration is gone after dispose, which is what
|
||||
* makes a reload safe.
|
||||
*/
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { SidebarRightTabRegistry } from '@deepseek-ai/dsh-client-ui-sidebar-right/src/client/tab-registry.ts'
|
||||
@@ -43,6 +44,7 @@ interface Recorded {
|
||||
|
||||
async function boot() {
|
||||
const ctx = new Context()
|
||||
ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) } } as never)
|
||||
const tabs = new SidebarRightTabRegistry(ctx)
|
||||
const registered: Recorded[] = []
|
||||
const slots = {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Inert parsing precedes the restrictive policy so source markup cannot navigate or load frames. */
|
||||
import { expect, it } from 'vitest'
|
||||
import { createBasicHtmlDocument } from '../src/client/html/basic-document.ts'
|
||||
|
||||
it('removes MathML navigation and links exposed by parser mutation', () => {
|
||||
const cases = [
|
||||
'<math href="https://example.invalid/"><mi xlink:href="https://example.invalid/">x</mi></math>',
|
||||
'<form><math><mtext></form><form><mglyph><style></math><a href="https://example.invalid/">go</a>',
|
||||
]
|
||||
for (const source of cases) {
|
||||
const clean = createBasicHtmlDocument(new TextEncoder().encode(source))
|
||||
const reparsed = new DOMParser().parseFromString(clean, 'text/html')
|
||||
expect(reparsed.querySelector('[href], [xlink\\:href]')).toBeNull()
|
||||
expect(reparsed.querySelectorAll('meta[http-equiv]')).toHaveLength(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves static content and removes navigation, policy overrides and active documents', () => {
|
||||
const html = createBasicHtmlDocument(new TextEncoder().encode(`<!doctype html><html><head>
|
||||
<meta http-equiv="refresh" content="0;url=https://example.invalid">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src *">
|
||||
<base href="https://example.invalid"><style>p {color: red}</style>
|
||||
</head><body><p>Static</p><script>alert(1)</script><iframe src="https://example.invalid"></iframe>
|
||||
<object data="https://example.invalid"></object><embed src="https://example.invalid">
|
||||
<a href="https://example.invalid">Link</a><svg><a xlink:href="https://example.invalid">SVG</a></svg>
|
||||
<img src="data:image/png;base64,AA=="></body></html>`))
|
||||
const parsed = new DOMParser().parseFromString(html, 'text/html')
|
||||
expect(parsed.head.firstElementChild?.getAttribute('http-equiv')).toBe('Content-Security-Policy')
|
||||
expect(parsed.querySelector('meta')?.getAttribute('content')).toContain("connect-src 'none'")
|
||||
expect(parsed.querySelectorAll('script, iframe, object, embed, base, a[href], a[xlink\\:href]')).toHaveLength(0)
|
||||
expect(parsed.querySelectorAll('meta[http-equiv]')).toHaveLength(1)
|
||||
expect(parsed.querySelector('p')?.textContent).toBe('Static')
|
||||
expect(parsed.querySelector('style')?.textContent).toContain('color: red')
|
||||
expect(parsed.querySelector('img')?.getAttribute('src')).toBe('data:image/png;base64,AA==')
|
||||
})
|
||||
|
||||
it('preserves document styling and removes navigation inside nested templates and SVG animation', () => {
|
||||
const html = createBasicHtmlDocument(new TextEncoder().encode(`<html lang="zh" class="dark"><head><link rel="preconnect" href="https://example.invalid"></head><body style="margin:0" dir="rtl">
|
||||
<noscript><meta http-equiv="refresh" content="0;url=https://example.invalid"></noscript>
|
||||
<div><template shadowrootmode="open"><template><a href="https://example.invalid">Nested</a><iframe src="https://example.invalid"></iframe></template></template></div>
|
||||
<svg><a href="https://example.invalid"><set attributeName="href" to="https://example.invalid"/><animate attributeName="href" values="https://example.invalid"/></a></svg>
|
||||
</body></html>`))
|
||||
const parsed = new DOMParser().parseFromString(html, 'text/html')
|
||||
expect(parsed.documentElement.lang).toBe('zh')
|
||||
expect(parsed.documentElement.className).toBe('dark')
|
||||
expect(parsed.body.style.margin).toBe('0px')
|
||||
expect(parsed.body.dir).toBe('rtl')
|
||||
expect(parsed.querySelectorAll('noscript, link, set, animate')).toHaveLength(0)
|
||||
const nested = parsed.querySelector('template')!.content.querySelector('template')!.content
|
||||
expect(nested.querySelector('a')!.hasAttribute('href')).toBe(false)
|
||||
expect(nested.querySelector('iframe')).toBeNull()
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Document extension registration and dispatch through the production Sidebar and Slot renderer. */
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
@@ -36,6 +37,7 @@ afterEach(async () => {
|
||||
async function boot() {
|
||||
const rt = await SlotTestRuntime.create()
|
||||
runtime = rt
|
||||
rt.ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) } } as never)
|
||||
rt.ctx.provide('layout', { openRightbar: vi.fn(), closeRightbar: vi.fn() } as never)
|
||||
const locale = new LocaleRuntime(rt.ctx)
|
||||
rt.ctx.provide('locale', locale)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** HTML metadata and keyed slot contributions share one identity and unwind with their fiber. */
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
@@ -30,6 +31,7 @@ describe('HTML registration', () => {
|
||||
|
||||
it('registers its dictionary and matching keyed body, and removes all contributions on disposal', async () => {
|
||||
const ctx = new Context()
|
||||
ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) } } as never)
|
||||
// No Session or Tab services are mounted; the global callback must use its file address.
|
||||
const registry = new DocumentPreviewRegistry()
|
||||
const dictionaries = new Map<string, unknown>()
|
||||
@@ -46,7 +48,7 @@ describe('HTML registration', () => {
|
||||
bind: () => (key: keyof typeof en) => en[key],
|
||||
register: (name: string, value: unknown) => { dictionaries.set(name, value); return () => { dictionaries.delete(name) } },
|
||||
} as never)
|
||||
const fiber = ctx.plugin({ apply })
|
||||
const fiber = ctx.plugin({ inject: ['settingsScope'], apply })
|
||||
dispose = async () => { await fiber.dispose() }
|
||||
await fiber.await()
|
||||
expect(registry.candidates('INDEX.HTM').map(entry => entry.id)).toEqual([HTML_BODY_ID])
|
||||
|
||||
@@ -34,6 +34,7 @@ afterEach(() => {
|
||||
function props(text = '<p>hello</p>'): HtmlBodyProps {
|
||||
const signal = new AbortController().signal
|
||||
return {
|
||||
useInteractivePreview: select => select(true),
|
||||
resourceAddress: 'dsh-resource://file/session/html/index.html',
|
||||
content: { kind: 'bytes', data: utf8(text) },
|
||||
wrap: false,
|
||||
@@ -48,6 +49,28 @@ function props(text = '<p>hello</p>'): HtmlBodyProps {
|
||||
const utf8 = (text: string): Uint8Array<ArrayBuffer> => new TextEncoder().encode(text)
|
||||
|
||||
describe('HtmlBody', () => {
|
||||
it('renders static HTML without reading related files, running scripts or retaining an advanced frame', async () => {
|
||||
const initial = props('<h1>Preview</h1><script src="./script.js"></script><p>Static content</p>')
|
||||
const basic = { ...initial, useInteractivePreview: ((select: (enabled: boolean) => unknown) => select(false)) as HtmlBodyProps['useInteractivePreview'] }
|
||||
const view = render(<HtmlBody {...basic} />)
|
||||
const frame = screen.getByTitle(en.frame)
|
||||
expect(frame.getAttribute('sandbox')).toBe('')
|
||||
expect(frame.getAttribute('srcdoc')).toContain("default-src 'none'")
|
||||
expect(frame.getAttribute('srcdoc')).not.toContain('<script')
|
||||
expect(initial.readRelated).not.toHaveBeenCalled()
|
||||
expect(create).not.toHaveBeenCalled()
|
||||
const scripted = props('<p>Advanced</p><script>window.ready=true</script>')
|
||||
view.rerender(<HtmlBody {...scripted} />)
|
||||
const advanced = await screen.findByTitle(en.frame)
|
||||
expect(advanced).not.toBe(frame)
|
||||
expect(advanced.getAttribute('sandbox')).toBe('allow-scripts')
|
||||
view.rerender(<HtmlBody {...basic} />)
|
||||
expect(advanced.isConnected).toBe(false)
|
||||
expect(revoke).toHaveBeenCalledOnce()
|
||||
view.rerender(<HtmlBody {...basic} content={{ kind: 'bytes', data: new Uint8Array([255]) }} />)
|
||||
expect(screen.getByRole('alert').textContent).toBe(en.failed)
|
||||
})
|
||||
|
||||
it('renders a Blob iframe with only scripts allowed, keeping it mounted for unrelated props', async () => {
|
||||
const initial = props()
|
||||
const view = render(<HtmlBody {...initial} />)
|
||||
|
||||
@@ -64,6 +64,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../document/office-to-pdf"
|
||||
},
|
||||
{
|
||||
"path": "../ui-settings"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
/** ui-subagent browser half: catalog actions and read-only composer routing. */
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
@@ -78,7 +79,7 @@ async function fullBench(sessions: SessionSummary[]) {
|
||||
},
|
||||
} as never)
|
||||
ctx.provide('remote', { $on: () => () => {} } as never)
|
||||
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) }, bind: () => stubSettingsScope().scope } as never)
|
||||
await provideSlotFaces(ctx)
|
||||
await ctx.plugin({ inject: localeInject, apply: applyLocale }).await()
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
|
||||
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user