Merge remote-tracking branch 'origin/master' into feat/mcp-manager-core

This commit is contained in:
gengruilin
2026-10-29 17:11:59 +08:00
178 changed files with 9343 additions and 1393 deletions
@@ -0,0 +1,37 @@
# Bilingual-pair consistency record for 2026-10-20-workspace-tree-state-per-pane.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-20-workspace-tree-state-per-pane.md
/agent-note-the-workspace-tree-is-per-pane-state-drawn-inside-each-file-tab-and-the-plugin-mounting-it-decides-where-files-open:
en: 3768a6ac42af3a0b
zh: ba3c0826b2a4d287
/agent-note-the-workspace-tree-is-per-pane-state-drawn-inside-each-file-tab-and-the-plugin-mounting-it-decides-where-files-open/problem:
en: 3a7f14dd68a3ff14
zh: b21be07e8a7e6024
/agent-note-the-workspace-tree-is-per-pane-state-drawn-inside-each-file-tab-and-the-plugin-mounting-it-decides-where-files-open/decision:
en: a15c7c13f167e5ac
zh: ff261e3705c47ff0
/agent-note-the-workspace-tree-is-per-pane-state-drawn-inside-each-file-tab-and-the-plugin-mounting-it-decides-where-files-open/decision/the-tree-is-drawn-inside-each-file-tab-and-its-state-is-kept-per-pane:
en: 432aff475f1dfd21
zh: bbc058c1f967c067
/agent-note-the-workspace-tree-is-per-pane-state-drawn-inside-each-file-tab-and-the-plugin-mounting-it-decides-where-files-open/decision/the-rows-are-a-controlled-component-factory-over-flat-entries:
en: a06955933f9f7f0e
zh: ccda2bd27175d36b
/agent-note-the-workspace-tree-is-per-pane-state-drawn-inside-each-file-tab-and-the-plugin-mounting-it-decides-where-files-open/decision/the-plugin-that-mounts-the-tree-decides-where-a-file-lands:
en: 7cf8e3671900fe71
zh: 952f69cba9c44c10
/agent-note-the-workspace-tree-is-per-pane-state-drawn-inside-each-file-tab-and-the-plugin-mounting-it-decides-where-files-open/decision/navigation-is-consumed-by-the-destination-tab:
en: e7b254df55d9768d
zh: d3303f20bb9ebdd6
/agent-note-the-workspace-tree-is-per-pane-state-drawn-inside-each-file-tab-and-the-plugin-mounting-it-decides-where-files-open/decision/each-package-works-without-the-others:
en: dd160808a3ac77bc
zh: f63741f159717072
/agent-note-the-workspace-tree-is-per-pane-state-drawn-inside-each-file-tab-and-the-plugin-mounting-it-decides-where-files-open/alternatives-considered:
en: 1c5501c71afc7748
zh: 3a2b0ec2d7923a12
/agent-note-the-workspace-tree-is-per-pane-state-drawn-inside-each-file-tab-and-the-plugin-mounting-it-decides-where-files-open/consequences:
en: 586ec09cfcf60b82
zh: ef683318fbf68d71
/agent-note-the-workspace-tree-is-per-pane-state-drawn-inside-each-file-tab-and-the-plugin-mounting-it-decides-where-files-open/verification:
en: eae354783022bed8
zh: fe2563a3bb259d55
@@ -0,0 +1,74 @@
# Agent Note: The workspace tree is per-pane state drawn inside each file tab, and the plugin mounting it decides where files open
Status: implemented
English | [中文](2026-10-20-workspace-tree-state-per-pane.zh.md)
## Problem
Reading several workspace files needs a tree beside their previews without losing expansion, loaded levels, width, scroll, or keyboard position on every tab switch. The dock mounts only the selected tab's body, so a component instance cannot own the state shared by a pane's file tabs.
A floating panel holds one tab. A preview's file picker must replace that tab in its panel, while the files page must remain available and open content in the dock. A covering tree must close where content is navigated to, not where the initiating click happened.
## Decision
### The tree is drawn inside each file tab, and its state is kept per pane
`ui-sidebar-right` supplies `useTabInfo().panel` with the containing pane's `id` and `isFloating`; moving a tab updates those fields without changing `tab.id`. It supplies no pane lifetime. `ui-sidebar-files` owns each pane's tree state, visibility, width, listing, directory watches, scroll, and last revealed file. The navigator and toggle are Session-scoped Component Factories; `ui-sidebar-documentpreview` renders them inside the tab's content row and header, with stable positions across loading states. The docking kit does not acquire a files-specific overlay or layout rule.
A pane's records follow the tabs whose navigator last drew there, using each tab's `signal`. A replacement in the same pane retains the records. A moved tab uses its destination pane's records; a pane without records starts hidden at the default width. Tab-owned preview scroll is independent of that tree state. Records are released after their last associated tab closes or draws the navigator elsewhere.
Directory watches follow visible drawings rather than retained records. Hidden trees hold no Host directory watch; showing them again refreshes the root and expanded directories while retaining rows. A same-pane tab switch keeps the listing and watches across the replacement drawing. This extends the [directory watch lifetimes](../feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md) from one tab to the pane's drawn tree.
The files face owns ancestor reveal, scrolling, row measurement, and focus across drawings, so replacing a drawing does not discard an ongoing interaction. [The navigator reference](../../../../packages/client/ui-sidebar-files/README.md#the-navigator) owns layout, workspace-command, and keyboard rules.
### The rows are a controlled Component Factory over flat entries
The root-scoped `sidebar.files.tree` Factory keeps reusable rows, its local `accessory` slot, and localized copy with the files plugin. Callers own data access, expansion, opening, and reveal. The workspace navigator adapts its pane's directory states to these inputs.
### The plugin that mounts the tree decides where a file lands
`FileNavigatorInput.onOpen(address)` is the containing plugin's callback. The navigator constructs the file address but neither chooses placement nor closes the initiating tree after the call. Its `page` input controls layout only. A callback keeps the same navigator usable for the files page and document previews without teaching its rows either host's opening policy.
The [preview and files-page callbacks](../../../../packages/client/ui-sidebar-files/README.md#the-navigator) express different lifetimes: the preview gives way to new content, while the files page remains a directory browser. [Right Sidebar placement](../../../../packages/client/ui-sidebar-right/README.md#ctxsidebarright) owns replacement and reveal; the navigator does not duplicate those policies.
### Navigation is consumed by the destination tab
The files face follows the existing navigation observable only for tabs attached to its navigator. It retains each tab's consumed revision independently of the drawing and releases that subscription on tab closure or plugin disposal. It does not require a layout inventory, navigation-origin field, or an opening result from the right Sidebar.
The face waits for the destination drawing and measures its current row, so the previous tab's width cannot decide whether content is covered. It consumes navigation even while hidden to prevent a later manual show from replaying an old close. [The navigator reference](../../../../packages/client/ui-sidebar-files/README.md#the-navigator) defines the navigation sources and revision exclusions.
### Each package works without the others
The packages compose through the right Sidebar's tab information, the files Factories, and the tab-type registry. The preview imports only the files Factory declarations; files has no preview dependency. Without a registered viewer, the files page still lists the workspace and disables file rows it cannot open. Without the files plugin, the unregistered Factory positions render nothing and the preview keeps its own header, body, and file-handoff slots.
## Alternatives considered
**A static tree package or a component in `ui-primitives`.** The [package-shape rule](../../../../docs/defensive-patterns/package-surface.md) excludes new static libraries. A primitive would also require each caller to supply the generic tree labels. The Factory keeps that copy and registration with files, at the cost of requiring that plugin to draw rows.
**A tree that owns directory reads or requires a complete path list.** A workspace is listed one expanded directory at a time. The tree therefore accepts partial entries and per-directory loading, failure, and truncation state. Owning reads inside each drawing would split the state and watch lifetimes that must survive tab switches.
**A pane lifetime signal.** Floating and docking can replace the pane while retaining the tab. A pane signal would end tree state too early and invite attaching tab-owned resources to the wrong lifetime; transferring the signal would make it outlive the pane id used as the state key. The tabs' existing signals provide the required release condition.
**A pane-level overlay that stays mounted.** It requires a layer above docked and floating tab hosts and changes to layout, focus, measurement, and drop targets to avoid intercepting input, including floating resize handles. Drawing inside tabs keeps these concerns in their existing owners.
**Keep visited bodies mounted or copy per-tab trees.** Retaining every body still creates independent trees and watches for tabs sharing a pane and retains each preview's rendered content. Copying state cannot transfer watches and needs a conflict rule when tabs move between panes.
**Keep watches for a pane's whole lifetime or release by inspecting its layout.** The main docked pane rarely disappears, so lifetime-bound watches remain after hiding. Inspecting tab kinds in the layout couples files to every navigator consumer and still does not express whether the tree is drawn.
**A focus request bit consumed by a component effect.** A request stored on the initiating pane can survive a reveal elsewhere and affect a later drawing; a hidden consumer has no element to focus. The face ties focus to its actual drawings instead.
**Carry a dragged tab's tree preference into its new pane.** This needs another transfer policy and conflicts with the state already owned by the destination pane. A new pane starts with the same defaults as every other new pane.
## Consequences
Tab remounts do not own or reset the pane's tree. Moving a tab selects the destination's state rather than copying the source's; hiding releases directory watches without deleting expansion and scroll. Navigation consumption is per tab, while display preferences remain per pane, so an open in another pane does not close the source tree.
The files page retains directory reload and directory-action controls; a preview retains its file controls and handoff. Opening policy remains with those hosts, not the reusable rows.
## Verification
- `packages/client/ui-sidebar-right/tests/tab-info.client.spec.tsx`, `service.client.spec.ts`, and `seat.client.spec.tsx` cover live pane information, replacement, reveal, and framework tab hooks.
- `packages/client/ui-sidebar-files/tests/` covers per-pane state, navigation consumption, drawing lifetimes, workspace roots, and file opening callbacks.
- `packages/client/ui-sidebar-files/tests/file-tree.client.spec.tsx`, `file-tree-interactions.client.spec.tsx`, and `file-tree-factory.client.spec.tsx` cover generic rows, independent accessories, and real Factory composition with locale and fallback.
- `apps/web/tests/file-browser.e2e.ts` and `apps/web/tests/file-browser-composition.e2e.ts` own browser evidence; the latter disables each of `ui-sidebar-documentpreview` and `ui-sidebar-files` in the shipped Web composition.
@@ -0,0 +1,74 @@
# Agent Note: 工作区目录树是按分栏保存的状态,在每个文件 tab 内绘制;挂载它的插件决定文件在哪里打开
Status: implemented
[English](2026-10-20-workspace-tree-state-per-pane.md) | 中文
## 问题
连续阅读多个工作区文件需要预览旁的目录树在每次切换 tab 后保留展开状态、已加载的层、宽度、滚动位置与键盘位置。停靠布局只挂载选中 tab 的正文,因此组件实例不能拥有分栏内各文件 tab 共用的状态。
一个浮窗只容纳一个 tab。预览的文件选择器必须在浮窗内替换该 tab,文件页则必须保持可用,并在停靠区打开内容。遮盖正文的目录树应在内容被导航到的地方关闭,而不是在发起点击的地方关闭。
## 决定
### 目录树在每个文件 tab 内绘制,其状态按分栏保存
`ui-sidebar-right` 通过 `useTabInfo().panel` 提供所在分栏的 `id` 与 `isFloating`;移动 tab 时更新这些字段,`tab.id` 保持不变。它不提供分栏生命周期。`ui-sidebar-files` 拥有各分栏的目录树状态、可见性、宽度、列举、目录监听、滚动与上次定位的文件。导航栏和开关是 Session 作用域的 Component Factory;`ui-sidebar-documentpreview` 在 tab 的内容行与头部渲染它们,加载状态变化时保持位置稳定。停靠套件不增加 files 专属的覆盖层或布局规则。
分栏记录跟随最近在该分栏绘制导航栏的 tab,以各 tab 的 `signal` 为依据。同一分栏内的替换保留记录。移走的 tab 使用目标分栏的记录;没有记录的分栏默认隐藏目录树、使用默认宽度。tab 拥有的预览滚动状态独立于目录树状态。最后一个关联 tab 关闭或在别处绘制导航栏后,记录被释放。
目录监听跟随可见的绘制,而不是保留的记录。隐藏的目录树不持有 Host 目录监听;再次显示时保留行并刷新根目录与已展开目录。同一分栏内切换 tab 时,替代绘制之间保留目录列表与监听。这把[目录监听生命周期](../feature/2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md)从单个 tab 扩展为分栏中已绘制的目录树。
files face 负责跨绘制的祖先定位、滚动、行宽测量与焦点,因此替换绘制不会丢弃进行中的交互。[导航栏参考](../../../../packages/client/ui-sidebar-files/README.zh.md#the-navigator)定义布局、工作区命令与键盘规则。
### 各行是以扁平条目为输入的受控 Component Factory
root 作用域的 `sidebar.files.tree` Factory 将可复用的行、局部 `accessory` slot 和本地化文案留在 files 插件。调用方负责数据访问、展开、打开与定位。工作区导航栏把分栏的目录状态转换为这些输入。
### 挂载目录树的插件决定文件落在哪里
`FileNavigatorInput.onOpen(address)` 是容纳导航栏的插件提供的回调。导航栏构造文件地址,但不选择落位,也不在调用后关闭发起侧目录树。`page` 输入只控制布局。回调使同一导航栏能同时用于文件页与文档预览,不必让各行知道这两种宿主的打开策略。
[预览与文件页的回调](../../../../packages/client/ui-sidebar-files/README.zh.md#the-navigator)表达不同的生命周期:预览让位给新内容,文件页则保持为目录浏览器。[右侧 Sidebar 落位](../../../../packages/client/ui-sidebar-right/README.zh.md#ctxsidebarright)负责替换与揭示,导航栏不重复这些策略。
### 导航由目标 tab 消费
files face 仅跟随已关联到其导航栏的 tab 的现有导航可观察值。它独立于绘制保留各 tab 已消费的 revision,并在 tab 关闭或插件卸载时释放订阅。它不需要右侧 Sidebar 提供布局清单、导航来源字段或打开结果。
face 等待目标绘制并测量其当前行宽,因此上一个 tab 的宽度不能决定内容是否被覆盖。隐藏期间也消费导航,防止之后手动显示时重放旧的关闭操作。[导航栏参考](../../../../packages/client/ui-sidebar-files/README.zh.md#the-navigator)定义导航来源与 revision 排除规则。
### 每个包都可以单独工作
各包通过右侧 Sidebar 的 tab 信息、files Factory 和 tab 类型注册表组合。预览仅导入 files Factory 的声明;files 不依赖预览。没有已注册查看器时,文件页仍列出工作区,并禁用无法打开的文件行。没有 files 插件时,未注册的 Factory 位置不绘制内容,预览保留自己的头部、正文和文件交接 slot。
## 考虑过的替代方案
**静态目录树包或 `ui-primitives` 中的组件。** [包形态规则](../../../../docs/defensive-patterns/package-surface.zh.md)排除新增静态库。primitive 还要求各调用方传入通用目录树文案。Factory 使文案与注册留在 files,代价是绘制各行必须加载该插件。
**由目录树读取目录,或要求完整路径列表。** 工作区逐个展开的目录列出。因此目录树接收不完整条目,以及各目录的加载、失败与截断状态。在每次绘制中拥有读取会拆散必须跨 tab 切换保留的状态和监听生命周期。
**分栏生命周期信号。** 浮出和停靠可能替换分栏,但保留 tab。分栏信号会过早结束目录树状态,还会诱使调用方把 tab 资源绑到错误的生命周期;转移信号则让它比用作状态键的 pane id 活得更久。tab 已有的信号提供了所需的释放条件。
**始终挂载的分栏级覆盖层。** 它需要在停靠与浮动 tab 宿主之上增加一层,并修改布局、焦点、测量与拖放目标,避免拦截包括浮窗缩放手柄在内的输入。在 tab 内绘制则让这些职责留在原有所有者处。
**保留已访问正文或复制逐 tab 的目录树。** 保留所有正文仍会为同一分栏的不同 tab 创建独立目录树与监听,并保留各预览的已渲染内容。复制状态无法转移监听,还需要在 tab 跨分栏移动时决定冲突如何处理。
**让监听与分栏同寿,或检查布局后释放。** 主停靠分栏很少消失,因此按其生命周期持有的监听在隐藏后仍存在。检查布局中的 tab 类型会让 files 与每个导航栏消费方耦合,仍不能表达目录树是否正在绘制。
**由组件 effect 消费的聚焦请求位。** 保存在发起分栏的请求可能在揭示别处后留存,影响之后的绘制;隐藏的消费方也没有可聚焦元素。face 则把焦点与实际绘制绑定。
**把被拖动 tab 的目录树偏好带到新分栏。** 这需要另一套转移策略,并与目标分栏已经拥有的状态冲突。新分栏使用与其他新分栏相同的默认值。
## 后果
tab 重新挂载不拥有也不重置分栏目录树。移动 tab 时选择目标状态,不复制源状态;隐藏释放目录监听,但不删除展开与滚动状态。导航消费按 tab 保存,显示偏好仍按分栏保存,因此在另一分栏打开内容不会关闭源目录树。
文件页保留目录重新读取与目录操作控件;预览保留文件控件与交接操作。打开策略由这些宿主拥有,不属于可复用的行。
## 验证
- `packages/client/ui-sidebar-right/tests/tab-info.client.spec.tsx`、`service.client.spec.ts` 与 `seat.client.spec.tsx` 覆盖实时分栏信息、原位替换、揭示与框架 tab hook。
- `packages/client/ui-sidebar-files/tests/` 覆盖逐分栏状态、导航消费、绘制生命周期、工作区根目录与文件打开回调。
- `packages/client/ui-sidebar-files/tests/file-tree.client.spec.tsx`、`file-tree-interactions.client.spec.tsx` 与 `file-tree-factory.client.spec.tsx` 覆盖通用行、独立附件,以及带 locale 和 fallback 的真实 Factory 组合。
- `apps/web/tests/file-browser.e2e.ts` 与 `apps/web/tests/file-browser-composition.e2e.ts` 负责浏览器验证;后者在随包发布的 Web 组合中分别禁用 `ui-sidebar-documentpreview` 与 `ui-sidebar-files`。
@@ -18,8 +18,8 @@
en: ca63fb903b447c2d
zh: 3014adbdb0a890ee
/agent-note-on-demand-watching-and-automatic-refresh-for-sidebar-previews-and-file-trees/implementation-and-responsibilities/client-and-sidebar:
en: 5cb8326d8a5b0407
zh: a31173f990276856
en: 5be003d933357fee
zh: 0aec7cea1b06a7ce
/agent-note-on-demand-watching-and-automatic-refresh-for-sidebar-previews-and-file-trees/watch-requests-and-notifications:
en: 025ba83ae78824df
zh: 264d74d2bdfe3d27
@@ -42,8 +42,8 @@
en: d688642e5f272d48
zh: 96088fbc0471aeae
/agent-note-on-demand-watching-and-automatic-refresh-for-sidebar-previews-and-file-trees/directory-node-tree-and-lifecycle:
en: 2ea8615be8d5229b
zh: 0c73fa4ebb06445e
en: eb011d181eeb83e3
zh: 7d2cb793fd1737b5
/agent-note-on-demand-watching-and-automatic-refresh-for-sidebar-previews-and-file-trees/user-flows:
en: eb4005a334103895
zh: e2c207faffa64e57
@@ -57,8 +57,8 @@
en: 4f343f7a6c4d3ba4
zh: a4390843a20f8dde
/agent-note-on-demand-watching-and-automatic-refresh-for-sidebar-previews-and-file-trees/readiness-races-and-refresh-coalescing:
en: caadd187479e9074
zh: 3deedd8093f60703
en: baff3282e8925728
zh: 2052ca0afadd7a1b
/agent-note-on-demand-watching-and-automatic-refresh-for-sidebar-previews-and-file-trees/alternatives-considered:
en: 8a8c19229647c760
zh: 2de283fc2c815e65
@@ -60,10 +60,11 @@ Notifications invoke existing reload/list operations; a failed reload does not r
| Package-private `ResourceGroup` | [resource-group.ts](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/resource-group.ts) | Observes members through existing `resources.source(address)`; `add` joins a resource, `set` reconciles full membership, and `close` releases all members without changing the generic Resource service |
| `DocumentBodyOwner`, `HtmlBody`, `createReadHtmlRelative` | [Renderer inputs](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts), [HTML body](../../../../packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.tsx), [Related-file reader](../../../../packages/client/ui-sidebar-documentpreview/src/client/html/read-relative.ts) | Renderers declare read members through `addResource` and publish the current dependency list through `setResources`; HTML adds CSS/JS resources from Host-reported paths after each read returns and releases unreferenced members after parsing |
| Document renderers | [DocumentContent](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) and renderer directories in the same package | Do not watch directly; keep consuming text, bytes, or renderer revisions; `failed()` ends renderer loading without recording a successful version. Check cancellation of previous work, release of old objects, and retention of existing view preferences during refresh |
| `filesFace`, `FilesInjected` | [Files face](../../../../packages/client/ui-sidebar-files/src/client/face.ts) | Remain the existing business entry point, directly owning each Tab's root node; bind directory reads and change streams and forward expand, collapse, refresh, and automatic-refresh operations without a new `FilesController` |
| `filesFace`, `FilesInjected` | [Files face](../../../../packages/client/ui-sidebar-files/src/client/face.ts) | Remain the existing business entry point, directly owning each pane's root node while a navigator draws the pane's tree ([per-pane state](../architecture/2026-10-20-workspace-tree-state-per-pane.md)); bind directory reads and change streams and forward expand, collapse, refresh, and automatic-refresh operations without a new `FilesController` |
| Package-private `DirectoryNode` | [directory-node.ts](../../../../packages/client/ui-sidebar-files/src/client/directory-node.ts) | Represents one open directory, owning its stream, read state, and open children; handles local refresh, expansion, recursive closure, and propagation of automatic-refresh state; `setExpanded` updates pending restoration from the latest expansion preferences |
| `createFilesStore`, `FilesTabState`, `LevelState` | [Files store](../../../../packages/client/ui-sidebar-files/src/client/store.ts) | Retain expansion, scrolling, and displayed directory data; distinguish initial loading from refresh over existing content to avoid unmounting subtrees on every background read; hold no watcher or AbortController |
| `FilesBody`, `Level`, `Entry` | [FilesBody.tsx](../../../../packages/client/ui-sidebar-files/src/client/FilesBody.tsx) | Delegate expansion, collapse, refresh, and panel unmount to injected callbacks; render node results without scanning the entire tree into a watch set, using existing framework hooks and store reads |
| `createFilesStore`, `FilesPaneState`, `FilesTreeState`, `LevelState` | [Files store](../../../../packages/client/ui-sidebar-files/src/client/store.ts) | Retain expansion and displayed directory data; distinguish initial loading from refresh over existing content to avoid unmounting subtrees on every background read; hold no watcher or AbortController |
| `FileNavigator` | [FileNavigator.tsx](../../../../packages/client/ui-sidebar-files/src/client/FileNavigator.tsx), [workspace-tree.ts](../../../../packages/client/ui-sidebar-files/src/client/workspace-tree.ts) | Read per-pane state through framework hooks; convert listed levels into entries and directory states; pass expansion, collapse, and drawing lifetimes to the injected Files face, which owns listing, refresh, and watches |
| `FileTree` | [FileTree.tsx](../../../../packages/client/ui-sidebar-files/src/client/FileTree.tsx) | Draw entries, directory states, and expansion from props; report row actions through `onToggle` and `onOpen`. The root-scoped `sidebar.files.tree` Factory reads localized copy and its local `accessory` Component through framework seats, without owning listings or watches |
| Files registration | [Files client/index.ts](../../../../packages/client/ui-sidebar-files/src/client/index.ts) | Add a directory-change callback to the existing `filesFace`; keep async effects in injection, without importing another feature plugin's runtime exports |
## Watch requests and notifications
@@ -176,13 +177,14 @@ Pause and play use existing icons; manual reload retains its circular arrow. No
```text
Files.open
→ FilesBody → Session.cwd + Tab
→ filesFace.start → DirectoryNode.open
→ FileNavigator → Session.cwd + pane
→ filesFace.hold → DirectoryNode.open
→ changes(sessionId, path)
→ Host.watch(depth: 0) → ready
→ DirectoryNode → list(sessionId, path)
→ createFilesStore.levels[path]
→ Level / Entry
→ createFilesStore.byPane[paneId].tree.levels[path]
→ FileNavigator → workspace-tree.ts: workspaceEntries / workspaceDirectories
→ renderFactorySlot('sidebar.files.tree', input) → Level / Entry
Entry.expand
→ filesFace.toggle
@@ -204,7 +206,7 @@ The directory panel obtains invalidations and listings through injected Remote c
## Directory-node tree and lifecycle
The Files runtime is itself an on-demand directory tree. It does not first construct the whole Workspace tree or separately derive a flat watcher set. Existing `filesFace` owns each Tab's root; `DirectoryNode` owns reads, observation, and release recursively, without another controller class.
The Files runtime is itself an on-demand directory tree. It does not first construct the whole Workspace tree or separately derive a flat watcher set. Existing `filesFace` owns each pane's root; `DirectoryNode` owns reads, observation, and release recursively, without another controller class.
| Node responsibility | Behavior |
|---|---|
@@ -239,14 +241,14 @@ The parent's watch still detects deletion, renaming, or replacement of a collaps
| First open | Subscribe to the file Resource and read contents | Open the root node, which subscribes and lists |
| Expand child | No effect | The parent opens the child; the child subscribes and reads direct entries, progressively restoring still-present descendants from preferences |
| Collapse child | Open file previews keep their watches | Release that directory and its hidden descendants; expansion preferences and displayed cache may remain |
| Switch Tab or unmount panel | The Tab pin retains file metadata; an unmounted body does not reread, and refreshes on return according to pending Resource changes and toggle state | Retain expanded nodes and watchers for the Tab lifetime; do not introduce another panel-visibility lifetime |
| Switch Tab or hide the tree | The Tab pin retains file metadata; an unmounted body does not reread, and refreshes on return according to pending Resource changes and toggle state | Switching between a pane's file tabs keeps expanded nodes and watchers, because the next tab draws the tree in the same commit; hiding the tree or showing a tab without it closes them while the store keeps expansion and displayed levels, which the next drawing lists again in place |
| Manual refresh | Reuse the current preview reload without rebuilding the Resource system | Recursively refresh open children from the root, without entering collapsed directories or rebuilding all watchers |
| Last holder closes | Resource abort ends the file stream; the Host awaits watcher closure | Tab closure ends every directory stream, cancels reads, and forgets Tab state |
| Last holder closes | Resource abort ends the file stream; the Host awaits watcher closure | Once no navigator is mounted in a pane and no open tab last drew one there, every directory stream ends, reads are cancelled, and the pane's state is forgotten |
| Connection recovers | Stat again after the new stream's ready frame, discovering changes even without event replay | List every still-watched directory after its new stream's ready frame |
Files watches follow Tab lifetimes. Collapse closes the corresponding subtree; Tab closure releases the root and its entire active tree. Temporarily hiding the panel does not rebuild nodes, keeping implementation within the existing entry point and directory objects. The store retains expansion preferences; nodes use them for progressive restoration only when reopened, without a second global expansion set.
Files watches follow each pane's drawn tree. Collapse closes the corresponding subtree; the end of the pane's last drawing releases the root and its entire active tree, and the pane's state is forgotten once no open tab last drew the tree there. The store retains expansion preferences and displayed levels; nodes use them for progressive restoration only when reopened, without a second global expansion set.
`ResourceRegistry` continues sharing identical complete file Resource addresses. Parent-child ownership prevents duplicate active branches inside one Tab. This version adds no global watcher deduplication across Sessions, windows, or path aliases. Ordinary file rows do not each subscribe to file watches; only files opened as preview Resources acquire independent file observation.
`ResourceRegistry` continues sharing identical complete file Resource addresses. Parent-child ownership prevents duplicate active branches inside one pane. This version adds no global watcher deduplication across Sessions, windows, or path aliases. Ordinary file rows do not each subscribe to file watches; only files opened as preview Resources acquire independent file observation.
## User flows
@@ -281,8 +283,8 @@ Observation, reads, and presentation need a few explicit ordering rules, not dis
- Events mean that a target may have changed, not exact replay of a user operation. Duplicate Chokidar events and consecutive writes may coalesce; files deduplicate versions, and directories coalesce pending refreshes.
- Each directory has at most one active list. Further invalidation during that request or its completion marks a pending reread, which runs afterwards rather than building an arbitrary queue of refresh jobs.
- File refresh reuses read generations and renderer revisions. Consecutive notifications must not let old requests overwrite new content or endlessly reload an already handled version.
- Background directory refresh retains the displayed listing without a display-unused `refreshing` flag. Only a directory without displayable entries enters `loading`, avoiding subtree flicker and repeated watcher reconstruction.
- After collapse, deletion, or Tab closure, late cancelled reads cannot recreate state or restore watchers. Cancellation closes the Host watcher even while the generator is suspended at a yielded frame; stream and plugin teardown await closure.
- Background directory refresh retains the displayed listing and marks it `refreshing`; the display ignores the mark, while locating a file waits on it for the directory to be listed again. Only a directory without displayable entries enters `loading`, avoiding subtree flicker and repeated watcher reconstruction.
- After collapse, deletion, the end of a drawing, or the pane's state being forgotten, late cancelled reads cannot recreate state or restore watchers. Cancellation closes the Host watcher even while the generator is suspended at a yielded frame; stream and plugin teardown await closure.
- Temporary file disappearance while its parent remains does not end observation. Ordinary file changes and watcher failures remain distinct; same-path file watches cover atomic saves and delete/recreate.
- Existing file reads are not transaction snapshots: writes can occur between stat and content reading. Later notifications trigger refresh; automatic refresh does not promise an atomic snapshot for every read.
@@ -60,10 +60,11 @@ Sidebar 的 Document Preview 和 Files 面板都需要反映磁盘上的最新
| 包内 `ResourceGroup` | [resource-group.ts](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/resource-group.ts) | 通过现有 `resources.source(address)` 观察成员;`add` 加入资源,`set` 更新完整成员列表,`close` 释放全部成员,不改变通用 Resource 服务 |
| `DocumentBodyOwner`、`HtmlBody`、`createReadHtmlRelative` | [渲染器输入](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts)、[HTML body](../../../../packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.tsx)、[关联文件读取](../../../../packages/client/ui-sidebar-documentpreview/src/client/html/read-relative.ts) | 渲染器通过 `addResource` 声明读取到的成员,通过 `setResources` 提交本次依赖列表;HTML 在每次读取返回后按 Host 报告的路径加入 CSS/JS 资源,解析结束后释放不再引用的成员 |
| 各文档渲染器 | [DocumentContent](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) 及同包各渲染器目录 | 不接 watch;继续消费文本、字节或 renderer revision;`failed()` 结束渲染器加载但不记录成功版本。检查刷新时取消旧任务、释放旧对象及保留现有视图偏好 |
| `filesFace`、`FilesInjected` | [Files face](../../../../packages/client/ui-sidebar-files/src/client/face.ts) | 继续作为已有业务入口,直接持有各 Tab 根节点;绑定目录读取和变化流,转发展开、折叠、刷新及自动刷新开关,不新增 `FilesController` |
| `filesFace`、`FilesInjected` | [Files face](../../../../packages/client/ui-sidebar-files/src/client/face.ts) | 继续作为已有业务入口,在导航栏绘制分栏目录树期间直接持有各分栏的根节点([按分栏保存状态](../architecture/2026-10-20-workspace-tree-state-per-pane.zh.md));绑定目录读取和变化流,转发展开、折叠、刷新及自动刷新开关,不新增 `FilesController` |
| 包内 `DirectoryNode` | [directory-node.ts](../../../../packages/client/ui-sidebar-files/src/client/directory-node.ts) | 表示一个已打开的目录,拥有本目录变化流、读取状态和已打开子节点;负责局部刷新、展开、递归关闭,以及向子节点传递自动刷新状态;`setExpanded` 按最新展开偏好更新待恢复节点 |
| `createFilesStore`、`FilesTabState`、`LevelState` | [Files store](../../../../packages/client/ui-sidebar-files/src/client/store.ts) | 保留展开、滚动和目录显示数据;区分首次加载与已有内容上的刷新,避免每次后台重读都把子树卸载;不保存 watcher 或 AbortController |
| `FilesBody`、`Level`、`Entry` | [FilesBody.tsx](../../../../packages/client/ui-sidebar-files/src/client/FilesBody.tsx) | 将展开、折叠、刷新和面板卸载交给注入回调;按节点展示结果渲染,不扫描整棵树生成监听集合,展示仍只读已有框架钩子与存储 |
| `createFilesStore`、`FilesPaneState`、`FilesTreeState`、`LevelState` | [Files store](../../../../packages/client/ui-sidebar-files/src/client/store.ts) | 保留展开和目录显示数据;区分首次加载与已有内容上的刷新,避免每次后台重读都把子树卸载;不保存 watcher 或 AbortController |
| `FileNavigator` | [FileNavigator.tsx](../../../../packages/client/ui-sidebar-files/src/client/FileNavigator.tsx)、[workspace-tree.ts](../../../../packages/client/ui-sidebar-files/src/client/workspace-tree.ts) | 通过框架钩子读取各分栏状态;将已列出的层级转换为条目与目录状态;将展开、折叠和绘制生命周期交给注入的 Files face,由它负责列出目录、刷新与监听 |
| `FileTree` | [FileTree.tsx](../../../../packages/client/ui-sidebar-files/src/client/FileTree.tsx) | 根据属性绘制条目、目录状态与展开状态,通过 `onToggle` 和 `onOpen` 报告行操作。root 作用域的 `sidebar.files.tree` Factory 通过框架席位读取本地化文案与局部 `accessory` 组件,不持有目录列表或监听 |
| Files 注册入口 | [Files client/index.ts](../../../../packages/client/ui-sidebar-files/src/client/index.ts) | 给现有 `filesFace` 增加目录变化流回调;异步副作用留在注入实现,不引入另一个功能插件的运行时导出 |
## 监听接口与事件内容
@@ -176,13 +177,14 @@ Document Preview 和 Files 各自保留原 reload 按钮。独立自动刷新开
```text
Files.open
→ FilesBody → Session.cwd + Tab
→ filesFace.start → DirectoryNode.open
→ FileNavigator → Session.cwd + pane
→ filesFace.hold → DirectoryNode.open
→ changes(sessionId, path)
→ Host.watch(depth: 0) → ready
→ DirectoryNode → list(sessionId, path)
→ createFilesStore.levels[path]
→ Level / Entry
→ createFilesStore.byPane[paneId].tree.levels[path]
→ FileNavigator → workspace-tree.ts: workspaceEntries / workspaceDirectories
→ renderFactorySlot('sidebar.files.tree', input) → Level / Entry
Entry.expand
→ filesFace.toggle
@@ -204,7 +206,7 @@ Process → create / remove / rename
## 目录节点树与生命周期
Files 的运行时对象本身就是一棵按需打开的目录树,不先建立整棵 Workspace 树,也不另算一个扁平的 watcher 集合。现有 `filesFace` 持有各 Tab 根节点,读取、监听和释放逐层交给 `DirectoryNode`,不另抽控制器类。
Files 的运行时对象本身就是一棵按需打开的目录树,不先建立整棵 Workspace 树,也不另算一个扁平的 watcher 集合。现有 `filesFace` 持有各分栏的根节点,读取、监听和释放逐层交给 `DirectoryNode`,不另抽控制器类。
| 节点职责 | 具体行为 |
|---|---|
@@ -239,14 +241,14 @@ workspace/ watch
| 首次打开 | 打开文件 Resource 监听并读取正文 | 打开根节点,由根节点建立监听并读取列表 |
| 展开子目录 | 无影响 | 父节点打开对应子节点;子节点监听并读取直接子项,按偏好逐层恢复仍存在的后代 |
| 折叠子目录 | 已打开的文件预览继续监听 | 释放该目录及其当前不可见后代的监听;可保留展开偏好和显示缓存 |
| 切走 Tab 或面板卸载 | Tab pin 继续持有文件元数据流;正文组件不在时不主动重读,切回来按待处理 Resource 变化与开关状态补刷新 | 此版沿用 Tab 生命周期,保留已展开目录节点和监听;不另外引入面板显隐生命周期 |
| 切走 Tab 或隐藏目录树 | Tab pin 继续持有文件元数据流;正文组件不在时不主动重读,切回来按待处理 Resource 变化与开关状态补刷新 | 在分栏的文件 tab 之间切换时保留已展开目录节点和监听,因为下一个 tab 在同一次提交中绘制目录树;隐藏目录树或显示不带目录树的 tab 时关闭它们,存储保留展开状态与已显示的层级,下一次绘制时就地重新列出 |
| 手动刷新 | 重用当前预览 reload,不重建整套 Resource | 从根节点递归刷新已打开子节点,不进入折叠目录,也不重建全部 watcher |
| 关闭最后一个持有者 | Resource abort,文件流结束,Host 等待 watcher 关闭 | Tab 关闭时终止全部目录流、取消读取并删除 Tab 状态 |
| 关闭最后一个持有者 | Resource abort,文件流结束,Host 等待 watcher 关闭 | 分栏中没有挂载的导航栏、也没有最近一次在那里绘制导航栏的打开 tab 时,终止全部目录流、取消读取并删除该分栏的状态 |
| 连接恢复 | 新流 ready 后重新 stat,即使断线期间没有可回放事件也能发现变化 | 新流 ready 后重新 list 所有仍需观察的目录 |
Files 监听跟随 Tab 生命周期;折叠关闭对应子树,关闭 Tab 则释放根节点与整棵活动树。暂时隐藏面板不额外拆除和重建节点,这使改动集中在现有入口和目录对象。存储保留展开偏好,节点只在重新展开时用这份偏好逐层恢复,不维护第二份全局展开集合。
Files 监听跟随各分栏已绘制的目录树;折叠关闭对应子树,分栏最后一次绘制结束时释放根节点与整棵活动树,没有打开的 tab 最近一次在该分栏绘制目录树时,还会删除其状态。存储保留展开偏好与已显示的层级,节点只在重新展开时用这份偏好逐层恢复,不维护第二份全局展开集合。
同一个完整文件 Resource 地址的共享继续由 `ResourceRegistry` 完成。目录节点的父子所有权保证一个 Tab 内同一分支不会重复打开;第一版不增加跨 Session、跨窗口或路径别名的全局 watcher 去重系统。目录下的普通文件项不各自订阅文件 watch,只有真正打开为预览 Resource 的文件才取得独立文件监听。
同一个完整文件 Resource 地址的共享继续由 `ResourceRegistry` 完成。目录节点的父子所有权保证一个分栏内同一分支不会重复打开;第一版不增加跨 Session、跨窗口或路径别名的全局 watcher 去重系统。目录下的普通文件项不各自订阅文件 watch,只有真正打开为预览 Resource 的文件才取得独立文件监听。
## 用户可见动线
@@ -281,8 +283,8 @@ Files 监听跟随 Tab 生命周期;折叠关闭对应子树,关闭 Tab 则
- 事件表示“目标可能变化”,不是一次用户操作的精确回放。Chokidar 的重复通知和连续写入可以合并;文件按版本去重,目录按待刷新状态合并。
- 同一目录最多有一次有效 list 在途;在途或读取收尾期间再次失效,只记录需要补读,随后读取最新状态,不累计任意长度的刷新任务队列。
- 文件自动刷新复用读取代次和 renderer revision。连续通知不能让旧请求覆盖新内容,也不能因同一已处理版本而无限 reload。
- 目录后台刷新保留当前列表,不另存没有显示用途的 `refreshing` 标记;只有没有可显示列表时才进入 `loading`,避免子树闪烁和监听反复重建。
- 目录折叠、删除或 Tab 关闭后,已取消读取的晚到结果不得重新创建状态或恢复监听。即使生成器停在已产出的帧上,取消也会关闭 Host watcher;流与插件拆除等待关闭完成。
- 目录后台刷新保留当前列表并标记 `refreshing`;显示不受该标记影响,定位文件据此等待目录重新列出;只有没有可显示列表时才进入 `loading`,避免子树闪烁和监听反复重建。
- 目录折叠、删除、绘制结束或分栏状态被删除后,已取消读取的晚到结果不得重新创建状态或恢复监听。即使生成器停在已产出的帧上,取消也会关闭 Host watcher;流与插件拆除等待关闭完成。
- 父目录保持存在时,文件短暂消失不结束监听;正常文件变化与监听自身失败分别处理。同路径文件监听覆盖原子保存与删除重建。
- 原有文件读取并非事务快照,stat 与正文读取之间仍可能发生写入;后续通知触发刷新,自动刷新不保证每次读取都获得原子快照。
@@ -29,6 +29,9 @@
/agent-note-frontend-large-session-performance-budgets/calibration/history-selector-invalidation:
en: 2d2f78f4cf6aad8a
zh: aec6af5f7c66de16
/agent-note-frontend-large-session-performance-budgets/calibration/streaming-assistant-visibility:
en: 5dc3c84b80be0e8e
zh: 4bbd16e1f455ef85
/agent-note-frontend-large-session-performance-budgets/alternatives-considered:
en: 4d2a4a64ccd14507
zh: 8a4aaf4d4abea13d
@@ -102,6 +102,23 @@ These local runs both failed the independent open budget: their open medians wer
Post-GC browser heap was 80.70/80.71 MiB. GNU time reported maximum RSS of 535.78/561.80 MiB for each benchmark command and its children; this is not input-phase memory or an aggregate browser-memory measurement. Moving the composer's selection reveal into an animation frame was rejected: profiling showed the style flush moved to another geometry read, without improving typing. The product fix removes the invalidation source instead.
### Streaming Assistant visibility
Chat retains visibility per Assistant block. Same-kind text and reasoning appends inspect only the incoming delta until the block becomes visible, then reuse that result. This avoids repeated `trim()` calls on accumulated strings without changing whitespace, first-token timing, or visible anchors. Replacements recalculate visibility, retry resets it, and final messages rebuild it. Copying the block array remains proportional to its length; this does not make every Assembler operation constant-time.
The [conversation-fold worker](../../../../benchmarks/conversation-fold/conversation-fold.worker.client.ts) measures a single 256 KiB block split into 4,096 or 16,384 deltas, using ordinary text or reasoning whose first half is whitespace. Five fresh compiled Node children per case reuse the existing benchmark registrations, production Assembler and Chat builder. Timing includes every append, one final flush, and reading every output character. Fixture preparation and GC are excluded; retained heap is the post-GC increment with the Assembler, live entries and published state still reachable.
Local macOS arm64 measurements on Node 26.8.2 give these elapsed medians. The time limits use reference allowances of 12/32 ms and the existing shared scale and headroom; they are not new hosted-runner calibration.
| Workload | Deltas | Repeated accumulated trim | Cached visibility | Time limit |
|---|---:|---:|---:|---:|
| Ordinary text | 4,096 | 42.25 ms | 7.32 ms | 30 ms |
| Half-leading-whitespace reasoning | 4,096 | 252.75 ms | 7.96 ms | 30 ms |
| Ordinary text | 16,384 | 206.71 ms | 23.06 ms | 80 ms |
| Half-leading-whitespace reasoning | 16,384 | 1114.18 ms | 23.08 ms | 80 ms |
All four cases reject accumulated trimming and pass with cached visibility. Retained heap remains approximately 2.8/9.9 MiB for the two fragment counts, below the 20 MiB limit derived from a 16 MiB allowance. Maximum worker RSS across these samples falls from 185.70 to 89.36 MiB; it is a whole-process diagnostic, not aggregate application memory. This endpoint measures local assembly with one final publication, excluding network, browser rendering and Trajectory. Separate browser regressions cover settlement, cancellation, retry, reload, reopening and blank reasoning without asserting a browser speedup.
## Alternatives considered
**Use the Node fold as paint evidence.** Rejected because it never performs DOM mutation, layout, or browser scheduling. The focused reconnect case likewise makes no GUI speed claim.
@@ -102,6 +102,23 @@ Trajectory 激活在动画帧上采样可见搜索框与 `[data-trajectory-scrol
强制 GC 后的浏览器 heap 为 80.70/80.71 MiB。GNU time 为两次基准命令及其子进程报告的最大 RSS 为 535.78/561.80 MiB;这不是输入阶段内存,也不是浏览器总内存测量。曾尝试将输入框的选区显示推迟到动画帧,但分析显示样式刷新转移到另一处几何读取,没有改善键入,因此未采用。产品修复直接移除样式失效的来源。
### 流式 Assistant 可见性
Chat 保存每个 Assistant 块的可见状态。同类型文本和推理追加只检查新增片段,直到该块可见,之后复用结果。这避免对累计字符串重复调用 `trim()`,同时保留空白、首 token 时间及可见锚点。块替换重新计算可见性,重试清空状态,最终消息重建状态。块数组复制仍与其长度成正比,因此并非每次 Assembler 操作都是常数时间。
[conversation-fold worker](../../../../benchmarks/conversation-fold/conversation-fold.worker.client.ts) 测量单个 256 KiB 块,拆成 4,096 或 16,384 个片段,内容为普通文本或前半段为空白的推理。每个用例使用五个全新的已编译 Node 子进程,复用现有基准注册、生产 Assembler 和 Chat builder。计时包含每次追加、一次最终 flush 及逐字符读取输出。夹具准备和 GC 不计时;保留堆内存为 GC 后的增量,此时 Assembler、实时条目和已发布状态仍可达。
macOS arm64、Node 26.8.2 上的本地测量得到以下耗时中位数。时间上限使用 12/32 ms 参考额度及现有共享缩放与余量,不代表新的托管运行器校准。
| 工作负载 | 片段数 | 累计文本重复 trim | 缓存可见性 | 时间上限 |
|---|---:|---:|---:|---:|
| 普通文本 | 4,096 | 42.25 ms | 7.32 ms | 30 ms |
| 前半段为空白的推理 | 4,096 | 252.75 ms | 7.96 ms | 30 ms |
| 普通文本 | 16,384 | 206.71 ms | 23.06 ms | 80 ms |
| 前半段为空白的推理 | 16,384 | 1114.18 ms | 23.08 ms | 80 ms |
四个用例都拒绝累计文本重复 trim,并在缓存可见性后通过。两个片段规模的保留堆内存仍约为 2.8/9.9 MiB,低于 16 MiB 额度经余量计算后的 20 MiB 上限。这些样本中的 worker 最大 RSS 从 185.70 降至 89.36 MiB;它是整个进程的诊断值,不是应用总内存。该终点只测量一次最终发布的本地组装,不包含网络、浏览器渲染和 Trajectory。独立浏览器回归覆盖结算、取消、重试、刷新、重开及空白推理,不据此声称浏览器提速。
## 考虑过的替代方案
**用 Node 折叠作为绘制证据。** 拒绝,因为它不执行 DOM 修改、布局或浏览器调度。聚焦重连用例同样不声称 GUI 提速。
+5 -3
View File
@@ -55,7 +55,7 @@ it('replays highlighted Python stubs, PowerShell and CSV reads with their docume
await expandTurnProcesses(page)
await page.locator('[data-sidebar-right-expand]').click()
await page.locator('[data-sidebar-right-guide-entry="files"]').click()
const filesTab = page.locator('[data-dockkit-tab]').filter({ has: page.getByText('Files', { exact: true }) })
const toggle = page.locator('[data-files-navigator-toggle]:visible')
const preview = page.locator('[data-textpreview-url]')
const snapshots: string[] = []
for (const file of FILES) {
@@ -66,8 +66,10 @@ it('replays highlighted Python stubs, PowerShell and CSV reads with their docume
await expect.poll(() => read.locator('[data-read] span[style*="color:"]').count()).toBeGreaterThan(0)
expect(await read.getByText(file.lang, { exact: true }).count()).toBe(1)
snapshots.push(`## Read ${file.name}\n\n${await captureStableAria(page, readSelector, scaffold.workspaceCwd)}`)
await filesTab.click()
await page.locator('[data-files-entry="file"]').getByRole('button', { name: file.name, exact: true }).click()
// The first file replaces the files page; later ones open from the pane's navigator.
// The files page always shows its tree and draws no toggle.
if (await toggle.count() > 0 && await toggle.getAttribute('aria-expanded') !== 'true') await toggle.click()
await page.locator('[data-files-navigator]:visible [data-files-entry="file"]').getByRole('button', { name: file.name, exact: true }).click()
const viewer = preview.locator('[data-document-viewer-menu]')
if (file.name === 'table.csv') {
await expect.poll(() => viewer.innerText()).toBe('Spreadsheet')
+20
View File
@@ -268,6 +268,26 @@ describe.each(['MacIntel', 'Win32', 'Linux x86_64'])('web context menus on %s',
await file.click({ button: 'right' })
await page.getByRole('menuitem', { name: 'Open in sidebar', exact: true }).click()
await page.locator('[data-document-preview]').getByText('SECOND', { exact: true }).waitFor()
// The same link moves that tab into a floating panel and back, delivering its line each time.
const floating = page.locator('[data-sidebar-right-session]:not([hidden]) [data-dockkit-float]')
const dockedFirst = page.locator('[data-rightbar-col] [data-dockkit-pane] [data-dockkit-tab]', { hasText: 'first.txt' })
await file.click({ button: 'right' })
await page.getByRole('menuitem', { name: 'Open in floating panel', exact: true }).click()
await expect.poll(() => floating.count()).toBe(1)
await floating.locator('[data-textpreview-target="2"]', { hasText: 'SECOND' }).waitFor()
await expect.poll(() => dockedFirst.count()).toBe(0)
// The new panel covers the link, so the keyboard raises its menu.
await file.focus()
await page.keyboard.press('Shift+F10')
await page.getByRole('menuitem', { name: 'Open in floating panel', exact: true }).click()
await floating.locator('[data-textpreview-target="2"]').waitFor()
expect(await floating.count()).toBe(1)
await file.focus()
await page.keyboard.press('Shift+F10')
await page.getByRole('menuitem', { name: 'Open in sidebar', exact: true }).click()
await expect.poll(() => floating.count()).toBe(0)
await expect.poll(() => dockedFirst.count()).toBe(1)
await page.locator('[data-rightbar-col] [data-textpreview-target="2"]').waitFor()
await page.getByRole('link', { name: 'Website', exact: true }).click({ button: 'right' })
await page.getByRole('menuitem', { name: 'Copy link address', exact: true }).click()
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe('https://example.com/path?q=1#fragment')
@@ -113,7 +113,7 @@ async function sidebarSnapshot(page: Page) {
coversViewport: expanded && rect.x === 0 && rect.y === 0
&& Math.round(rect.width) === window.innerWidth && Math.round(rect.height) === window.innerHeight,
resizeHandleWidth: handle === null ? 0 : Math.round(handle.getBoundingClientRect().width),
expandedDirectories: [...panel.querySelectorAll('[data-files-entry="directory"] > button[aria-expanded="true"]')]
expandedDirectories: [...panel.querySelectorAll('[data-files-entry="directory"] button[aria-expanded="true"]')]
.map(button => button.textContent?.trim()),
}
})
@@ -196,6 +196,14 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
.toBe('none')
await expect.poll(() => blankColumn.locator('[data-sidebar-right-panel]').boundingBox())
.toEqual({ x: 0, y: 0, width: 767, height: blankViewport.height })
// A preview the files page opened starts with its tree hidden; shown, the tree fills the viewport.
const treeToggle = blankColumn.locator('[data-files-navigator-toggle]')
await expect.poll(() => treeToggle.getAttribute('aria-expanded')).toBe('false')
await treeToggle.click()
await blankColumn.locator('[data-files-navigator]').waitFor({ state: 'visible' })
expect(await blankColumn.locator('[data-textpreview-body]').evaluate(node => getComputedStyle(node).visibility)).toBe('hidden')
await treeToggle.click()
await expect.poll(() => treeToggle.getAttribute('aria-expanded')).toBe('false')
await blankColumn.getByText('Workspace preview is available.', { exact: true }).waitFor()
await blankColumn.locator('[data-sidebar-right-toggle]').click()
await blankColumn.locator('[data-dockkit-pane]').first().waitFor({ state: 'hidden' })
@@ -369,7 +377,8 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
expect(await panel.getAttribute('data-sidebar-right-panel')).toBe('push')
await column.locator('[data-sidebar-right-guide-entry="files"]').click()
await column.locator('[data-files-state="tree"]').waitFor({ timeout: 15_000 })
const workspaceDirectory = column.locator('[data-files-entry="directory"] > button').filter({ hasText: /^workspace$/ })
const workspaceDirectory = column.locator('[data-files-entry="directory"] button')
.and(column.getByRole('button', { name: 'workspace', exact: true }))
await workspaceDirectory.waitFor({ timeout: 15_000 })
await workspaceDirectory.click()
await expect.poll(() => workspaceDirectory.getAttribute('aria-expanded')).toBe('true')
+56
View File
@@ -1563,6 +1563,62 @@ else process.exit(1);
await compareOrRefreshGolden(EXPECTED, sections.join('\n\n'), MODE)
await assertFixtureInventory(SNAPSHOT_DIR, ['document.expected.md', 'applications.expected.md', 'applications-no-default.expected.md', 'paging.patch.yml'])
})
it('preserves Markdown reading position beyond the first page after automatic and manual refresh', async () => {
onTestFailed(() => saveFailureShot(page, `screenshots/0908-document-preview/refresh-scroll-${process.pid}`))
if (await page.getByText('LIGHTHOUSE', { exact: true }).count() === 0) {
const settled = scaffold.whenTurnSettled()
const input = page.locator('[data-composer-input]').first()
await input.fill(PROMPT)
await input.press('Enter')
await settled
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor()
}
const cwd = scaffold.ctx.agents.list()[0]?.session.header.cwd
if (cwd === undefined) throw new Error('Session has no workspace cwd')
const path = join(cwd, 'refresh-scroll.md')
const markdown = '# Before refresh\n\n' + Array.from({ length: PAGE_LINES * 2 }, (_, index) => `Paragraph ${index + 1}\n`).join('\n')
await writeFile(path, markdown)
const column = page.locator('[data-rightbar-col]')
const expand = page.locator('[data-sidebar-right-expand]')
if (await expand.isVisible()) await expand.click()
const filesTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('Files', { exact: true }) })
if (await filesTab.count() === 0) await column.locator('[data-sidebar-right-guide-entry="files"]').click()
await filesTab.click()
await column.locator('[data-files-reload]').click()
const preview = column.locator('[data-textpreview-url]')
await openPreviewFile(column, filesTab, preview, 'refresh-scroll.md')
const body = preview.locator('[data-textpreview-body]')
const heading = body.getByRole('heading', { level: 1 })
await expect.poll(() => heading.textContent()).toBe('Before refresh')
await expect.poll(() => preview.locator('[data-textpreview-more]').isEnabled()).toBe(true)
const target = await body.evaluate(node => node.scrollHeight + 100)
await scrollForNextPage(body)
await expect.poll(() => body.evaluate(node => node.scrollHeight - node.clientHeight)).toBeGreaterThan(target)
await body.evaluate((node, top) => new Promise<void>((resolve) => {
node.addEventListener('scroll', () => { resolve() }, { once: true })
node.scrollTop = top
}), target)
const original = await body.evaluate(node => node.scrollTop)
expect(original).toBe(target)
// Content growing above the viewport keeps the pixel offset instead of following the content.
await heading.evaluate((node) => { node.style.paddingTop = '300px' })
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))
expect(await body.evaluate(node => node.scrollTop)).toBe(original)
await heading.evaluate((node) => { node.style.paddingTop = '' })
await writeFile(path, markdown.replace('Before refresh', 'After refresh'))
await expect.poll(() => heading.textContent()).toBe('After refresh')
await expect.poll(() => body.evaluate((node, top) => Math.abs(node.scrollTop - top), original)).toBeLessThanOrEqual(1)
const rendered = await heading.elementHandle()
await preview.locator('[data-textpreview-tool="reload"]').click()
await expect.poll(() => rendered!.evaluate(node => node.isConnected)).toBe(false)
await expect.poll(() => heading.textContent()).toBe('After refresh')
await expect.poll(() => body.evaluate((node, top) => Math.abs(node.scrollTop - top), original)).toBeLessThanOrEqual(1)
await rendered!.dispose()
expect(tripwire.pageErrors).toEqual([])
})
})
describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => {
+5 -3
View File
@@ -54,7 +54,7 @@ describe.skipIf(mode === 'record')('web e2e: independent-writer Excel previews',
await column.locator('[data-sidebar-right-guide-entry="files"]').click()
await column.locator('[data-files-state="tree"]').waitFor()
await column.locator('[data-files-reload]').click()
const files = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('Files', { exact: true }) })
const toggle = column.locator('[data-files-navigator-toggle]:visible')
const preview = column.locator('[data-textpreview-url]')
const excel = preview.locator('[data-excel-preview]')
const sheets = excel.locator('.luckysheet-sheets-item-name')
@@ -62,8 +62,10 @@ describe.skipIf(mode === 'record')('web e2e: independent-writer Excel previews',
const rows = ['# Independent-writer Excel previews', '']
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin })
for (const name of [...excelOpcCases, ...excelOpeningCases, ...excelXmlCases]) {
await files.click()
await column.locator('[data-files-entry="file"]').getByRole('button', { name: `${name}.xlsx`, exact: true }).click()
// The first file replaces the files page; later ones open from the pane's navigator.
// The files page always shows its tree and draws no toggle.
if (await toggle.count() > 0 && await toggle.getAttribute('aria-expanded') !== 'true') await toggle.click()
await column.locator('[data-files-navigator]:visible [data-files-entry="file"]').getByRole('button', { name: `${name}.xlsx`, exact: true }).click()
await expect.poll(async () => (await preview.getAttribute('data-textpreview-url'))?.endsWith(`/${name}.xlsx`)).toBe(true)
await sheets.getByText('数据', { exact: true }).waitFor()
await excel.locator('.fortune-sheet-overlay').click({ position: { x: 70, y: 30 } })
@@ -29,20 +29,20 @@
- textbox "模型 ID 1":
- /placeholder: 模型 ID
- text: deepseek-flash
- button "模型选项 1"
- button "删除模型 1"
- textbox "显示名称 1":
- /placeholder: 显示名称
- text: DeepSeek-V4.1-Flash
- button "模型选项 1"
- button "删除模型 1"
- button "调整模型顺序 2"
- textbox "模型 ID 2":
- /placeholder: 模型 ID
- text: deepseek-v4-pro
- button "模型选项 2"
- button "删除模型 2"
- textbox "显示名称 2":
- /placeholder: 显示名称
- text: DeepSeek-V4-Pro
- button "模型选项 2"
- button "删除模型 2"
- button "添加模型"
- button "取消"
- button "保存"
@@ -36,11 +36,11 @@
- textbox "模型 ID 1":
- /placeholder: 模型 ID
- text: gpt-6-astra
- button "模型选项 1" [expanded]
- button "删除模型 1"
- textbox "显示名称 1":
- /placeholder: 显示名称
- text: GPT-6 Astra
- button "模型选项 1" [expanded]
- button "删除模型 1"
- text: 上下文窗口
- textbox "上下文窗口 1":
- /placeholder: 1M
@@ -44,10 +44,10 @@
- textbox "模型 ID 1":
- /placeholder: 模型 ID
- text: acme-large
- textbox "显示名称 1":
- /placeholder: 显示名称
- button "模型选项 1" [expanded]
- button "删除模型 1"
- textbox "显示名称 1":
- /placeholder: 显示名称
- text: 上下文窗口
- textbox "上下文窗口 1":
- /placeholder: 1M
@@ -44,10 +44,10 @@
- textbox "模型 ID 1":
- /placeholder: 模型 ID
- text: acme-large
- textbox "显示名称 1":
- /placeholder: 显示名称
- button "模型选项 1" [expanded]
- button "删除模型 1"
- textbox "显示名称 1":
- /placeholder: 显示名称
- text: 上下文窗口
- textbox "上下文窗口 1":
- /placeholder: 1M
@@ -0,0 +1,64 @@
- dialog "设置":
- text: 设置
- navigation:
- button "通用设置"
- button "模型"
- button "内置插件"
- button "Agent 预设"
- button "打开配置文件"
- button "关闭"
- heading "模型" [level=2]
- paragraph: 填入各提供商的 API 密钥即可使用其模型。
- list:
- listitem:
- text: minimax-cn
- img "API 密钥已配置"
- button "编辑 minimax-cn": 编辑
- button "删除 minimax-cn": 删除
- listitem:
- text: Acme 网关 自定义
- button "编辑 Acme 网关 (acme-gateway)": 编辑
- button "删除 Acme 网关 (acme-gateway)": 删除
- text: Acme 网关 acme-gateway API 密钥
- textbox "API 密钥":
- /placeholder: 输入 API 密钥
- group:
- text: 自定义设置 显示名称
- textbox "显示名称":
- /placeholder: acme-gateway
- text: Acme 网关
- text: API 地址
- textbox "API 地址":
- /placeholder: https://gateway.acme.example/v1
- text: https://gateway.acme.example/v1
- text: API 协议
- combobox "API 协议":
- option "OpenAI Chat Completions"
- option "OpenAI Responses"
- option "Anthropic Messages" [selected]
- region "模型目录":
- text: 模型目录 已自定义模型目录
- button "恢复默认模型"
- button "获取可用模型"
- button "调整模型顺序 1"
- textbox "模型 ID 1":
- /placeholder: 模型 ID
- text: order-second
- button "模型选项 1"
- button "删除模型 1"
- button "调整模型顺序 2"
- textbox "模型 ID 2":
- /placeholder: 模型 ID
- text: order-third
- button "模型选项 2"
- button "删除模型 2"
- button "调整模型顺序 3"
- textbox "模型 ID 3":
- /placeholder: 模型 ID
- text: acme-large
- button "模型选项 3"
- button "删除模型 3"
- button "添加模型"
- button "取消"
- button "保存"
- button "添加模型提供商"
@@ -0,0 +1,84 @@
- dialog "设置":
- text: 设置
- navigation:
- button "通用设置"
- button "模型"
- button "内置插件"
- button "Agent 预设"
- button "打开配置文件"
- button "关闭"
- heading "模型" [level=2]
- paragraph: 填入各提供商的 API 密钥即可使用其模型。
- list:
- listitem:
- text: minimax-cn
- img "API 密钥已配置"
- button "编辑 minimax-cn": 编辑
- button "删除 minimax-cn": 删除
- listitem:
- text: Acme 网关 自定义
- button "编辑 Acme 网关 (acme-gateway)": 编辑
- button "删除 Acme 网关 (acme-gateway)": 删除
- text: Acme 网关 acme-gateway API 密钥
- textbox "API 密钥":
- /placeholder: 输入 API 密钥
- group:
- text: 自定义设置 显示名称
- textbox "显示名称":
- /placeholder: acme-gateway
- text: Acme 网关
- text: API 地址
- textbox "API 地址":
- /placeholder: https://gateway.acme.example/v1
- text: https://gateway.acme.example/v1
- text: API 协议
- combobox "API 协议":
- option "OpenAI Chat Completions"
- option "OpenAI Responses"
- option "Anthropic Messages" [selected]
- region "模型目录":
- text: 模型目录 已自定义模型目录
- button "恢复默认模型"
- button "获取可用模型"
- button "调整模型顺序 1"
- textbox "模型 ID 1":
- /placeholder: 模型 ID
- text: order-second
- button "模型选项 1" [expanded]
- button "删除模型 1"
- text: 显示名称
- textbox "显示名称 1":
- /placeholder: 显示名称
- text: 手机上的完整显示名称
- text: 上下文窗口
- textbox "上下文窗口 1":
- /placeholder: 1M
- text: 最大输出 token 数
- textbox "最大输出 token 数 1":
- /placeholder: 128K
- group "输入类型 1":
- text: 输入类型
- checkbox "文本" [checked] [disabled]
- text: 文本
- checkbox "图片"
- text: 图片
- group "推理等级 1":
- text: 推理等级
- button "推理等级说明": "?"
- switch "推理等级 1"
- button "调整模型顺序 2"
- textbox "模型 ID 2":
- /placeholder: 模型 ID
- text: order-third
- button "模型选项 2"
- button "删除模型 2"
- button "调整模型顺序 3"
- textbox "模型 ID 3":
- /placeholder: 模型 ID
- text: acme-large
- button "模型选项 3"
- button "删除模型 3"
- button "添加模型"
- button "取消"
- button "保存"
- button "添加模型提供商"
@@ -29,11 +29,11 @@
- textbox "模型 ID 1":
- /placeholder: 模型 ID
- text: deepseek-flash
- button "模型选项 1" [expanded]
- button "删除模型 1"
- textbox "显示名称 1":
- /placeholder: 显示名称
- text: DeepSeek-V4.1-Flash
- button "模型选项 1" [expanded]
- button "删除模型 1"
- text: 上下文窗口
- textbox "上下文窗口 1":
- /placeholder: 1M
@@ -51,11 +51,11 @@
- textbox "模型 ID 2":
- /placeholder: 模型 ID
- text: deepseek-v4-pro
- button "模型选项 2"
- button "删除模型 2"
- textbox "显示名称 2":
- /placeholder: 显示名称
- text: DeepSeek-V4-Pro
- button "模型选项 2"
- button "删除模型 2"
- button "添加模型"
- button "取消"
- button "保存"
@@ -30,11 +30,11 @@
- textbox "模型 ID 1":
- /placeholder: 模型 ID
- text: private-preview
- button "模型选项 1" [expanded]
- button "删除模型 1"
- textbox "显示名称 1":
- /placeholder: 显示名称
- text: Private Preview
- button "模型选项 1" [expanded]
- button "删除模型 1"
- text: 上下文窗口
- textbox "上下文窗口 1":
- /placeholder: 1M
@@ -0,0 +1,8 @@
# Discover toolbar responsive layout
- 390px: wrapped rows; index, search, labels, sort, and refresh are contained, reachable, and separate; no horizontal overflow; search focus retained.
- 430px: wrapped rows; index, search, labels, sort, and refresh are contained, reachable, and separate; no horizontal overflow; search focus retained.
- 768px: single row; index, search, labels, sort, and refresh are contained, reachable, and separate; no horizontal overflow; search focus retained.
- 1280px: single row; index, search, labels, sort, and refresh are contained, reachable, and separate; no horizontal overflow; search focus retained.
At 390px, restoring the previous no-wrap layout causes horizontal overflow and fails the same layout check. Removing that override restores the controls and preserves search focus.
@@ -18,6 +18,7 @@
- button "Split" [disabled]
- text: {{cwd}}/workspace
- button "Reload"
- list:
- listitem:
- button "t6-refresh.txt"
- complementary "Workspace files":
- list:
- listitem:
- button "t6-refresh.txt"
@@ -21,5 +21,6 @@
- button "Split" [disabled]
- text: {{cwd}}/workspace
- button "Reload"
- list:
- listitem: Empty directory
- complementary "Workspace files":
- list:
- listitem: Empty directory
@@ -2,5 +2,6 @@
- button "Reload"
- button "Open in Finder"
- button "More ways to open"
- list:
- listitem: Empty directory
- complementary "Workspace files":
- list:
- listitem: Empty directory
@@ -0,0 +1,63 @@
/**
* The workspace file browser and the document preview each work without the
* other in the shipped Web composition: without the preview the files page
* lists the workspace and leaves files it cannot open unclickable; without the
* file browser a preview opens with no navigator or toggle beside it.
*/
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright'
import { expect, it, onTestFailed, onTestFinished } from 'vitest'
import { launchWebScaffold, seedSession, watchConsole, type WebScaffold } from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
/** A settled Session whose answer links `src/example.txt`, borrowed from the Markdown file links case. */
const SEED = fileURLToPath(new URL('../../../snapshots/web/markdown-file-links/session.v3.jsonl', import.meta.url))
/** Boot the composition with one plugin disabled, a workspace holding `src/example.txt`, and the seeded Session open. */
async function open(overlay: string, name: string) {
const scaffold: WebScaffold = await launchWebScaffold({ extraOverlayPath: fileURLToPath(new URL(overlay, import.meta.url)) })
onTestFinished(() => scaffold.close())
await mkdir(join(scaffold.workspaceCwd, 'src'))
await writeFile(join(scaffold.workspaceCwd, 'src/example.txt'), Array.from({ length: 40 }, (_, i) => `source line ${i + 1}\n`).join(''))
await seedSession(scaffold, await readFile(SEED, 'utf8'), name)
const browser = await chromium.launch()
onTestFinished(() => browser.close())
const page = await newEnglishPage(browser)
onTestFailed(() => saveFailureShot(page, `web-e2e-${name}`))
const errors = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.locator('[role="treeitem"]').first().click()
await page.locator('[role="treeitem"]').nth(1).click()
await page.getByRole('button', { name: 'src/example.txt:24–30', exact: true }).waitFor()
return { page, errors, column: page.locator('[data-rightbar-col]') }
}
it('lists the workspace without the document preview, leaving files nothing can open unclickable', async () => {
const { page, errors, column } = await open('./no-sidebar-documentpreview.overlay.yml', 'composition-no-preview')
await page.locator('[data-sidebar-right-expand]').click()
await column.locator('[data-sidebar-right-guide-entry="files"]').click()
const navigator = column.locator('[data-files-navigator]')
await navigator.getByRole('button', { name: 'src', exact: true }).click()
const row = navigator.locator('[data-files-path$="/src/example.txt"]')
await row.waitFor()
expect(await row.locator('button').count()).toBe(0)
expect(await row.locator('[aria-disabled="true"]').getAttribute('title')).toBe('No viewer can open this file.')
const tabs = await column.locator('[data-dockkit-tab]').count()
await row.click()
expect(await column.locator('[data-dockkit-tab]').count()).toBe(tabs)
expect(await column.locator('[data-files-page]').count()).toBe(1)
expect(errors).toEqual({ warnings: [], pageErrors: [] })
}, 120_000)
it('previews a linked file without the file browser, with no navigator or toggle beside it', async () => {
const { page, errors, column } = await open('./no-sidebar-files.overlay.yml', 'composition-no-files')
await page.getByRole('button', { name: 'src/example.txt:24–30', exact: true }).click()
await expect.poll(() => column.locator('[data-textpreview-target="24"]').textContent()).toBe('source line 24\n')
expect(await column.locator('[data-files-navigator], [data-files-navigator-toggle]').count()).toBe(0)
expect(await column.locator('[data-textpreview-content] > *').evaluateAll(children =>
children.map(child => child.hasAttribute('data-textpreview-body')))).toEqual([true])
expect(await column.locator('[data-sidebar-right-guide-entry="files"]').count()).toBe(0)
expect(errors).toEqual({ warnings: [], pageErrors: [] })
}, 120_000)
+539
View File
@@ -0,0 +1,539 @@
/**
* The workspace file browser over the shipped Web composition, a real
* workspace and the real dock, across docked and floating panes and a
* phone-width viewport. The package README owns the behavior asserted here.
*/
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium, type Locator, type Page } from 'playwright'
import { expect, it, onTestFailed, onTestFinished } from 'vitest'
import { createLaunchEnvironmentSnapshot, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
import { launchWebScaffold, watchConsole } from './scaffold.ts'
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
/** Press at the centre of `from`, travel in steps, and release at `to`. */
async function drag(page: Page, from: Locator, to: { x: number; y: number }): Promise<void> {
const box = await from.boundingBox()
if (box === null) throw new Error('drag source is not rendered')
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
await page.mouse.down()
await page.mouse.move(to.x, to.y, { steps: 12 })
await page.mouse.up()
}
/** Resize through the hit-tested corner, rather than dispatching events through covering content. */
async function resizeFloat(page: Page, panel: Locator, width: number, height: number): Promise<void> {
const before = await panel.boundingBox()
const handle = panel.locator('[data-dockkit-float-resize]')
const grip = await handle.boundingBox()
if (before === null || grip === null) throw new Error('floating resize geometry is not rendered')
await expect.poll(() => handle.evaluate((node) => {
const box = node.getBoundingClientRect()
return node.contains(document.elementFromPoint(box.x + box.width / 2, box.y + box.height / 2))
})).toBe(true)
await drag(page, handle, {
x: grip.x + grip.width / 2 + width - before.width,
y: grip.y + grip.height / 2 + height - before.height,
})
await expect.poll(async () => {
const after = await panel.boundingBox()
return after === null ? Infinity : Math.max(Math.abs(after.width - width), Math.abs(after.height - height))
}).toBeLessThanOrEqual(1)
}
/** Boot the shipped Web composition into a settled Session whose workspace holds `src/{a,b,c}.txt`, `README.md`, and `focus.html`. */
async function openWorkspace(
shot: string,
openInAppEnvironment?: LaunchEnvironmentSnapshot,
): Promise<{ page: Page; input: Locator; errors: ReturnType<typeof watchConsole> }> {
const scaffold = await launchWebScaffold({
replayFixture: fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v3.jsonl', import.meta.url)),
paceMs: 5, compareReplaySession: false, ...openInAppEnvironment === undefined ? {} : { openInAppEnvironment },
})
onTestFinished(() => scaffold.close())
const browser = await chromium.launch()
onTestFinished(() => browser.close())
const page = await browser.newPage({ locale: 'en-US', viewport: { width: 1680, height: 1000 } })
await page.addInitScript(() => { Object.defineProperty(navigator, 'platform', { value: 'Win32' }) })
onTestFailed(() => saveFailureShot(page, shot))
const errors = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await connectFreshWorkspace(page, scaffold.workspaceCwd, 'test workspace')
const settled = scaffold.whenTurnSettled()
const input = page.locator('[data-composer-input]').first()
await input.fill('Reply with the single word LIGHTHOUSE and stop.')
await input.press('Enter')
const session = await settled
const cwd = scaffold.ctx.agents.get(session)?.session.header.cwd
if (cwd === undefined) throw new Error('Session has no workspace')
await mkdir(join(cwd, 'src'))
const lines = (name: string): string => Array.from({ length: 300 }, (_, index) => `${name} line ${index}\n`).join('')
await writeFile(join(cwd, 'src', 'a.txt'), lines('a'))
await writeFile(join(cwd, 'src', 'b.txt'), lines('b'))
await writeFile(join(cwd, 'src', 'c.txt'), lines('c'))
await writeFile(join(cwd, 'README.md'), '# Readme\n')
await writeFile(join(cwd, 'focus.html'), '<!doctype html><button>Preview focus</button>')
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor()
return { page, input, errors }
}
it('browses workspace files in tabs that share one pane tree', async () => {
const { page, input, errors } = await openWorkspace('web-e2e-file-browser')
await page.locator('[data-sidebar-right-expand]').click()
const column = page.locator('[data-rightbar-col]')
const layout = await page.addStyleTag({ content: '[data-sidebar-right-panel] { width: 960px !important; }' })
await column.locator('[data-sidebar-right-guide-entry="files"]').click()
const tabs = column.locator('[data-dockkit-tab]')
const navigator = column.locator('[data-files-navigator]:visible')
const toggle = column.locator('[data-files-navigator-toggle]:visible')
const body = column.locator('[data-textpreview-state]:visible [data-textpreview-body]')
const path = column.locator('[data-textpreview-path]:visible')
const current = navigator.locator('[aria-current="true"]')
await column.locator('[data-files-page]').waitFor()
// The page's tree fills the page and stays shown: no toggle, no resize edge, and Escape leaves it.
await navigator.waitFor()
expect(await toggle.count()).toBe(0)
expect(await navigator.getByRole('separator').count()).toBe(0)
const pageContent = await column.locator('[data-files-content]').boundingBox()
const pageTree = await navigator.boundingBox()
if (pageContent === null || pageTree === null) throw new Error('files page geometry is not rendered')
expect(Math.abs(pageTree.width - pageContent.width)).toBeLessThanOrEqual(1)
await navigator.getByRole('button', { name: 'src', exact: true }).focus()
await page.keyboard.press('Escape')
expect(await navigator.count()).toBe(1)
const pageTab = await tabs.last().getAttribute('data-dockkit-tab')
const tabCount = await tabs.count()
// The page stays and opens the first file in a new tab after it in its pane, where the file's tree starts hidden.
await navigator.getByRole('button', { name: 'src', exact: true }).click()
await navigator.getByRole('button', { name: 'a.txt', exact: true }).click()
await expect.poll(() => path.textContent()).toContain('a.txt')
expect(await tabs.count()).toBe(tabCount + 1)
expect(await column.locator(`[data-dockkit-tab="${pageTab}"]`).count()).toBe(1)
expect(await column.locator('[data-dockkit-pane]').count()).toBe(1)
expect(await toggle.getAttribute('aria-expanded')).toBe('false')
// Picking the open file again from the page switches back to its tab, which is the visible change, so nothing glows.
const flash = column.locator('[data-sidebar-right-flash]')
await column.locator(`[data-dockkit-tab="${pageTab}"]`).click()
await navigator.getByRole('button', { name: 'a.txt', exact: true }).click()
await expect.poll(() => path.textContent()).toContain('a.txt')
expect(await flash.count()).toBe(0)
expect(await tabs.count()).toBe(tabCount + 1)
// The preview's toggle shows the pane's tree without changing the files page.
await toggle.click()
await expect.poll(() => toggle.getAttribute('aria-expanded')).toBe('true')
await expect.poll(() => current.textContent()).toBe('a.txt')
// Picking the open file again while its glow plays restarts the glow, which then lasts its whole 600ms animation.
const openFile = navigator.getByRole('button', { name: 'a.txt', exact: true })
await openFile.click()
// A glow replaced before it starts is dropped without a cancellation, so the repeat waits until the first one plays.
await expect.poll(() => flash.evaluate(pane => pane.getAnimations({ subtree: true })
.some(glow => Number(glow.currentTime) > 100))).toBe(true)
const first = await flash.getAttribute('data-sidebar-right-flash')
const repeat = await openFile.evaluate((button) => {
const pane = button.closest<HTMLElement>('[data-sidebar-right-flash]')
if (pane === null) throw new Error('the open file is outside the glowing pane')
const since = performance.now()
return new Promise<{ mark: string | undefined; lasted: number }>((resolve) => {
let mark: string | undefined
new MutationObserver((_, observer) => {
mark = pane.dataset.sidebarRightFlash ?? mark
if (pane.dataset.sidebarRightFlash !== undefined) return
observer.disconnect()
resolve({ mark, lasted: performance.now() - since })
}).observe(pane, { attributes: true, attributeFilter: ['data-sidebar-right-flash'] })
;(button as HTMLElement).click()
})
})
expect(repeat.mark).toBe(first === 'odd' ? 'even' : 'odd')
expect(repeat.lasted).toBeGreaterThan(450)
// In a wide row the tree is a card beside the preview: the body narrows by the card and its 8px margins, and still scrolls.
await column.locator('[data-files-navigator-mode="push"]:visible').waitFor()
const wideRow = await body.locator('..').boundingBox()
const wideBody = await body.boundingBox()
const wideTree = await navigator.boundingBox()
if (wideRow === null || wideBody === null || wideTree === null) throw new Error('preview row is not rendered')
expect(Math.abs(wideBody.width - (wideRow.width - wideTree.width - 16))).toBeLessThanOrEqual(1)
expect(wideTree.x).toBeGreaterThanOrEqual(wideBody.x + wideBody.width + 7)
await expect.poll(() => body.evaluate(node => node.scrollHeight > node.clientHeight)).toBe(true)
await page.mouse.move(wideBody.x + 40, wideBody.y + 80)
await page.mouse.wheel(0, 400)
await expect.poll(() => body.evaluate(node => node.scrollTop)).toBeGreaterThan(0)
// A press on the preview leaves the tree shown.
await page.mouse.click(wideBody.x + 40, wideBody.y + 80)
expect(await toggle.getAttribute('aria-expanded')).toBe('true')
await toggle.focus()
// In dark mode the card takes the filled tree's background, with a soft white shadow.
const theme = await page.locator('body').getAttribute('data-ds-dark-theme')
try {
await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
const cardPaint = await navigator.evaluate(node => ({
background: getComputedStyle(node).backgroundColor,
shadow: getComputedStyle(node).boxShadow,
}))
expect(cardPaint.shadow).toContain('color(srgb 1 1 1 / 0.08)')
await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 360px !important; }' })
await column.locator('[data-files-navigator-mode="fill"]:visible').waitFor()
expect(await navigator.evaluate(node => getComputedStyle(node).backgroundColor)).toBe(cardPaint.background)
await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 960px !important; }' })
await navigator.getByRole('separator').waitFor()
} finally {
await page.evaluate((value) => {
if (value === null) document.body.removeAttribute('data-ds-dark-theme')
else document.body.setAttribute('data-ds-dark-theme', value)
}, theme)
}
// The reader scrolls the preview and widens the tree.
await body.evaluate((node) => { node.scrollTop = 900 })
const edge = navigator.getByRole('separator', { name: 'Resize file tree' })
const grip = await edge.boundingBox()
if (grip === null) throw new Error('resize edge is not rendered')
await page.mouse.move(grip.x + grip.width / 2, grip.y + 120)
await page.mouse.down()
await page.mouse.move(grip.x + grip.width / 2 - 40, grip.y + 120, { steps: 4 })
await page.mouse.up()
await expect.poll(() => edge.getAttribute('aria-valuenow')).toBe('300')
// The files page appends another preview; it shares the pane's tree choice and width.
await column.locator(`[data-dockkit-tab="${pageTab}"]`).click()
await navigator.getByRole('button', { name: 'b.txt', exact: true }).click()
await column.locator(`[data-dockkit-tab="${pageTab}"]`).hover()
await column.locator(`[data-dockkit-tab-close="${pageTab}"]`).click()
await expect.poll(() => path.textContent()).toContain('b.txt')
expect(await tabs.count()).toBe(tabCount + 1)
await expect.poll(() => current.textContent()).toBe('b.txt')
expect(await navigator.getByRole('button', { name: 'src', exact: true }).getAttribute('aria-expanded')).toBe('true')
expect(await edge.getAttribute('aria-valuenow')).toBe('300')
// Clicking an open file returns to its tab, where the reader left off.
await navigator.getByRole('button', { name: 'a.txt', exact: true }).click()
await expect.poll(() => path.textContent()).toContain('a.txt')
expect(await tabs.count()).toBe(tabCount + 1)
await expect.poll(() => body.evaluate(node => node.scrollTop)).toBe(900)
await expect.poll(() => current.textContent()).toBe('a.txt')
// Workspace navigation has one open action; the row's shared context menu does not offer a second new-tab route.
await navigator.getByRole('button', { name: 'a.txt', exact: true }).click({ button: 'right' })
const rowMenu = page.locator('[data-context-menu][role="menu"]')
await rowMenu.waitFor()
expect(await page.getByRole('menuitem', { name: 'Open in new tab', exact: true }).count()).toBe(0)
await page.keyboard.press('Escape')
await rowMenu.waitFor({ state: 'detached' })
// A narrower pane floats the tree over the preview below its header; the body keeps the row's width and still scrolls.
await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 460px !important; }' })
await column.locator('[data-files-navigator-mode="overlay"]:visible').waitFor()
const header = await path.locator('..').boundingBox()
const tree = await navigator.boundingBox()
if (header === null || tree === null) throw new Error('browser geometry is not rendered')
expect(tree.y).toBeGreaterThanOrEqual(header.y + header.height - 1)
const middleRow = await body.locator('..').boundingBox()
const preview = await body.boundingBox()
if (middleRow === null || preview === null) throw new Error('preview body is not rendered')
expect(preview.width).toBeGreaterThan(middleRow.width - 1)
await body.evaluate((node) => { node.scrollTop = 0 })
await page.mouse.move(preview.x + 20, preview.y + 80)
await page.mouse.wheel(0, 400)
await expect.poll(() => body.evaluate(node => node.scrollTop)).toBeGreaterThan(0)
await navigator.getByRole('button', { name: 'b.txt', exact: true }).click()
await expect.poll(() => current.textContent()).toBe('b.txt')
// The keyboard stays in the tree the revealed tab draws, so Escape reaches the row.
await expect.poll(() => current.evaluate(node => node === document.activeElement)).toBe(true)
await page.keyboard.press('Escape')
await expect.poll(() => toggle.getAttribute('aria-expanded')).toBe('false')
expect(await navigator.count()).toBe(0)
expect(await toggle.evaluate(node => node === document.activeElement)).toBe(true)
// A row narrower than 400px is filled by the tree, without the resize edge; Escape still closes it.
await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 360px !important; }' })
await toggle.click()
await column.locator('[data-files-navigator-mode="fill"]:visible').waitFor()
const narrowRow = await body.locator('..').boundingBox()
const narrowTree = await navigator.boundingBox()
if (narrowRow === null || narrowTree === null) throw new Error('narrow geometry is not rendered')
expect(Math.abs(narrowTree.width - narrowRow.width)).toBeLessThanOrEqual(1)
expect(await navigator.getByRole('separator').count()).toBe(0)
expect(await body.evaluate(node => getComputedStyle(node).visibility)).toBe('hidden')
await toggle.focus()
await page.keyboard.press('Tab')
expect(await body.evaluate(node => node.contains(document.activeElement))).toBe(false)
expect(await navigator.evaluate(node => node.contains(document.activeElement))).toBe(true)
await navigator.getByRole('button', { name: 'b.txt', exact: true }).focus()
await page.keyboard.press('Escape')
await expect.poll(() => toggle.getAttribute('aria-expanded')).toBe('false')
// A phone-width viewport fills the row below the header with the tree, without the resize edge, and opening a file hides it.
// The resize edge holding the keyboard when the viewport narrows hands it to the tree.
await layout.evaluate((node) => { node.textContent = '' })
await toggle.click()
const resizeEdge = navigator.getByRole('separator', { name: 'Resize file tree' })
await resizeEdge.focus()
expect(await resizeEdge.evaluate(node => node === document.activeElement)).toBe(true)
await page.setViewportSize({ width: 390, height: 844 })
await expect.poll(() => navigator.evaluate(node => node.contains(document.activeElement)
&& !document.activeElement?.hasAttribute('data-files-navigator-resize'))).toBe(true)
// The panel slides into its fullscreen place, so the geometry is read once it settles.
await expect.poll(async () => {
const row = await body.locator('..').boundingBox()
const filled = await navigator.boundingBox()
return row === null || filled === null ? Number.POSITIVE_INFINITY : Math.abs(filled.width - row.width)
}).toBeLessThanOrEqual(1)
expect(await navigator.getByRole('separator').count()).toBe(0)
await navigator.getByRole('button', { name: 'a.txt', exact: true }).click()
await expect.poll(() => path.textContent()).toContain('a.txt')
await expect.poll(() => toggle.getAttribute('aria-expanded')).toBe('false')
expect(await navigator.count()).toBe(0)
await page.setViewportSize({ width: 1680, height: 1000 })
await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 460px !important; }' })
// The workspace command shows the pane's tree, moves the keyboard into it, and keeps the file.
await input.focus()
await page.keyboard.press('Control+Alt+p')
await expect.poll(() => toggle.getAttribute('aria-expanded')).toBe('true')
await expect.poll(() => current.evaluate(node => node === document.activeElement)).toBe(true)
await expect.poll(() => path.textContent()).toContain('a.txt')
expect(await tabs.count()).toBe(tabCount + 1)
await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 960px !important; }' })
await column.locator('[data-dockkit-add-tab]').click()
await column.locator('[data-sidebar-right-guide-entry="files"]').click()
const htmlFilesTab = await tabs.filter({ hasText: 'Files' }).getAttribute('data-dockkit-tab')
await navigator.getByRole('button', { name: 'focus.html', exact: true }).click()
await column.locator(`[data-dockkit-tab="${htmlFilesTab}"]`).hover()
await column.locator(`[data-dockkit-tab-close="${htmlFilesTab}"]`).click()
await expect.poll(() => path.textContent()).toContain('focus.html')
const frame = body.locator('[data-html-preview]')
await frame.waitFor()
for (const narrow of ['row', 'viewport'] as const) {
await toggle.focus()
if (narrow === 'row') await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 360px !important; }' })
else await page.setViewportSize({ width: 390, height: 844 })
await expect.poll(() => body.evaluate(node => getComputedStyle(node).visibility)).toBe('hidden')
await page.keyboard.press('Tab')
expect(await navigator.evaluate(node => node.contains(document.activeElement))).toBe(true)
expect(await body.evaluate(node => node.contains(document.activeElement))).toBe(false)
await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 960px !important; }' })
await page.setViewportSize({ width: 1680, height: 1000 })
await expect.poll(() => body.evaluate(node => getComputedStyle(node).visibility)).toBe('visible')
await frame.focus()
if (narrow === 'row') await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 360px !important; }' })
else await page.setViewportSize({ width: 390, height: 844 })
await expect.poll(() => body.evaluate(node => getComputedStyle(node).visibility)).toBe('hidden')
expect(await toggle.getAttribute('aria-expanded')).toBe('true')
await expect.poll(() => navigator.evaluate(node => node.contains(document.activeElement))).toBe(true)
expect(await frame.evaluate(node => document.activeElement === node)).toBe(false)
await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 960px !important; }' })
await page.setViewportSize({ width: 1680, height: 1000 })
await expect.poll(() => body.evaluate(node => getComputedStyle(node).visibility)).toBe('visible')
}
// A preview dragged out into a floating panel keeps where the reader was in it.
await tabs.filter({ hasText: 'b.txt' }).click()
await expect.poll(() => path.textContent()).toContain('b.txt')
await body.evaluate((node) => { node.scrollTop = 600 })
await expect.poll(() => body.evaluate(node => node.scrollTop)).toBe(600)
const surface = await page.locator('[data-dockkit-surface]').first().boundingBox()
if (surface === null) throw new Error('surface is not rendered')
await drag(page, tabs.filter({ hasText: 'b.txt' }), { x: surface.x - 240, y: surface.y + surface.height / 2 })
const float = page.locator('[data-sidebar-right-session]:not([hidden]) [data-dockkit-float]')
await expect.poll(() => float.count()).toBe(1)
const floatPath = float.locator('[data-textpreview-path]')
await floatPath.waitFor()
const floatBody = float.locator('[data-textpreview-state] [data-textpreview-body]')
await expect.poll(() => floatBody.evaluate(node => node.scrollTop)).toBe(600)
// The panel is a new pane, so its tree starts hidden; shown, it draws there too, and the panel keeps its resize handle.
const floatToggle = float.locator('[data-files-navigator-toggle]')
expect(await floatToggle.getAttribute('aria-expanded')).toBe('false')
expect(await float.locator('[data-files-navigator]').count()).toBe(0)
await floatToggle.click()
const before = await float.boundingBox()
if (before === null) throw new Error('float is not rendered')
const resize = float.locator('[data-dockkit-float-resize]')
await drag(page, resize, { x: before.x + Math.max(before.width + 80, 560), y: before.y + before.height + 60 })
await expect.poll(async () => (await float.boundingBox())?.width ?? before.width).toBeGreaterThan(before.width)
// Wide enough that the tree does not fill the panel.
expect((await float.boundingBox())!.width).toBeGreaterThanOrEqual(440)
const floatTree = float.locator('[data-files-navigator]')
await floatTree.waitFor()
expect(await floatToggle.getAttribute('aria-expanded')).toBe('true')
// Resize while the tree is open, through both sides of its fill breakpoint.
const openBox = await float.boundingBox()
if (openBox === null) throw new Error('floating preview is not rendered')
await resizeFloat(page, float, 360, openBox.height - 20)
await float.locator('[data-files-navigator-mode="fill"]').waitFor()
await resizeFloat(page, float, 560, openBox.height)
expect(await floatTree.count()).toBe(1)
const floatPane = await float.getAttribute('data-dockkit-float')
// A file nothing shows replaces the preview in its panel, which keeps its pane and its tree.
await floatTree.getByRole('button', { name: 'README.md', exact: true }).click()
await expect.poll(() => floatPath.textContent()).toContain('README.md')
expect(await float.count()).toBe(1)
expect(await float.getAttribute('data-dockkit-float')).toBe(floatPane)
expect(await float.locator('[data-files-navigator] [aria-current="true"]').textContent()).toBe('README.md')
// Navigating to the floating preview itself exposes its file when its tree fills the row.
const previewHeight = (await float.boundingBox())!.height
await resizeFloat(page, float, 360, previewHeight)
await float.locator('[data-files-navigator-mode="fill"]').waitFor()
await floatTree.getByRole('button', { name: 'README.md', exact: true }).click()
await expect.poll(() => floatToggle.getAttribute('aria-expanded')).toBe('false')
await expect.poll(() => floatToggle.evaluate(node => node === document.activeElement)).toBe(true)
await floatToggle.click()
await floatTree.waitFor()
// A file already shown is revealed in the column, which first shows another tab; the panel keeps its file.
// Revealing b.txt opened `src`.
const shown = column.locator('[data-dockkit-tab][aria-selected="true"]')
await column.locator('[data-dockkit-add-tab]').click()
await expect.poll(() => shown.textContent()).not.toContain('a.txt')
const guide = await shown.getAttribute('data-dockkit-tab')
const dockedTabs = await tabs.count()
await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 360px !important; }' })
const sourceFile = floatTree.getByRole('button', { name: 'a.txt', exact: true })
await sourceFile.click()
await expect.poll(() => shown.textContent()).toContain('a.txt')
const dockPreview = column.locator('[data-dockkit-pane] [data-textpreview-state]:visible')
await expect.poll(() => dockPreview.locator('[data-files-navigator-toggle]').getAttribute('aria-expanded')).toBe('false')
expect(await dockPreview.locator('[data-textpreview-body]').evaluate(node => getComputedStyle(node).visibility)).toBe('visible')
expect(await floatToggle.getAttribute('aria-expanded')).toBe('true')
expect(await sourceFile.evaluate(node => node === document.activeElement)).toBe(true)
await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 960px !important; }' })
await resizeFloat(page, float, 560, previewHeight)
expect(await tabs.count()).toBe(dockedTabs)
expect(await floatPath.textContent()).toContain('README.md')
expect(await float.count()).toBe(1)
expect(await tabs.filter({ hasText: 'a.txt' }).count()).toBe(1)
// The column offers a new tab again only once its guide closes.
await column.locator(`[data-dockkit-tab="${guide}"]`).hover()
await column.locator(`[data-dockkit-tab-close="${guide}"]`).click()
await expect.poll(() => column.locator(`[data-dockkit-tab="${guide}"]`).count()).toBe(0)
// A floating files page stays and opens a file nothing shows in the active docked pane.
await column.locator('[data-dockkit-add-tab]').click()
await column.locator('[data-sidebar-right-guide-entry="files"]').click()
await column.locator('[data-files-page]').waitFor()
await drag(page, tabs.filter({ hasText: 'Files' }), { x: surface.x - 240, y: surface.y + 120 })
await expect.poll(() => float.count()).toBe(2)
const pageFloat = float.filter({ has: page.locator('[data-files-page]') })
const filePageBox = await pageFloat.boundingBox()
if (filePageBox === null) throw new Error('floating files page is not rendered')
await resizeFloat(page, pageFloat, 360, filePageBox.height - 20)
await page.setViewportSize({ width: 390, height: 844 })
const phoneBox = await pageFloat.boundingBox()
if (phoneBox === null) throw new Error('phone-width files page is not rendered')
await resizeFloat(page, pageFloat, 340, phoneBox.height - 20)
expect(await pageFloat.locator('[data-files-navigator]').count()).toBe(1)
await page.setViewportSize({ width: 1680, height: 1000 })
const docked = await tabs.count()
await pageFloat.getByRole('button', { name: 'src', exact: true }).click()
await pageFloat.getByRole('button', { name: 'c.txt', exact: true }).click()
await column.locator('[data-textpreview-path]:visible', { hasText: 'c.txt' }).waitFor()
expect(await pageFloat.count()).toBe(1)
expect(await float.count()).toBe(2)
expect(await column.locator('[data-dockkit-pane]').count()).toBe(1)
expect(await tabs.count()).toBe(docked + 1)
// A file already shown is revealed in the column, and the floating page stays with its tree.
await pageFloat.getByRole('button', { name: 'a.txt', exact: true }).click()
await expect.poll(() => column.locator('[data-dockkit-pane-active] [data-dockkit-tab][aria-selected="true"]').textContent()).toContain('a.txt')
expect(await pageFloat.count()).toBe(1)
expect(await pageFloat.locator('[data-files-navigator]').count()).toBe(1)
expect(await float.count()).toBe(2)
expect(await tabs.count()).toBe(docked + 1)
// With the docked area emptied and the column collapsed, a file from the floating page gets a docked pane in the expanded column.
const dockTabs = column.locator('[data-dockkit-pane] [data-dockkit-tab]')
while (await dockTabs.count() > 0) {
const id = await dockTabs.first().getAttribute('data-dockkit-tab')
await dockTabs.first().hover()
await column.locator(`[data-dockkit-tab-close="${id}"]`).click()
await expect.poll(() => column.locator(`[data-dockkit-tab="${id}"]`).count()).toBe(0)
}
const expand = page.locator('[data-sidebar-right-expand]')
await expand.waitFor()
expect(await pageFloat.count()).toBe(1)
await pageFloat.getByRole('button', { name: 'b.txt', exact: true }).click()
await column.locator('[data-textpreview-path]:visible', { hasText: 'b.txt' }).waitFor()
// Past the column's slide, it stays open with the file's tab alone: no default page beside it.
await expect.poll(() => column.evaluate(node => node.getAnimations({ subtree: true })
.filter(animation => animation.playState === 'running').length)).toBe(0)
expect(await expand.count()).toBe(0)
expect(await column.locator('[data-dockkit-pane]').count()).toBe(1)
expect(await dockTabs.count()).toBe(1)
expect(await dockTabs.first().textContent()).toContain('b.txt')
expect(await column.locator('[data-sidebar-right-guide-entry]').count()).toBe(0)
expect((await column.boundingBox())?.width ?? 0).toBeGreaterThan(200)
expect(errors).toEqual({ warnings: [], pageErrors: [] })
})
it('moves a file between the docked column and a floating panel from the file menu, keeping its reading position', async () => {
// The file menu's open actions belong to Open In, which ordinary scenarios leave out; remote launch facts suppress native probes.
const { page, errors } = await openWorkspace('web-e2e-file-browser-destination',
createLaunchEnvironmentSnapshot([{ source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }]))
const column = page.locator('[data-rightbar-col]')
const expand = page.locator('[data-sidebar-right-expand]')
const float = page.locator('[data-sidebar-right-session]:not([hidden]) [data-dockkit-float]')
const docked = (name: string) => column.locator('[data-dockkit-pane] [data-dockkit-tab]', { hasText: name })
const menu = page.locator('[data-context-menu][role="menu"]')
// The files page floats and the column collapses, so every later open starts from a floating source pane.
await expand.click()
await column.locator('[data-sidebar-right-guide-entry="files"]').click()
await column.locator('[data-files-page]').waitFor()
const surface = await page.locator('[data-dockkit-surface]').first().boundingBox()
if (surface === null) throw new Error('surface is not rendered')
await drag(page, column.locator('[data-dockkit-tab]', { hasText: 'Files' }), { x: surface.x - 240, y: surface.y + 120 })
await expect.poll(() => float.count()).toBe(1)
await column.locator('[data-sidebar-right-toggle]').click()
await expand.waitFor()
const pageFloat = float.filter({ has: page.locator('[data-files-page]') })
await pageFloat.getByRole('button', { name: 'src', exact: true }).click()
const row = pageFloat.getByRole('button', { name: 'a.txt', exact: true })
const choose = async (item: string): Promise<void> => {
await row.click({ button: 'right' })
await menu.waitFor()
await menu.getByRole('menuitem', { name: item, exact: true }).click()
await menu.waitFor({ state: 'detached' })
}
await row.click({ button: 'right' })
await menu.getByRole('menuitem', { name: 'Open in floating panel', exact: true }).waitFor()
expect(await menu.getByRole('menuitem', { name: 'Open in sidebar', exact: true }).count()).toBe(1)
await page.keyboard.press('Escape')
await menu.waitFor({ state: 'detached' })
// A file nothing shows opens in its own floating panel; the collapsed column stays collapsed.
await choose('Open in floating panel')
await expect.poll(() => float.count()).toBe(2)
const fileFloat = float.filter({ has: page.locator('[data-textpreview-path]', { hasText: 'a.txt' }) })
const floatBody = fileFloat.locator('[data-textpreview-state] [data-textpreview-body]')
await floatBody.waitFor()
expect(await expand.count()).toBe(1)
expect(await docked('a.txt').count()).toBe(0)
await expect.poll(() => floatBody.evaluate(node => node.scrollHeight > node.clientHeight)).toBe(true)
await floatBody.evaluate((node) => { node.scrollTop = 600 })
await expect.poll(() => floatBody.evaluate(node => node.scrollTop)).toBe(600)
// Asking again reveals that panel rather than adding one.
await choose('Open in floating panel')
expect(await float.count()).toBe(2)
expect(await fileFloat.count()).toBe(1)
expect(await expand.count()).toBe(1)
// The floating file moves into the expanded column, where the reader left off.
await choose('Open in sidebar')
await expect.poll(() => fileFloat.count()).toBe(0)
expect(await float.count()).toBe(1)
await expect.poll(() => expand.count()).toBe(0)
await expect.poll(() => docked('a.txt').count()).toBe(1)
const dockBody = column.locator('[data-dockkit-pane] [data-textpreview-state]:visible [data-textpreview-body]')
await expect.poll(() => dockBody.evaluate(node => node.scrollTop)).toBe(600)
// And back out into a floating panel, leaving the column open and its strip without the file.
await choose('Open in floating panel')
await expect.poll(() => fileFloat.count()).toBe(1)
expect(await float.count()).toBe(2)
await expect.poll(() => docked('a.txt').count()).toBe(0)
expect(await expand.count()).toBe(0)
await expect.poll(() => floatBody.evaluate(node => node.scrollTop)).toBe(600)
expect(errors).toEqual({ warnings: [], pageErrors: [] })
})
@@ -0,0 +1,19 @@
{
"format": 1,
"title": { "en": "Awesome DSH Plugin", "zh": "Awesome DSH Plugin 插件精选" },
"metadata": {
"categories": { "tools": { "title": { "en": "Tools", "zh": "工具与能力" } } },
"labels": { "featured": { "title": { "en": "Featured", "zh": "精选" } } }
},
"plugins": [
{
"name": "dsh-toolbar-layout-fixture",
"title": { "en": "Toolbar layout fixture", "zh": "工具栏布局示例" },
"description": { "en": "A local index for responsive toolbar checks.", "zh": "用于工具栏响应式检查的本地索引" },
"metadata": { "category": "tools", "labels": ["featured"] },
"versions": [
{ "version": "1.0.0", "publishTimestamp": 1793145600, "source": { "type": "npm" } }
]
}
]
}
+72 -21
View File
@@ -1,37 +1,88 @@
/** Model input types stay below both capacity fields across editor widths. */
/** Model identity, capacity and input-type fields follow the model row's available width. */
import type { Locator, Page } from 'playwright'
import { expect } from 'vitest'
/**
* Verify the expanded row at the current viewport and a narrow viewport.
* Verify expanded identity fields, capacity fields and input types across viewport and row-width boundaries.
* @param page - page with an explicit viewport, restored even when an assertion fails.
* @param editor - expanded model editor containing the capacity and input-type fields.
* @returns after checking both widths and restoring the caller's size.
* @param editor - expanded model editor containing the first model's identity, capacity and input-type fields.
* @returns after checking all widths and restoring the caller's size.
*/
export async function assertModelInputLayout(page: Page, editor: Locator): Promise<void> {
const original = page.viewportSize()
if (original === null) throw new Error('Model input layout checks require an explicit viewport')
const drawerCount = await page.locator('[data-sidebar-drawer]').count()
const entry = editor.locator('[data-row-key]').first()
const originalStyle = await entry.getAttribute('style')
const check = async (): Promise<void> => {
const id = await editor.getByLabel('模型 ID 1', { exact: true }).boundingBox()
const name = await editor.getByRole('textbox', { name: '显示名称 1', exact: true }).boundingBox()
const context = await editor.getByLabel('上下文窗口 1', { exact: true }).boundingBox()
const output = await editor.getByLabel('最大输出 token 数 1', { exact: true }).boundingBox()
const types = await editor.getByRole('group', { name: '输入类型 1' }).boundingBox()
if (id === null || name === null || context === null || output === null || types === null) {
throw new Error('Expanded model fields are not visible')
}
const geometry = await entry.evaluate((element) => {
const style = getComputedStyle(element)
const row = element.firstElementChild!
return {
contentWidth: element.getBoundingClientRect().width - Number.parseFloat(style.paddingLeft)
- Number.parseFloat(style.paddingRight) - Number.parseFloat(style.borderLeftWidth) - Number.parseFloat(style.borderRightWidth),
rowWidth: row.getBoundingClientRect().width,
gap: Number.parseFloat(getComputedStyle(row).columnGap),
buttons: [...row.querySelectorAll(':scope > button')].map(button => button.getBoundingClientRect().width),
}
})
expect(Math.abs(context.y - output.y)).toBeLessThan(1)
expect(types.y).toBeGreaterThanOrEqual(Math.max(context.y + context.height, output.y + output.height))
expect(types.width).toBeGreaterThan(context.width)
if (geometry.contentWidth <= 440) {
expect(name.y).toBeGreaterThanOrEqual(id.y + id.height)
expect(name.width).toBeGreaterThan(id.width)
expect(context.y).toBeGreaterThanOrEqual(name.y + name.height)
expect(geometry.buttons).toHaveLength(3)
const available = geometry.rowWidth - geometry.buttons.reduce((sum, width) => sum + width, 0) - 3 * geometry.gap
expect(Math.abs(id.width - available)).toBeLessThan(1)
} else {
expect(Math.abs(id.y - name.y)).toBeLessThan(1)
expect(name.x).toBeGreaterThanOrEqual(id.x + id.width)
}
const overflowing = await editor.getByRole('region', { name: '模型目录', exact: true }).evaluate((region) => {
const widths = []
for (let element: Element | null = region; element !== null; element = element.parentElement) {
if (element === region || /auto|scroll/.test(getComputedStyle(element).overflowY)) {
widths.push({ width: element.clientWidth, scroll: element.scrollWidth })
}
if (element.getAttribute('role') === 'dialog') break
}
return widths.filter(({ width, scroll }) => scroll > width)
})
expect(overflowing).toEqual([])
}
try {
for (const viewport of [original, { width: 760, height: 1000 }]) {
for (const viewport of [original, { width: 760, height: 1000 }, { width: 390, height: 844 }]) {
await page.setViewportSize(viewport)
const context = await editor.getByLabel('上下文窗口 1', { exact: true }).boundingBox()
const output = await editor.getByLabel('最大输出 token 数 1', { exact: true }).boundingBox()
const types = await editor.getByRole('group', { name: '输入类型 1' }).boundingBox()
expect(context).not.toBeNull()
expect(output).not.toBeNull()
expect(types).not.toBeNull()
if (context === null || output === null || types === null) throw new Error('Expanded model fields are not visible')
expect(Math.abs(context.y - output.y)).toBeLessThan(1)
expect(types.y).toBeGreaterThanOrEqual(Math.max(context.y + context.height, output.y + output.height))
expect(types.width).toBeGreaterThan(context.width)
await check()
}
await page.setViewportSize({ width: 1680, height: 1000 })
for (const width of [440, 441]) {
await entry.evaluate((element, width) => {
element.style.boxSizing = 'content-box'
element.style.width = `${String(width)}px`
}, width)
await check()
}
} finally {
await page.setViewportSize(original)
// The frame reports its own width to the layout store from a rAF-throttled
// ResizeObserver, so the restored viewport is only in force once the shell
// has left drawer mode; a capture taken in between reads the narrow frame.
if (await page.locator('[data-sidebar-drawer]').count() > 0) {
await expect.poll(async () => page.locator('[data-sidebar-drawer]').count(), { timeout: 5_000 }).toBe(0)
try {
await entry.evaluate((element, originalStyle) => {
if (originalStyle === null) element.removeAttribute('style')
else element.setAttribute('style', originalStyle)
}, originalStyle)
} finally {
await page.setViewportSize(original)
// The shell's ResizeObserver must restore its original drawer posture before the next capture.
await expect.poll(async () => page.locator('[data-sidebar-drawer]').count(), { timeout: 5_000 }).toBe(drawerCount)
}
}
}
+147 -1
View File
@@ -26,7 +26,7 @@ import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { openSettings, ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
import { expectTooltipOnTop, openSettings, ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/models-settings', import.meta.url))
const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md')
@@ -576,6 +576,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
return { x: rect.left + 20, y: rect.bottom + 4 }
})
await page.mouse.move(insertion.x, insertion.y, { steps: 3 })
expect(await dialog.locator('[data-drop-target]').evaluate(element => getComputedStyle(element).zIndex)).toBe('1')
await page.mouse.up()
await page.locator('body > div[inert][aria-hidden="true"]:has(button[aria-label^="调整模型顺序 "])').waitFor({ state: 'detached' })
await expect.poll(() => dialog.getByLabel(`模型 ID ${save ? '3' : '2'}`, { exact: true }).inputValue()).toBe('acme-large')
@@ -603,7 +604,21 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
const viewport = page.viewportSize()
if (viewport === null) throw new Error('Model ordering checks require an explicit viewport')
try {
const id = dialog.getByRole('textbox', { name: '模型 ID 1', exact: true })
const name = dialog.getByRole('textbox', { name: '显示名称 1', exact: true })
const toggle = dialog.getByRole('button', { name: '模型选项 1', exact: true })
const desktopId = await id.boundingBox()
const desktopName = await name.boundingBox()
if (desktopId === null || desktopName === null) throw new Error('Wide model identity fields are not visible')
expect(Math.abs(desktopId.y - desktopName.y)).toBeLessThan(1)
await name.focus()
await page.setViewportSize({ width: 390, height: 844 })
expect(await name.evaluate(input => input.ownerDocument.activeElement === input)).toBe(true)
expect(await name.isVisible()).toBe(true)
await toggle.click()
await name.waitFor({ state: 'hidden' })
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'mobile-collapsed.expected.md'),
await captureStableAria(page, '[data-shortcut-modal="settings"]', scaffold.workspaceCwd), MODE)
for (const name of ['调整模型顺序 1', '模型选项 1', '删除模型 1']) {
const box = await dialog.getByRole('button', { name, exact: true }).boundingBox()
expect(box).not.toBeNull()
@@ -611,6 +626,24 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
expect(box.x).toBeGreaterThanOrEqual(0)
expect(box.x + box.width).toBeLessThanOrEqual(390)
}
const compactId = await id.boundingBox()
if (compactId === null) throw new Error('Narrow model ID is not visible')
await toggle.click()
await name.waitFor({ state: 'visible' })
await name.fill('手机上的完整显示名称')
await toggle.click()
await name.waitFor({ state: 'hidden' })
expect(await id.inputValue()).toBe('order-second')
await toggle.click()
expect(await name.inputValue()).toBe('手机上的完整显示名称')
const expandedName = await name.boundingBox()
if (expandedName === null) throw new Error('Expanded model name is not visible')
expect(expandedName.y).toBeGreaterThanOrEqual(compactId.y + compactId.height)
expect(expandedName.width).toBeGreaterThan(compactId.width)
expect(await dialog.getByRole('region', { name: '模型目录', exact: true })
.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true)
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'mobile-expanded.expected.md'),
await captureStableAria(page, '[data-shortcut-modal="settings"]', scaffold.workspaceCwd), MODE)
} finally {
await page.setViewportSize(viewport)
}
@@ -618,6 +651,118 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
expect(tripwire.pageErrors).toEqual([])
})
it.each([{ name: 'Chromium', engine: chromium }, { name: 'WebKit', engine: webkit }])(
'keeps mobile model actions, focus and keyboard order in $name', async ({ name: engineName, engine }) => {
const provider = `review-${engineName.toLowerCase()}`
let mobileBrowser: Browser | undefined
try {
await scaffold.ctx.settings.mutate('llm-pi-ai', [{
op: 'set', path: ['providers', provider], value: {
api: 'anthropic-messages', baseURL: 'https://layout.example',
models: [{ id: 'layout-model', name: '完整显示名称' }, { id: 'next-model', name: '下一模型' }],
},
}])
mobileBrowser = await engine.launch()
const mobilePage = await mobileBrowser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
const errors = watchConsole(mobilePage)
onTestFailed(() => saveFailureShot(mobilePage, `web-e2e-models-mobile-${engineName.toLowerCase()}`))
await mobilePage.goto(scaffold.authenticatedUrl)
await openSettings(mobilePage, 'zh')
const dialog = mobilePage.getByRole('dialog', { name: '设置' })
await dialog.getByRole('button', { name: '模型', exact: true }).click()
const edit = dialog.getByRole('button', { name: `编辑 ${provider}`, exact: true })
await edit.click()
await dialog.getByText('自定义设置').click()
const id = dialog.getByRole('textbox', { name: '模型 ID 1', exact: true })
const name = dialog.getByRole('textbox', { name: '显示名称 1', exact: true })
const toggle = dialog.getByRole('button', { name: '模型选项 1', exact: true })
const nextToggle = dialog.getByRole('button', { name: '模型选项 2', exact: true })
await toggle.click()
await assertModelInputLayout(mobilePage, dialog)
await dialog.getByRole('button', { name: '调整模型顺序 1', exact: true }).hover()
const tooltip = mobilePage.getByRole('tooltip')
await tooltip.waitFor()
expect(await tooltip.evaluate(element => element.parentElement === element.ownerDocument.body)).toBe(true)
await expectTooltipOnTop(tooltip)
await id.hover()
await tooltip.waitFor({ state: 'hidden' })
await toggle.click()
await name.fill('缩窄后继续编辑')
const input = await name.elementHandle()
if (input === null) throw new Error('Model name input is missing')
await mobilePage.setViewportSize({ width: 390, height: 844 })
expect(await input.evaluate(element => element.isConnected && element.ownerDocument.activeElement === element)).toBe(true)
await nextToggle.click()
expect(await nextToggle.getAttribute('aria-expanded')).toBe('true')
expect(await toggle.getAttribute('aria-expanded')).toBe('true')
await nextToggle.click()
await name.fill('手机保存后的名称')
await dialog.getByRole('button', { name: '保存', exact: true }).click()
await id.waitFor({ state: 'detached' })
expect(scaffold.ctx.settings.describe().find(row => row.ns === 'llm-pi-ai')?.value).toMatchObject({
providers: { [provider]: { models: [{ id: 'layout-model', name: '手机保存后的名称' }, { id: 'next-model', name: '下一模型' }] } },
})
await dialog.getByText(`已保存 ${provider}。`, { exact: true }).waitFor()
await edit.click()
await dialog.getByText('自定义设置').click()
await name.waitFor({ state: 'hidden' })
await toggle.focus()
await mobilePage.keyboard.press('Enter')
const tab = engine === webkit ? 'Alt+Tab' : 'Tab'
await mobilePage.keyboard.press(tab)
expect(await dialog.getByRole('button', { name: '删除模型 1', exact: true })
.evaluate(button => button.ownerDocument.activeElement === button)).toBe(true)
await mobilePage.keyboard.press(tab)
expect(await name.evaluate(element => element.ownerDocument.activeElement === element)).toBe(true)
await name.fill('直接点击收起后保留')
await toggle.click()
await name.waitFor({ state: 'hidden' })
expect(await toggle.getAttribute('aria-expanded')).toBe('false')
expect(await toggle.evaluate(button => button.ownerDocument.activeElement === button)).toBe(true)
await toggle.click()
expect(await name.inputValue()).toBe('直接点击收起后保留')
const region = dialog.getByRole('region', { name: '模型目录', exact: true })
expect(await region.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true)
await dialog.getByRole('button', { name: '取消', exact: true }).click()
expect(errors.pageErrors).toEqual([])
const touchPage = await mobileBrowser.newPage({
viewport: { width: 390, height: 844 }, locale: ZH_BROWSER_LOCALE, isMobile: true, hasTouch: true,
})
const touchErrors = watchConsole(touchPage)
await touchPage.goto(scaffold.authenticatedUrl)
await openSettings(touchPage, 'zh')
const touchDialog = touchPage.getByRole('dialog', { name: '设置' })
await touchDialog.getByRole('button', { name: '模型', exact: true }).tap()
await touchDialog.getByRole('button', { name: `编辑 ${provider}`, exact: true }).tap()
await touchDialog.getByText('自定义设置').tap()
const touchToggle = touchDialog.getByRole('button', { name: '模型选项 1', exact: true })
const touchName = touchDialog.getByRole('textbox', { name: '显示名称 1', exact: true })
await touchToggle.tap()
expect(await touchName.evaluate(element => element.ownerDocument.activeElement === element)).toBe(false)
await touchName.fill('触屏编辑后的名称')
await touchToggle.tap()
await touchName.waitFor({ state: 'hidden' })
expect(await touchToggle.getAttribute('aria-expanded')).toBe('false')
await touchToggle.tap()
expect(await touchName.inputValue()).toBe('触屏编辑后的名称')
await touchName.fill('触屏保存后的名称')
const touchNext = touchDialog.getByRole('button', { name: '模型选项 2', exact: true })
await touchNext.tap()
expect(await touchNext.getAttribute('aria-expanded')).toBe('true')
await touchDialog.getByRole('button', { name: '保存', exact: true }).tap()
await touchName.waitFor({ state: 'detached' })
expect(scaffold.ctx.settings.describe().find(row => row.ns === 'llm-pi-ai')?.value).toMatchObject({
providers: { [provider]: { models: [{ id: 'layout-model', name: '触屏保存后的名称' }, { id: 'next-model', name: '下一模型' }] } },
})
expect(touchErrors.pageErrors).toEqual([])
} finally {
await mobileBrowser?.close()
await scaffold.ctx.settings.mutate('llm-pi-ai', [{ op: 'unset', path: ['providers', provider] }])
}
}, 60_000,
)
it.each([{ name: 'Chromium', engine: chromium }, { name: 'WebKit', engine: webkit }])(
'keeps keyboard reorder focus and visibility in $name', async ({ engine }) => {
const keyboardBrowser = await engine.launch()
@@ -704,6 +849,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
'configured.expected.md', 'declared-edit.expected.md', 'declared.expected.md',
'delete.expected.md', 'empty.expected.md', 'model-picker.expected.md',
'native-delete.expected.md', 'native-keyless.expected.md', 'catalog-inputs.expected.md',
'mobile-collapsed.expected.md', 'mobile-expanded.expected.md',
'refreshing.expected.md', 'image-only.expected.md',
])
})
@@ -0,0 +1,3 @@
# Exercise the workspace file browser without the document preview.
- id: ui-sidebar-documentpreview
disabled: true
@@ -0,0 +1,3 @@
# Exercise the document preview without the workspace file browser.
- id: ui-sidebar-files
disabled: true
@@ -248,7 +248,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
const customModelId = model.getByLabel('模型 ID 1')
await customModelId.fill('private-preview')
await model.getByLabel('显示名称 1').fill('Private Preview')
await settings.getByRole('button', { name: '模型选项 1' }).click()
expect(await settings.getByRole('button', { name: '模型选项 1' }).getAttribute('aria-expanded')).toBe('true')
await model.getByLabel('上下文窗口 1').fill('131072')
await model.getByLabel('最大输出 token 数 1').fill('64K')
expect(await settings.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).isChecked()).toBe(false)
+134
View File
@@ -0,0 +1,134 @@
/** Keyless browser geometry for the Discover toolbar against a local plugin index. */
import { once } from 'node:events'
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
import { fileURLToPath } from 'node:url'
import { chromium, type Locator } from 'playwright'
import { expect, it } from 'vitest'
import type {} from '@deepseek-ai/dsh-plugin-market'
import { compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode } from './scaffold.ts'
import { ZH_BROWSER_LOCALE } from './support.ts'
const EXPECTED = fileURLToPath(new URL('./expected/plugin-market/toolbar-responsive.expected.md', import.meta.url))
const INDEX = new URL('./fixtures/plugin-market/toolbar.index.json', import.meta.url)
/** Read live control geometry, including the index button's intentional left outset. */
async function measure(toolbar: Locator) {
return toolbar.evaluate((element) => {
const bounds = element.getBoundingClientRect()
const panel = element.closest('[data-plugin-panel]')!.getBoundingClientRect()
const controls = Array.from(element.querySelectorAll('button, input'))
const rects = controls.map(control => control.getBoundingClientRect())
const contained = rects.every((rect, index) => {
// The index button uses an 8px left outset to align its text with the page content.
const left = index === 0 ? bounds.left - 8 : bounds.left
return rect.left >= left - 1 && rect.right <= bounds.right + 1
&& rect.left >= panel.left && rect.right <= panel.right
&& rect.left >= 0 && rect.right <= innerWidth && rect.top >= 0 && rect.bottom <= innerHeight
})
const reachable = controls.every((control, index) => {
const rect = rects[index]!
const hit = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2)
return rect.width > 0 && rect.height > 0 && (hit === control || control.contains(hit))
})
const separated = rects.every((rect, index) => rects.slice(index + 1).every(other =>
rect.right <= other.left || other.right <= rect.left || rect.bottom <= other.top || other.bottom <= rect.top))
const centers = rects.map(rect => rect.top + rect.height / 2)
return {
controls: controls.length,
contained,
reachable,
separated,
overflow: element.scrollWidth > element.clientWidth,
pageOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth,
singleLine: Math.max(...centers) - Math.min(...centers) < 1,
focused: document.activeElement === element.querySelector('input'),
}
})
}
/** Every toolbar action remains visible, reachable, and separate without losing the search focus. */
function validLayout(layout: Awaited<ReturnType<typeof measure>>): boolean {
return layout.controls === 5 && layout.contained && layout.reachable && layout.separated
&& !layout.overflow && !layout.pageOverflow && layout.focused
}
it('keeps Discover controls reachable when the toolbar wraps and retains search focus across widths', async () => {
const body = await readFile(INDEX)
const source = createServer((request, response) => {
if (request.url !== '/index.json') {
response.writeHead(404).end()
return
}
response.writeHead(200, { 'content-type': 'application/json' }).end(body)
})
source.listen(0, '127.0.0.1')
try {
await once(source, 'listening')
const address = source.address()
if (address === null || typeof address === 'string') throw new Error('The plugin index fixture has no TCP port')
const scaffold = await launchWebScaffold({
extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
profile: { packages: [], bundles: ['@deepseek-ai/dsh-experimental-plugin-market-bundle'] },
})
try {
await scaffold.ctx.pluginMarket.setIndexes([`http://127.0.0.1:${address.port}/index.json`])
const browser = await chromium.launch()
try {
const page = await browser.newPage({ viewport: { width: 1280, height: 844 }, locale: ZH_BROWSER_LOCALE, reducedMotion: 'reduce' })
const consoleWatch = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.getByRole('button', { name: '插件', exact: true }).click()
await page.getByRole('tab', { name: '发现', exact: true }).click()
await page.locator('[data-market-entry="dsh-toolbar-layout-fixture"]').waitFor()
await page.getByRole('button', { name: '收起侧边栏', exact: true }).click()
await page.evaluate(() => document.fonts.ready)
const toolbar = page.locator('[data-discover-toolbar]')
const search = toolbar.getByRole('searchbox', { name: '搜索插件' })
await search.focus()
const facts: string[] = []
for (const width of [390, 430, 768, 1280]) {
await page.setViewportSize({ width, height: 844 })
await expect.poll(async () => {
const layout = await measure(toolbar)
return validLayout(layout) && layout.singleLine === (width >= 768) ? true : layout
}, { message: `Discover toolbar at ${width}px` }).toBe(true)
facts.push(`- ${width}px: ${width >= 768 ? 'single row' : 'wrapped rows'}; index, search, labels, sort, and refresh are contained, reachable, and separate; no horizontal overflow; search focus retained.`)
}
await page.setViewportSize({ width: 390, height: 844 })
await expect.poll(async () => validLayout(await measure(toolbar))).toBe(true)
const previous = await toolbar.evaluate((element) => {
const previous = { value: element.style.getPropertyValue('flex-wrap'), priority: element.style.getPropertyPriority('flex-wrap') }
element.style.setProperty('flex-wrap', 'nowrap')
return previous
})
try {
const noWrap = await measure(toolbar)
expect(noWrap.overflow).toBe(true)
expect(validLayout(noWrap)).toBe(false)
} finally {
await toolbar.evaluate((element, previous) => {
if (previous.value === '') element.style.removeProperty('flex-wrap')
else element.style.setProperty('flex-wrap', previous.value, previous.priority)
}, previous)
}
await expect.poll(async () => validLayout(await measure(toolbar))).toBe(true)
expect(consoleWatch.pageErrors).toEqual([])
await compareOrRefreshGolden(EXPECTED,
`# Discover toolbar responsive layout\n\n${facts.join('\n')}\n\nAt 390px, restoring the previous no-wrap layout causes horizontal overflow and fails the same layout check. Removing that override restores the controls and preserves search focus.`,
webSnapshotMode())
} finally {
await browser.close()
}
} finally {
await scaffold.close()
}
} finally {
await new Promise<void>((resolve, reject) => {
source.close((error) => {
if (error === undefined) resolve()
else reject(error)
})
})
}
})
+3
View File
@@ -184,6 +184,9 @@ describe.skipIf(process.platform === 'win32')('Web sidebar shortcuts', () => {
await page.keyboard.press(`${primary}+Shift+,`)
expect(await panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).count()).toBe(1)
expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane)).toBe(true)
// Once the reveal's glow has ended, the focused pane draws nothing of its own. file-browser.e2e.ts and the
// seat spec own the glow itself, so this waits for no marker rather than requiring the transient one.
await expect.poll(() => panel.locator('[data-dockkit-pane][data-sidebar-right-flash]').count()).toBe(0)
expect(await paneAppearance()).toEqual([plainPane])
await bind(page, primary, 'Toggle panel fullscreen', 'Workspace files')
+3 -2
View File
@@ -178,8 +178,9 @@ describe.skipIf(mode === 'record')('web e2e: workspace shortcuts', () => {
await expect.poll(() => launched).toEqual([{ app: 'vscode', path: workspace.path }])
await page.keyboard.press('Meta+Alt+P')
const files = page.locator('[data-files-state="tree"]')
const files = page.locator('[data-files-page]')
await files.locator('[data-files-row="empty"]').waitFor()
// The directory opener sits in the files page's header, above the tree.
const opener = files.locator('[data-open-target="directory"]')
await opener.getByRole('button', { name: 'Open in VS Code', exact: true }).click()
await expect.poll(() => launched).toEqual([
@@ -196,7 +197,7 @@ describe.skipIf(mode === 'record')('web e2e: workspace shortcuts', () => {
])
await expect.poll(() => opener.getAttribute('data-state')).toBe('idle')
await compareOrRefreshGolden(join(root, 'files-open.expected.md'),
await captureStableAria(page, '[data-files-state="tree"]', scaffold.workspaceCwd), mode)
await captureStableAria(page, '[data-files-page]', scaffold.workspaceCwd), mode)
await opener.getByRole('button', { name: 'More ways to open', exact: true }).click()
await page.getByRole('menuitem', { name: 'Finder (default)', exact: true }).waitFor()
await page.keyboard.press('Escape')
+38
View File
@@ -1094,6 +1094,44 @@ describe('web e2e: shipped right Sidebar', () => {
expect(tripwire.warnings).toEqual([])
}, 60_000)
it('expands the collapsed column when a floating panel\'s dock control returns its tab', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-dock-expands'))
const column = await resetSidebar(page)
const floats = page.locator('[data-sidebar-right-session]:not([hidden]) [data-dockkit-float]')
const open = column.locator('[data-sidebar-right-open]')
const selected = (): Promise<string[]> =>
column.locator('[role="tab"][aria-selected="true"] [data-dockkit-tab-title]').allTextContents()
await proseChip(page).click()
await expect.poll(async () => await tabTitles(paneAt(column, 0))).toEqual(['Files', SAMPLE_NAME])
await floatByDrag(page, paneAt(column, 0).locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }))
await expect.poll(async () => await floats.count()).toBe(1)
// The docked column still holds Files while collapsed.
await column.locator('[data-sidebar-right-toggle]').click()
await expect.poll(async () => await open.count()).toBe(0)
await floats.first().locator('[data-dockkit-float-dock]').click()
await expect.poll(async () => await open.count()).toBe(1)
expect(await floats.count()).toBe(0)
await expect.poll(async () => await tabTitles(column)).toEqual(['Files', SAMPLE_NAME])
await expect.poll(selected).toEqual([SAMPLE_NAME])
// Closing the column's only other tab collapses it empty; docking fills it with the tab alone.
await floatByDrag(page, column.locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }))
await expect.poll(async () => await floats.count()).toBe(1)
const files = column.locator('[data-dockkit-tab]').filter({ hasText: 'Files' })
await files.hover()
await files.locator('[data-dockkit-tab-close]').click()
await expect.poll(async () => await open.count()).toBe(0)
await floats.first().locator('[data-dockkit-float-dock]').click()
await expect.poll(async () => await open.count()).toBe(1)
expect(await floats.count()).toBe(0)
await expect.poll(async () => await tabTitles(column)).toEqual([SAMPLE_NAME])
await expect.poll(selected).toEqual([SAMPLE_NAME])
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 60_000)
it('drops a pane whose last tab closes, and follows the last-tab rule on the surface', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-settle'))
const column = await resetSidebar(page)
+3
View File
@@ -89,6 +89,8 @@
"tests/details-session-lifecycle.e2e.ts",
"tests/sessionless-header.e2e.ts",
"tests/document-preview.e2e.ts",
"tests/file-browser.e2e.ts",
"tests/file-browser-composition.e2e.ts",
"tests/excel-opc.e2e.ts",
"tests/office-fixture.ts",
"tests/sidebar-browser.e2e.ts",
@@ -96,6 +98,7 @@
"tests/plugin-config.e2e.ts",
"tests/input-plugin-extensions.e2e.ts",
"tests/plugin-manager.e2e.ts",
"tests/plugin-market-toolbar.e2e.ts",
"tests/plugin-sticky-motion.e2e.ts",
"tests/entry-customization.e2e.ts",
"tests/cua-optin.e2e.ts",
@@ -1,4 +1,4 @@
/** Required performance budgets for compiled Client history folding and tool preparation. */
/** Required performance budgets for compiled Client history folding and live Assistant updates. */
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
@@ -10,8 +10,8 @@ import {
ciTimeBudget,
PERFORMANCE_BUDGET_HEADROOM,
} from '../support/calibration.ts'
import { recordTimings, type BenchmarkCase } from '../support/scaling-report.ts'
import type { ConversationFoldWorkerReport, PreparingToolWorkerReport } from './conversation-fold.worker.client.ts'
import { recordPeakMemory, recordTimings, type BenchmarkCase } from '../support/scaling-report.ts'
import type { ConversationFoldWorkerReport, PreparingToolWorkerReport, StreamingAssistantWorkerReport } from './conversation-fold.worker.client.ts'
/** Replies in the folded window; each carries one reasoning block and one text block. */
const TURNS = 200
@@ -54,6 +54,20 @@ const PREPARING_CASES = [
{ tool: 'bash', characters: 128 * 1024, expectedMs: 150, expectedMb: 8 },
] as const
const STREAMING_CHARACTERS = 256 * 1024
const STREAMING_SAMPLES = 5
/** Reference-machine expectations; shared CI scaling applies only to elapsed time. */
const STREAMING_WORKLOADS = [
{ fragments: 4_096, expectedMs: 12 },
{ fragments: 16_384, expectedMs: 32 },
] as const
const STREAMING_CASES = STREAMING_WORKLOADS.flatMap(workload => [
{ ...workload, blockType: 'text', pattern: 'ordinary' },
{ ...workload, blockType: 'reasoning', pattern: 'leading-whitespace' },
] as const)
/** Incremental heap while the Assembler, live entries, and published Chat state remain reachable. */
const EXPECTED_STREAMING_RETAINED_MB = 16
const WORKER = join(
import.meta.dirname,
'..',
@@ -127,3 +141,39 @@ describe('preparing tool arguments', () => {
expect(retainedMb).toBeLessThanOrEqual(budgetMb)
})
})
describe('live Assistant text publication', () => {
it.each(STREAMING_CASES)('publishes $fragments $blockType deltas with $pattern content within bounded CPU and heap costs', async (workload) => {
const samples: StreamingAssistantWorkerReport[] = []
for (let index = 0; index < STREAMING_SAMPLES; index++) {
const run = await runBuiltBenchmarkWorker<StreamingAssistantWorkerReport>({
worker: WORKER,
args: ['streaming', workload.blockType, workload.pattern, String(STREAMING_CHARACTERS), String(workload.fragments)],
exposeGc: true, timeoutMs: WORKER_TIMEOUT_MS,
})
expect(run.timedOut, run.stderr).toBe(false)
expect(run.signal, run.stderr).toBeNull()
expect(run.exitCode, run.stderr).toBe(0)
const report = requireReport(run)
expect(report.characters).toBe(STREAMING_CHARACTERS)
expect(report.fragments).toBe(workload.fragments)
samples.push(report)
}
const median = samples.toSorted((a, b) => a.elapsedMs - b.elapsedMs)[Math.floor(STREAMING_SAMPLES / 2)]!
const retainedMb = samples.map(sample => sample.retainedMb).toSorted((a, b) => a - b)[Math.floor(STREAMING_SAMPLES / 2)]!
const budgetMs = ciTimeBudget(workload.expectedMs)
const budgetMb = EXPECTED_STREAMING_RETAINED_MB * PERFORMANCE_BUDGET_HEADROOM
const benchmarkCase: BenchmarkCase = {
id: `conversation-fold/streaming-${workload.blockType}-${workload.pattern}-${String(workload.fragments)}`,
measures: `Client append, final Chat publication, and complete text read for ${String(STREAMING_CHARACTERS / 1024)} KiB in ${String(workload.fragments)} ${workload.blockType} deltas with ${workload.pattern} content.`,
affects: 'Local processing of a long active model response; excludes model pacing, network transport, and browser rendering.',
}
console.log(JSON.stringify({ benchmark: benchmarkCase.id, samples, medianMs: median.elapsedMs, retainedMb, budgetMs, budgetMb }))
recordTimings(benchmarkCase, {
medianMs: { ms: median.elapsedMs, cpuMs: median.cpuUserMs + median.cpuSystemMs },
}, { medianMs: budgetMs })
recordPeakMemory(benchmarkCase, { peakRssMb: Math.max(...samples.map(sample => sample.maxRssMb)) })
expect(median.elapsedMs).toBeLessThanOrEqual(budgetMs)
expect(retainedMb).toBeLessThanOrEqual(budgetMb)
})
})
@@ -1,4 +1,4 @@
/** Compiled worker for Client history folding and live tool preparation. */
/** Compiled worker for Client history folding, live Assistant text, and tool preparation. */
import { performance } from 'node:perf_hooks'
import { Context } from '@deepseek-ai/cordis'
@@ -57,6 +57,20 @@ export interface PreparingToolWorkerReport {
readonly detail: string | undefined
}
/** One live Assistant block folded and read through the published Chat snapshot. */
export interface StreamingAssistantWorkerReport {
readonly blockType: 'text' | 'reasoning'
readonly pattern: 'ordinary' | 'leading-whitespace'
readonly characters: number
readonly fragments: number
readonly checksum: number
readonly elapsedMs: number
readonly cpuUserMs: number
readonly cpuSystemMs: number
readonly retainedMb: number
readonly maxRssMb: number
}
function registerEventDefinitions(ctx: Context): ConversationEventRegistry {
const events = new ConversationEventRegistry(ctx)
// Only the service holder is an adapter; registrations and routing use production code.
@@ -257,6 +271,74 @@ function preparingTool(tool: 'write' | 'bash', characters: number, definitions:
}
}
function streamingAssistant(
blockType: StreamingAssistantWorkerReport['blockType'],
pattern: StreamingAssistantWorkerReport['pattern'],
characters: number,
fragments: number,
definitions: ConversationEventRegistry,
): StreamingAssistantWorkerReport {
if (globalThis.gc === undefined) throw new Error('streaming benchmark requires --expose-gc')
if (characters % fragments !== 0) throw new Error('streaming characters must divide evenly into fragments')
const chunkSize = characters / fragments
const payload = pattern === 'ordinary'
? 'abcdefghijklmno '.repeat(Math.ceil(characters / 16)).slice(0, characters)
: ' '.repeat(characters / 2) + 'abcdefghijklmno '.repeat(Math.ceil(characters / 32)).slice(0, characters / 2)
const attemptId = LlmAttemptId('streaming-benchmark')
const live = (index: number, chunk: StreamChunk): SessionEventLikeEntry => ({
type: 'transient',
event: {
type: 'assistant/live-chunk', seq: 2 + index / (fragments + 1), time: TIME_ZERO + index,
data: { turn: 1, step: 1, attemptId, chunk },
},
})
const chunks: SessionEventLikeEntry[] = []
for (let index = 0; index < fragments; index++) {
chunks.push(live(index + 1, {
type: blockType === 'text' ? 'text-delta' : 'reasoning-delta', index: 0,
text: payload.slice(index * chunkSize, (index + 1) * chunkSize),
}))
}
const assembler = new ConversationNodeAssembler(definitions, new BenchViewDefinitions())
assembler.replaceWindow([
entry(0, 'turn/start', { turn: 1 }),
entry(1, 'step/start', { turn: 1, step: 1 }),
live(0, { type: 'block-start', index: 0, blockType }),
], false)
assembler.activateTarget('chat')
globalThis.gc()
const before = process.memoryUsage().heapUsed
const start = performance.now()
const cpuStart = process.cpuUsage()
for (const chunk of chunks) assembler.append(chunk)
assembler.flush()
const text = streamingText(assembler, blockType)
// Consuming every character includes any deferred string materialization in the endpoint.
let checksum = 0
for (let index = 0; index < text.length; index++) checksum += text.charCodeAt(index)
const cpu = process.cpuUsage(cpuStart)
const elapsedMs = performance.now() - start
if (text !== payload) throw new Error('streaming benchmark did not publish the complete Assistant text')
globalThis.gc()
const retainedMb = (process.memoryUsage().heapUsed - before) / 1048576
// This read keeps the complete owner graph reachable across endpoint collection.
if (streamingText(assembler, blockType) !== text) throw new Error('streaming benchmark lost its published Assistant text')
return {
blockType, pattern, characters: text.length, fragments, checksum,
elapsedMs, cpuUserMs: cpu.user / 1000, cpuSystemMs: cpu.system / 1000,
retainedMb, maxRssMb: process.resourceUsage().maxRSS / 1024,
}
}
function streamingText(assembler: ConversationNodeAssembler, blockType: StreamingAssistantWorkerReport['blockType']): string {
const node = assembler.get('chat')?.nodes.values().find((candidate): candidate is ChatNode<'assistant-step'> => candidate.kind === 'assistant-step')
const block = node?.data.blocks[0]
if (block?.kind !== blockType || (block.kind !== 'text' && block.kind !== 'reasoning')) {
throw new Error('streaming benchmark did not create its Assistant block')
}
return block.text
}
assertBuiltBenchmarkRuntime(import.meta.url, {
'@deepseek-ai/dsh-brand': import.meta.resolve('@deepseek-ai/dsh-brand'),
'@deepseek-ai/dsh-client-store': import.meta.resolve('@deepseek-ai/dsh-client-store'),
@@ -273,6 +355,16 @@ try {
if (tool !== 'write' && tool !== 'bash') throw new Error('preparing benchmark tool must be write or bash')
const report = preparingTool(tool, positiveInteger(process.argv[4], 'characters'), definitions)
process.stdout.write(`${JSON.stringify(report)}\n`)
} else if (process.argv[2] === 'streaming') {
const blockType = process.argv[3]
const pattern = process.argv[4]
if (blockType !== 'text' && blockType !== 'reasoning') throw new Error('streaming block type must be text or reasoning')
if (pattern !== 'ordinary' && pattern !== 'leading-whitespace') throw new Error('streaming pattern must be ordinary or leading-whitespace')
const report = streamingAssistant(
blockType, pattern, positiveInteger(process.argv[5], 'characters'),
positiveInteger(process.argv[6], 'fragments'), definitions,
)
process.stdout.write(`${JSON.stringify(report)}\n`)
} else {
const [turnsValue, smallDeltasValue, largeDeltasValue, attemptsValue] = process.argv.slice(2)
const turns = positiveInteger(turnsValue, 'turns')
+1
View File
@@ -55,6 +55,7 @@ Application listeners are outside this package matrix. Desktop Host subscribes t
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
| `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:35`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
| `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:40`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
| `llm-pi-ai/request-headers` | `waterfall` | [`packages/llm/llm-pi-ai/src/index.ts:114`](../packages/llm/llm-pi-ai/src/index.ts) | [`llm-pi-ai`](../packages/llm/llm-pi-ai) (`waterfall`) | - |
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:24`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), `remotes` |
| `llm/models-updated` | `emit` | [`packages/llm/llm/src/types.ts:30`](../packages/llm/llm/src/types.ts) | [`llm-deepseek`](../packages/llm/llm-deepseek) (`emit`) | `remotes` |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
+1
View File
@@ -57,6 +57,7 @@
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
| `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:35`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
| `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:40`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
| `llm-pi-ai/request-headers` | `waterfall` | [`packages/llm/llm-pi-ai/src/index.ts:114`](../packages/llm/llm-pi-ai/src/index.ts) | [`llm-pi-ai`](../packages/llm/llm-pi-ai) (`waterfall`) | - |
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:24`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), `remotes` |
| `llm/models-updated` | `emit` | [`packages/llm/llm/src/types.ts:30`](../packages/llm/llm/src/types.ts) | [`llm-deepseek`](../packages/llm/llm-deepseek) (`emit`) | `remotes` |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
+2 -2
View File
@@ -9,8 +9,8 @@
en: 96b95cdb015c1af6
zh: 01e4144d91551c13
/client-modules/the-scan:
en: 811c0e8a3a370399
zh: 4a49a3999d35503a
en: f4d641734c47b129
zh: 670819fb119ae688
/client-modules/the-bundle-route-and-index-injection:
en: 82c089fbd9ffd535
zh: 30ac52d01b4fc074
+3 -1
View File
@@ -93,7 +93,9 @@ Initial publication and HMR derive each row's `rev` from the entry's mtime, ctim
A package joins the table by declaring `dsh.client` (`platform: 'web'`, optional `inject` edges, optional `immediately`) in its package.json and exporting its built bundle at `exports["./client"]`. Each live row resolves from its own Loader specifier and owning-tree `baseUrl`, through the same `loader.internal.resolveSync` implementation that imports its Host face when available. The nearest owning package manifest supplies the browser module id, so relative source and built overlays retain the package identity. Distinct active Loader sources resolving to one package name fail composition; after one source unloads, the surviving source supplies the row without a fiber restart.
Scanning is incremental per package; there is no full-rescan code path. Every cordis `internal/plugin` emission (fiber construction or disposal) marks the fiber's entry name dirty, and a microtask flush reconciles each dirty name against the live loader entries. The activation pass seeds the same dirty set with all current entries and flushes synchronously, so first scan and steady state share one implementation — with opposite failure postures. At activation, a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud `AggregateError` listing every broken package: the fiber FAILS and the boot's fail-loud sweep reports it. In steady state, a broken package logs a warning and must not poison the others.
Package reconciliation is incremental by Loader entry name. Every Cordis `internal/plugin` emission (fiber construction or disposal) marks the fiber's entry name dirty. Each nonempty flush enumerates all Loader entries once and indexes them by name at flush start; pending names use their indexed entries, including all same-name sources, and missing names use an empty list to remove unloaded sources. Package resolution must not synchronously add or remove Loader entries during reconciliation. Activation seeds all current names and flushes synchronously; later notifications queue a microtask flush.
At activation, malformed declarations, missing bundles, and flush enumeration failures aggregate into a composition error: the fiber fails and boot reports it. In steady state, package failures log warnings without preventing other names from being reconciled. An enumeration failure consumes the failed batch, logs one warning, and leaves package state and the published graph unchanged; each affected name waits for another notification before retrying.
Each reconciliation reads the package manifest and the bundle metadata again. A fiber restart from the same source that finds the same declaration and bundle metadata keeps its row and rev and reads no bundle bytes; a changed declaration, client export, or bundle is published by that reconciliation. A bundle rebuilt without an entry restart reaches the graph through `rebuilt()`.
+3 -1
View File
@@ -93,7 +93,9 @@ interface WebBootGraph {
包加入这张表的方式,是在自己的 package.json 中声明 `dsh.client`(`platform: 'web'`、可选的 `inject` 边、可选的 `immediately`),并在 `exports["./client"]` 导出构建好的 bundle。每个 live row 都从自己的 Loader specifier 与所属 tree `baseUrl` 解析;若 `loader.internal.resolveSync` 可用,则使用 Host face import 所用的同一个实现。最近归属的 package manifest 提供浏览器模块 id,因此相对 source 与 built overlay 仍保留包身份。若不同的 active Loader source 解析到同一包名,组合会失败;一个来源卸载后,仍存活的来源无需重启 fiber 即可提供该 row。
扫描是单包增量的;不存在全量重扫代码路径。fiber 构造或 dispose(资源释放)时的每次 cordis `internal/plugin` 发射都把该 fiber 的 entry 名标脏,一次微任务 flush 把每个脏名与实时 loader entry 对账。激活趟以全部当前 entry 灌入同一个脏集合并同步 flush,因此初扫与稳态共享一条实现——但失败姿态相反。激活时,已加载 entry 中的畸形声明或缺失 bundle 会聚合为一个大声的 `AggregateError`,列出每个损坏的包:该 fiber 进入 FAILED,由启动的大声失败 sweep 上报。稳态下,损坏的包只记录一条警告,且不得殃及其他包。
包按 Loader entry 名增量对账。fiber 构造或 dispose(资源释放)时的每次 Cordis `internal/plugin` 事件都会把该 fiber 的 entry 名标脏。每次非空 flush 在开始时枚举全部 Loader entries 一次并按名称建索引;待处理名称使用索引中的全部同名来源,缺失名称使用空列表移除已卸载的来源。包解析过程不得在对账期间同步添加或移除 Loader entries。激活阶段初始化全部当前名称并同步 flush;后续通知通过微任务执行 flush。
激活时,畸形声明、缺失 bundle 和 flush 枚举失败会聚合为组合错误:fiber 失败,由启动流程报告。稳态下,包失败只记录警告,不妨碍其他名称继续对账。枚举失败会消费失败批次、记录一条警告,并保持包状态和已发布的图不变;受影响的每个名称都要等另一次通知才会重试。
每次对账都会重新读取包 manifest 与 bundle 元数据。同一来源的 fiber 重启若读到相同的声明与 bundle 元数据,就保留其 row 与 rev,且不读取 bundle 字节;声明、client 导出或 bundle 发生变化时,由这次对账发布。没有伴随条目重启的 bundle 重建经 `rebuilt()` 到达图。
+3
View File
@@ -44,6 +44,9 @@
/llm-streaming/official-deepseek-request-extensions:
en: 42bc50394b070e96
zh: 007caa743ec3c790
/llm-streaming/pi-ai-request-headers:
en: 8c140f9c5a799f01
zh: f66b895594247e92
/llm-streaming/service-and-provider-contracts:
en: 8dc79d4cc82824c2
zh: 3ebabd3a117ad717
+33
View File
@@ -860,6 +860,10 @@ interface LlmCallConfigAdapterDefaults {
The [wire reference](../deepseek-llm-api-wire-extensions.md) defines the exact request headers, extension transaction, field versions, and receiver obligations. The shipped composition registers [`dsh_session_log`](../../packages/session/session-log-deepseek/README.md) as a lossless incremental canonical-log suffix and [`dsh_plugin_packages`](../../packages/llm/plugin-package-inventory-deepseek/README.md) as the complete active Loader-backed package set. These fields remain outside model messages and are absent from the pi-ai adapter path.
## Pi-ai request headers
`PiAiRequestHeaderContext` contains the prepared call's provider, model, resolved model `baseURL`, and optional Session id and auxiliary purpose. It is immutable, contains no messages or authentication fields, and accompanies a request-owned `Headers` object in the synchronous `llm-pi-ai/request-headers` waterfall. The [adapter's request-header documentation](../../packages/llm/llm-pi-ai/README.md#request-headers) defines contribution ownership, attribution precedence, and failure behavior. This extension point is specific to pi-ai inference requests.
## Service and provider contracts
`LlmAdapter` is the provider contract: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerRetryPolicy()` is captured per route with normal defaults, while `providerInfo()` and asynchronous `listModels()` feed `LlmRuntime.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. One asynchronous `resolveModel()` query returns exact model identity plus optional correctness-sensitive context capacity, an adapter-configured `defaultMaxTokens`, and ordered model-owned reasoning ids with an optional deployment default; absent fields mean unavailable metadata or provider-owned behavior, not invalid catalog membership. The resolver receives optional cancellation and must settle promptly after abort. `LlmRuntime.resolveModelInfo()` validates and detaches the aggregate. At the final adapter boundary, `resolveCallConfig()` materializes the output default only when `maxTokens` is absent and validates and materializes reasoning, so direct calls cannot bypass either configured behavior; direct dispatch captures one registration before awaiting that resolution. The agent loop instead uses `prepareCall()` to keep the same registration across model resolution, durable header logging, and dispatch, retain detached context metadata from that exact lookup, and report which config fields the adapter defaulted. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. AgentLoop observes a request attempt once the outer waterfall returns a stream handle; that limited boundary does not prove a lazy terminal adapter was constructed or began provider I/O. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. [architecture.md](../architecture.md#turn-flow) shows where `ctx.llm.stream()` and the `llm/stream` waterfall sit in one turn.
@@ -1248,4 +1252,33 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t
```
Source: [`packages/llm/llm/src/index.ts`](../../packages/llm/llm/src/index.ts)
<a id="llm-pi-ai-events"></a>
### `llm-pi-ai/*` events
<a id="llm-pi-airequest-headers--waterfall"></a>
#### `llm-pi-ai/request-headers` — waterfall
Prepare headers for one pi-ai inference call, excluding model discovery. Listeners run synchronously and must call next(); they may edit only the request-owned headers and must not retain them. Throws fail the call with REQUEST_HEADERS before inference I/O. Harness attribution wins afterward. Non-TypeError messages are user-visible and must exclude secrets. TypeError details and non-Error values are replaced with safe guidance.
```ts cordis-catalog
/**
* Prepare headers for one pi-ai inference call, excluding model discovery.
* Listeners run synchronously and must call next(); they may edit only the
* request-owned headers and must not retain them. Throws fail the call with
* REQUEST_HEADERS before inference I/O. Harness attribution wins afterward.
* Non-TypeError messages are user-visible and must exclude secrets. TypeError
* details and non-Error values are replaced with safe guidance.
* @mode waterfall
* @param request - immutable routing metadata from the exact prepared call.
* @param headers - fresh profile headers, shared only within this dispatch.
* @param next - continue header preparation through the remaining listeners.
* @returns headers passed to pi-ai after Harness attribution is applied.
*/
'llm-pi-ai/request-headers'(request: PiAiRequestHeaderContext, headers: Headers, next: () => Headers): Headers
```
Source: [`packages/llm/llm-pi-ai/src/index.ts`](../../packages/llm/llm-pi-ai/src/index.ts)
<!-- END GENERATED cordis-surface -->
+33
View File
@@ -866,6 +866,10 @@ interface LlmCallConfigAdapterDefaults {
[协议参考](../deepseek-llm-api-wire-extensions.zh.md)定义确切的请求标头、扩展事务、字段版本和接收方义务。随附组合会将 [`dsh_session_log`](../../packages/session/session-log-deepseek/README.zh.md) 注册为无损增量权威日志后缀,并将 [`dsh_plugin_packages`](../../packages/llm/plugin-package-inventory-deepseek/README.zh.md) 注册为完整存活 Loader 包集合。这些字段仍位于模型消息之外,也不会进入 pi-ai 适配器路径。
## Pi-ai 请求头
`PiAiRequestHeaderContext` 包含已准备调用的 provider、model、解析后的模型 `baseURL`,以及可选 Session id 和辅助用途。它不可变且不含消息或认证字段,与本次请求独有的 `Headers` 一起传入同步的 `llm-pi-ai/request-headers` waterfall。[适配器的请求头文档](../../packages/llm/llm-pi-ai/README.zh.md#request-headers)定义贡献的所有权、应用标识的优先级与失败行为。此扩展点仅适用于 pi-ai 推理请求。
## 服务与提供方约定
`LlmAdapter` 是提供方约定:创建子类、实现 `stream()`,再用 `ctx.llm.registerAdapter(providers, adapter)` 注册一个适配器实例。`GenerateOptions.provider` 选择已注册适配器;`GenerateOptions.model` 会传给该适配器,无需在生命周期启动时注册。重复提供方路由会原子失败。可选的 `providerRetryPolicy()` 会按路由捕获并填入 normal 默认值,`providerInfo()` 与异步 `listModels()` 方法则为 `LlmRuntime.listProviders()` / `listModels()` 提供分离的 selector 元数据。该目录仅供参考,不是请求白名单:适配器仍是权威,并可接受未列出的模型 id。单次异步 `resolveModel()` 查询返回确切模型身份,以及可选的对正确性敏感的上下文容量、适配器配置的 `defaultMaxTokens`、由模型持有的有序推理强度 ID 和可选的部署默认值;字段缺失表示元数据不可用或保留提供方持有的行为,而不表示目录成员关系无效。解析器会接收可选的取消信号,并且必须在信号中止后迅速完成结算。`LlmRuntime.resolveModelInfo()` 会校验聚合结果并返回分离值。在最终适配器边界,`resolveCallConfig()` 仅在 `maxTokens` 缺失时填入输出默认值,并校验和填入推理强度,因此直接调用也无法绕过任何一项已配置行为;直接分派会在等待解析前捕获一项适配器注册。agent loop 则使用 `prepareCall()`,使模型解析、请求头持久记录和分派全程使用同一项注册,保留来自同一次查询的分离上下文元数据,并报告适配器填入的配置字段。适配器查找发生在 `llm/stream` waterfall 的终端 continuation,因此 listener 可以在查找前短路调用,或路由一个可变的一次性请求。AgentLoop 在外层 waterfall 返回流句柄时观察到一次请求尝试;这个有限边界不能证明惰性终端适配器已构造完成或开始提供方 I/O。`block-start` / `block-end` 的 `index` 关联与 assembler 共同意味着适配器只需 emit 格式正确的分片——块重组不是每个适配器各自的问题。`ctx.llm.stream()` 与 `llm/stream` waterfall 在一个轮次中的位置见 [architecture.md](../architecture.zh.md#turn-flow)。
@@ -1254,4 +1258,33 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t
```
Source: [`packages/llm/llm/src/index.ts`](../../packages/llm/llm/src/index.ts)
<a id="llm-pi-ai-events"></a>
### `llm-pi-ai/*` events
<a id="llm-pi-airequest-headers--waterfall"></a>
#### `llm-pi-ai/request-headers` — waterfall
Prepare headers for one pi-ai inference call, excluding model discovery. Listeners run synchronously and must call next(); they may edit only the request-owned headers and must not retain them. Throws fail the call with REQUEST_HEADERS before inference I/O. Harness attribution wins afterward. Non-TypeError messages are user-visible and must exclude secrets. TypeError details and non-Error values are replaced with safe guidance.
```ts cordis-catalog
/**
* Prepare headers for one pi-ai inference call, excluding model discovery.
* Listeners run synchronously and must call next(); they may edit only the
* request-owned headers and must not retain them. Throws fail the call with
* REQUEST_HEADERS before inference I/O. Harness attribution wins afterward.
* Non-TypeError messages are user-visible and must exclude secrets. TypeError
* details and non-Error values are replaced with safe guidance.
* @mode waterfall
* @param request - immutable routing metadata from the exact prepared call.
* @param headers - fresh profile headers, shared only within this dispatch.
* @param next - continue header preparation through the remaining listeners.
* @returns headers passed to pi-ai after Harness attribution is applied.
*/
'llm-pi-ai/request-headers'(request: PiAiRequestHeaderContext, headers: Headers, next: () => Headers): Headers
```
Source: [`packages/llm/llm-pi-ai/src/index.ts`](../../packages/llm/llm-pi-ai/src/index.ts)
<!-- END GENERATED cordis-surface -->
+8 -8
View File
@@ -15,11 +15,11 @@
en: b7b8a7b411aee94d
zh: 7298c20000945d97
/right-sidebar/navigation-ctx-sidebarright:
en: c695e9aca92271f2
zh: 4b4d820c5a16bed1
en: 2a2b3a33df9e0749
zh: 629c791cd2ae50ba
/right-sidebar/slots-and-owner-props:
en: f58dc6ff6b0027a7
zh: 707722fcbd096efc
en: 6b5faaed58ab5fb1
zh: 2118439d3403a735
/right-sidebar/document-renderers:
en: 2bf47a890fce175e
zh: 45f81c579b8939a8
@@ -30,8 +30,8 @@
en: 2b1b49dbba9cc9ee
zh: 13d2a45224743b37
/right-sidebar/shipped-types:
en: 81cc0928abeff74b
zh: 370fcfd5bb350ed9
en: f8f0e368701f6669
zh: de364aaf3345d70c
/right-sidebar/not-built:
en: 65e86c293eb99242
zh: 852dfe7713848453
en: 5cf5ab52c8007897
zh: 346490f01e318d95
+10 -9
View File
@@ -75,22 +75,23 @@ export function apply(ctx: Context): void {
## Navigation: `ctx.sidebarRight`
Two opens are the navigation controller, and every way into the column calls one of them: `openResource(address, options?)` for a `dsh-resource://` address — the conversation's file links, a tool row's line reference, a file tree's rows — and `openTab(kind, options?)` for a page — the strip's add control, a guide entry box, or an HTTP(S) link in Assistant Markdown. Both run four steps as one history entry — claim (the registry ranks the resource's types, or the named `kind`'s implementation in force answers); focus a tab already showing the same `(kind, address)`; otherwise seat a new tab; expand the column — and then record the navigation in the Tab domain ([service](../../packages/client/ui-sidebar-right/README.md#ctxsidebarright)). Content the user cannot see is not opened, so a collapsed column expands in the same step. `openResource` throws for an address outside `dsh-resource://` or one no type claims; `openTab` throws for a kind nothing registered: both are wiring mistakes, not user errors.
Two opens are the navigation controller, and every way into the column calls one of them: `openResource(address, options?)` for a `dsh-resource://` address — the conversation's file links, a tool row's line reference, a file tree's rows — and `openTab(kind, options?)` for a page — the strip's add control, a guide entry box, or an HTTP(S) link in Assistant Markdown. Both run four steps as one history entry — claim (the registry ranks the resource's types, or the named `kind`'s implementation in force answers); focus a tab already showing the same `(kind, address)`; otherwise seat a new tab; expand the column — and then record the navigation in the Tab domain ([service](../../packages/client/ui-sidebar-right/README.md#ctxsidebarright)). Content the user cannot see is not opened, so a collapsed column expands in the same step, unless the open replaces a floating tab or reveals a tab in a floating panel. A reveal of a tab already on screen makes the pane holding it glow once, for every tab type, and only while its Session is on screen; a reveal that switches tabs or expands the column does not glow. `openResource` throws for an address outside `dsh-resource://` or one no type claims; `openTab` throws for a kind nothing registered: both are wiring mistakes, not user errors.
| Option | Meaning |
|---|---|
| `paneId` | Land a new tab in this pane; default is the active docked pane (the first docked pane while a floating pane is active). |
| `replaceTab` | Take this tab's pane and strip slot, closing it in the same step; a floating tab lends no place, so the new tab lands as if unplaced. |
| `replaceTab` | New content takes this tab's place and the tab closes in the same step: a docked tab's pane and strip slot, or a floating tab's panel, keeping its pane id and rectangle. A reveal keeps the tab open ([semantics](../../packages/client/ui-sidebar-right/README.md#ctxsidebarright)). |
| `revealIfOpened` | Default `true`: a tab already showing the same `(kind, address)` is focused and handed `params`. `false` opens another tab regardless. |
| `preferNewPane` | Prefer a new docked pane under the normal pane budget and room rule; fall back to the target pane when it cannot split. Ignored with `replaceTab`. |
| `destination` (`openResource` only) | `'sidebar'` or `'float'`: settle in the active docked pane (the first docked pane while a floating pane is active) or a new floating panel, ignoring the four options above. A copy already there is revealed; else a copy in the other host moves there with its tab id and body state; else a new tab opens. `'float'` leaves the column's expansion unchanged, for a new tab as for a move ([semantics](../../packages/client/ui-sidebar-right/README.md#ctxsidebarright)). |
| `kind` (`openResource` only) | Name the opening type instead of ranking claims; its implementation in force opens the address, and its `canOpen` still applies. |
| `params` | Navigation parameters for the body, delivered as `navigation.params`. `openResource` types them by resource type through the merge-extensible `SidebarRightResourceParamsMap` (the text preview declares `{ line?: number }`); `openTab<K>` types them by kind through `SidebarRightTabParamsMap`, `undefined` for a kind that declares none; a body reads `SidebarRightNavigationParams`, the union of both. Values are JSON-shaped by convention and not validated at run time. |
Placement is the caller's option, never a type's property. The conversation calls `openResource(fileAddressFor(sessionId, cwd, path))` and, from a `read` tool row, adds `{ params: { line } }` from the call's 1-based `offset`; a guide entry box calls `tab.actions.openTab(entry.kind, { replaceTab: true })`; a file-tree row calls `tab.actions.openResource(address)`; the strip's add control calls `openTab('guide', { paneId, revealIfOpened: false })`.
Placement belongs to the caller, not the tab type. The [files navigator reference](../../packages/client/ui-sidebar-files/README.md#the-navigator) defines its host-owned opening callbacks; the [guide reference](../../packages/client/ui-sidebar-right/README.md#the-guide) defines guide replacement and explicit close.
`close(tabId)` closes a tab; `hide(tabId)` closes a floating tab or collapses its docked column; `active()` returns the active pane's active tab; `isExpanded()` and `toggleExpanded()` read and flip the column, the flip recorded in the sequence. Reads answer for the no-Session case with `undefined` or `false`; writes need a mounted Session surface and throw without one rather than write into a surface nobody draws.
`focus(tabId)` makes a tab its pane's active tab; `split(paneId?)` splits the active docked pane, or the named one, and returns the new pane's id — or `undefined`, recording nothing, when the pane budget or the column's width forbids a split; `float(tabId, rect?)` lifts a tab into a floating pane; `dock(paneId)` returns a floating pane to the docked area. All four run the store's existing actions and record one history entry each; a target that does not exist or is already in the requested state is a no-op, and like `open` they throw without a mounted Session surface. `TabId`, `PaneId`, `TabRecord`, and `FloatRect` are re-exported from the package's `/client` entry so a caller needs no dockkit import.
`focus(tabId)` makes a tab its pane's active tab; `split(paneId?)` splits the active docked pane, or the named one, and returns the new pane's id — or `undefined`, recording nothing, when the pane budget or the column's width forbids a split; `float(tabId, rect?)` lifts a tab into a floating pane; `dock(paneId)` returns a floating pane to the docked area and expands the column. All four run the store's existing actions and record one history entry each; a target that does not exist or is already in the requested state is a no-op, and like `open` they throw without a mounted Session surface. `TabId`, `PaneId`, `TabRecord`, and `FloatRect` are re-exported from the package's `/client` entry so a caller needs no dockkit import.
`focusedTarget(element?)` captures the visible page under live DOM focus, including an embedding iframe; outside or stale sidebar markup yields no target. `commandTarget(element?)` also permits the mounted Session's active dock pane for an open initiated outside the sidebar. A `SidebarRightTarget` captures Session, pane, host, tab occurrence, and navigation revision. `isTargetCurrent(target)` checks that identity before execution, so reopening a record or navigating it invalidates a captured action; it never retargets to another page. See the [controller methods](../../packages/client/ui-sidebar-right/src/client/service.ts) for close and placement outcomes.
@@ -106,9 +107,9 @@ The Sidebar declares four extension slots; its document tab declares the additio
| `sidebar.right.tab.menu.item` | list, Session scope | Content-level actions appended after the kit's own layout actions. An item that acts must call the owner's `dismiss()`. |
| `sidebar.right.tab.document` | keyed by the document implementation's `id`, Session scope | The selected file renderer inside the document tab; the parent owns shared loading and toolbar controls. |
A body, title and guide replacement receive the framework-injected `useTabInfo()`. It returns `{ sidebar, panel, tab }`: `sidebar` holds `expanded` and `fullscreen`, `panel.id` names the containing pane, and `tab` contains its record fields plus `visible`, `navigation`, `signal`, and `actions`. All tabs require a foreground Session. Docked bodies also require expansion and selection; docked titles require expansion; foreground floats survive collapse. `signal` aborts when the record disappears or the plugin unloads, not on hiding or Session switching. `tab.actions` provides `openResource`, `openTab`, and `close`, bound to the tab's own Session. Open placement defaults to its current pane; `revealIfOpened` defaults to `true`, and `replaceTab: true` replaces this record in the same history entry. Menu entries retain plain `tab` and `dismiss` owner parameters.
A body, title and guide replacement receive the framework-injected `useTabInfo()`. It returns `{ sidebar, panel, tab }`: `sidebar` holds `expanded` and `fullscreen`, `panel.id` names the containing pane and the required `panel.isFloating: boolean` distinguishes a floating panel from a docked pane. Both update through the hook when a tab moves, while `tab.id` stays unchanged. `tab` also contains its other record fields plus `visible`, `navigation`, `signal`, and `actions`. All tabs require a foreground Session. Docked bodies also require expansion and selection; docked titles require expansion; foreground floats survive collapse. `signal` aborts when the record disappears or the plugin unloads, not on hiding or Session switching. `tab.actions` provides `openResource`, `openTab`, and `close`, bound to the tab's own Session. Opens return `void` and default to its current docked pane, or the active docked pane when this tab floats; `revealIfOpened` defaults to `true`, and `replaceTab: true` lets new content replace this record in the same history entry; an `openResource` with `destination` passes neither this tab's pane nor this record. Menu entries retain plain `tab` and `dismiss` owner parameters.
`navigation.revision` increments on every navigation to the tab whether or not `params` changed, so a body can act on "navigated again" alone; it is `1` for a tab opened by address and `0` for a record nobody opened by address — a seeded guide, or a tab restored by undo. The Tab domain holds one occurrence per open record: a record that appears is pinned in the resource model, so switching tabs unmounts a body without dropping its content; a record that vanishes is aborted and dropped; a record restored by undo is a new occurrence ([Tab domain](../../packages/client/ui-sidebar-right/README.md#the-tab-domain)).
`navigation.revision` increments on every navigation to the tab whether or not `params` changed, so a body can act on "navigated again" alone; it is `1` for a tab opened by address and `0` for a record nobody opened by address — a seeded guide, or a tab restored by reload or undo. The Tab domain holds one occurrence per open record: a record that appears is pinned in the resource model, so switching tabs unmounts a body without dropping its content; a record that vanishes is aborted and dropped; a record restored by undo is a new occurrence ([Tab domain](../../packages/client/ui-sidebar-right/README.md#the-tab-domain)).
`tab.actions.bindCommands(commands)` contributes `SidebarRightTabCommands`, currently an optional `refresh` callback, for the mounted body. Omitted operations are unavailable; the returned disposer releases that registration without removing a newer body's commands. The tab lifetime also releases them. `tab.refreshShortcut` supplies the effective refresh catalog entry for page controls; it is optional when no entry is available. These fields are declared in the [slot owner types](../../packages/client/ui-sidebar-right/src/client/contract/slots.ts).
@@ -141,15 +142,15 @@ The Host `ctx.workspaceFiles` service and generated `workspaceFiles` Remote name
## Shipped types
- **`guide`** — `builtin`, opened as `openTab('guide')`. A muted compass sits above one capsule per contributed `guide` entry, in `order`; short lists show registered descriptions, and every missing icon uses the shipped placeholder. Picking a capsule opens the contributing type as a page in the guide tab's place. A pane holds at most one guide tab, and the strip's add control appears only while its pane has none. A new pane receives the registered default page: the sole guide entry directly, or the guide when the entry count is not one ([guide](../../packages/client/ui-sidebar-right/README.md#the-guide)).
- **`text`** — `fallback`, `dsh-resource://file/**`, claiming Session addresses only. Document Preview observes metadata through `useResource<'file'>`, loads content through Remote callbacks, and owns renderer selection, the toolbar, per-tab refresh, scroll, and source navigation; unknown extensions render as plain text ([README](../../packages/client/ui-sidebar-documentpreview/README.md)).
- **`files`** — `builtin`, opened as `openTab('files')`. The workspace directory tree, listed lazily through `list` and watched by expanded directory, opening a file with `tab.actions.openResource(fileAddressFor(sessionId, root, path))` into its own pane ([README](../../packages/client/ui-sidebar-files/README.md)).
- **`text`** — `fallback`, `dsh-resource://file/**`, claiming Session addresses only. Document Preview observes metadata through `useResource<'file'>`, loads content through Remote callbacks, and owns renderer selection, the toolbar, per-tab refresh, scroll, and source navigation; unknown extensions render as plain text. It directly renders the files plugin's `sidebar.files.navigator` and `sidebar.files.navigator.toggle` Factories in docked and floating panes; unregistered Factories render nothing ([README](../../packages/client/ui-sidebar-documentpreview/README.md)).
- **`files`** — `builtin`, opened as `openTab('files')`. The files page and navigator share one workspace tree per pane. [The package reference](../../packages/client/ui-sidebar-files/README.md) owns lazy listings, watches, opening callbacks, and navigation-driven visibility.
- **`browser`** — multi-instance `builtin`, opened as `openTab('browser', { params: { url? } })`. Chat's [link preference](../../packages/client/ui-chat/README.md) controls whether Assistant Markdown opens HTTP(S) links here. It accepts public and loopback HTTP(S) targets under the default sandbox, rejects local files in favor of Document Preview, and uses application-known iframe history ([README](../../packages/client/ui-sidebar-browser/README.md)).
- **`subagentchat`** — `builtin`, `dsh-resource://subagentchat/session/<child>?parent=<parent>&mode=<mode>`. An explicitly addressed subagent Conversation retained by the resource provider and rendered through the shared Conversation Factory ([README](../../packages/client/ui-subagent/README.md)).
<a id="not-built"></a>
## Not built
- Persistence: layout state is memory-only; a reload starts every session collapsed, and no session's tabs are visible from another.
- Cross-device layout synchronization: layouts are browser-local, with separate saved state per Session ([state](../../packages/client/ui-sidebar-right/README.md#state)).
- A read-only layout snapshot or subscription on `ctx.sidebarRight`: the service exposes operations only, and dockkit's `LayoutState`/`LayoutOp` are internal.
- A capability-discovery array (`features`) on the service.
- An `option` priority band for tab types: nothing lists a tab type without letting it claim.
+10 -9
View File
@@ -75,22 +75,23 @@ export function apply(ctx: Context): void {
## 导航:`ctx.sidebarRight`
两种打开构成导航控制器,进入这一列的每条路都调用其一:`openResource(address, options?)` 打开 `dsh-resource://` 地址——会话区的文件链接、工具行的行号引用、文件树的行;`openTab(kind, options?)` 打开页面——tab 条的新增控件、引导页入口框或 Assistant Markdown 中的 HTTP(S) 链接。两者都以一条历史记录走完四步——认领(注册表为资源排候选,或点名 `kind` 的生效实现应答);聚焦已显示同一 `(kind, address)` 的 tab;否则落一个新 tab;展开这一列——然后把导航记入 Tab 域([服务](../../packages/client/ui-sidebar-right/README.zh.md#ctxsidebarright))。用户看不见的内容不算打开,所以折叠的列会在同一步展开。`openResource` 对 `dsh-resource://` 之外的地址或无人认领的地址抛错;`openTab` 对无人注册的 kind 抛错:二者都是接线错误,不是用户错误。
两种打开构成导航控制器,进入这一列的每条路都调用其一:`openResource(address, options?)` 打开 `dsh-resource://` 地址——会话区的文件链接、工具行的行号引用、文件树的行;`openTab(kind, options?)` 打开页面——tab 条的新增控件、引导页入口框或 Assistant Markdown 中的 HTTP(S) 链接。两者都以一条历史记录走完四步——认领(注册表为资源排候选,或点名 `kind` 的生效实现应答);聚焦已显示同一 `(kind, address)` 的 tab;否则落一个新 tab;展开这一列——然后把导航记入 Tab 域([服务](../../packages/client/ui-sidebar-right/README.zh.md#ctxsidebarright))。用户看不见的内容不算打开,所以折叠的列会在同一步展开;替换浮动 tab、或揭示浮窗中的 tab 时除外。揭示一个已在屏幕上显示的 tab 时,存放它的格发光提示一次,对所有 tab 类型都一样,且只在其会话位于屏幕上时播放;需要切换 tab 或展开该列的揭示不发光。`openResource` 对 `dsh-resource://` 之外的地址或无人认领的地址抛错;`openTab` 对无人注册的 kind 抛错:二者都是接线错误,不是用户错误。
| 选项 | 含义 |
|---|---|
| `paneId` | 新 tab 落到这个 pane;缺省为活动的停靠 pane(活动的是浮窗时取第一个停靠 pane)。 |
| `replaceTab` | 占用这个 tab 的 pane 与条上位置,并在同一步关闭它;浮窗里的 tab 让不出位置,新 tab 按未指定位置落位。 |
| `replaceTab` | 新内容取代这个 tab 的位置,并在同一步关闭它:停靠 tab 让出 pane 与条上位置,浮动 tab 让出浮窗,新 tab 沿用其 pane id 与矩形。揭示时该 tab 保持打开([语义](../../packages/client/ui-sidebar-right/README.zh.md#ctxsidebarright))。 |
| `revealIfOpened` | 缺省 `true`:已显示同一 `(kind, address)` 的 tab 被聚焦并收到 `params`。`false` 则无论如何再开一个。 |
| `preferNewPane` | 在普通格数预算与空间规则下优先新建停靠格;不能分栏时回退到目标格。与 `replaceTab` 一起使用时忽略。 |
| `destination`(仅 `openResource`) | `'sidebar'` 或 `'float'`:落在活跃停靠格(活动格是浮窗时为第一个停靠格)或新浮窗,忽略上面四个选项。该处已有副本时揭示它;否则把另一宿主中的副本连同 tab id 与正文状态移过去;否则新开 tab。`'float'` 新开和移动都不改变该列的展开状态([语义](../../packages/client/ui-sidebar-right/README.zh.md#ctxsidebarright))。 |
| `kind`(仅 `openResource`) | 点名打开类型而不排候选;该 kind 的生效实现打开地址,它的 `canOpen` 仍生效。 |
| `params` | 给正文的导航参数,作为 `navigation.params` 送达。`openResource` 按资源类型经声明合并表 `SidebarRightResourceParamsMap` 定型(文本预览声明 `{ line?: number }`);`openTab<K>` 按 kind 经 `SidebarRightTabParamsMap` 定型,未声明的 kind 为 `undefined`;正文读到的是二者联合 `SidebarRightNavigationParams`。值按约定为 JSON 形状,运行时不校验。 |
落位是调用方的选项,从不是类型的属性。会话区调 `openResource(fileAddressFor(sessionId, cwd, path))`,`read` 工具行另加 `{ params: { line } }`(来自调用的 1 起 `offset`);引导页入口框调 `tab.actions.openTab(entry.kind, { replaceTab: true })`;文件树的行调 `tab.actions.openResource(address)`;tab 条的新增控件调 `openTab('guide', { paneId, revealIfOpened: false })`。
落位属于调用方,不是 tab 类型的属性。[文件导航栏参考](../../packages/client/ui-sidebar-files/README.zh.md#the-navigator)定义其宿主提供的打开回调;[引导页参考](../../packages/client/ui-sidebar-right/README.zh.md#the-guide)定义引导页替换与显式关闭。
`close(tabId)` 关闭一个 tab;`hide(tabId)` 关闭浮动 tab 或收起其停靠列;`active()` 返回活动 pane 的活动 tab;`isExpanded()` 与 `toggleExpanded()` 读取与翻转这一列,翻转记入序列。无会话时读操作返回 `undefined` 或 `false`;写操作需要已挂载的会话面,没有时抛错而不是写进没人绘制的面。
`focus(tabId)` 让一个 tab 成为其 pane 的活动 tab;`split(paneId?)` 分割活动的停靠 pane 或点名的 pane,返回新 pane 的 id——pane 数预算或列宽不允许时返回 `undefined` 且不记账;`float(tabId, rect?)` 把一个 tab 浮出为浮窗 pane;`dock(paneId)` 把浮窗 pane 收回停靠区。四者都走 store 既有动作、各记一条历史;目标不存在或已处于目标状态时是空操作,与 `open` 一样在没有已挂载会话面时抛错。`TabId`、`PaneId`、`TabRecord`、`FloatRect` 自本包 `/client` 入口再导出,调用方无需引 dockkit。
`focus(tabId)` 让一个 tab 成为其 pane 的活动 tab;`split(paneId?)` 分割活动的停靠 pane 或点名的 pane,返回新 pane 的 id——pane 数预算或列宽不允许时返回 `undefined` 且不记账;`float(tabId, rect?)` 把一个 tab 浮出为浮窗 pane;`dock(paneId)` 把浮窗 pane 收回停靠区并展开该列。四者都走 store 既有动作、各记一条历史;目标不存在或已处于目标状态时是空操作,与 `open` 一样在没有已挂载会话面时抛错。`TabId`、`PaneId`、`TabRecord`、`FloatRect` 自本包 `/client` 入口再导出,调用方无需引 dockkit。
`focusedTarget(element?)` 从实时 DOM 焦点(包括内嵌 iframe)捕获可见页面;侧栏外部或过期的侧栏标记不产生目标。`commandTarget(element?)` 还允许侧栏外部发起的打开操作使用已挂载 Session 的活动停靠窗格。`SidebarRightTarget` 捕获 Session、窗格、host、tab occurrence 和导航 revision。`isTargetCurrent(target)` 在执行前检查这些身份,因此重新打开记录或再次导航都会使已捕获的动作失效;它不会改为操作其他页面。关闭与落位结果见[控制器方法](../../packages/client/ui-sidebar-right/src/client/service.ts)。
@@ -106,9 +107,9 @@ Sidebar 声明四个扩展 slot;其文档 tab 另行声明下表中的 keyed
| `sidebar.right.tab.menu.item` | list,会话作用域 | 追加在 kit 自身布局动作之后的内容级动作。执行了动作的条目必须调用 owner 的 `dismiss()`。 |
| `sidebar.right.tab.document` | 按文档实现的 `id` keyed,会话作用域 | 文档 tab 内选中的文件渲染器;父组件拥有共享加载与工具栏控件。 |
正文、标题与引导页替换项接收框架注入的 `useTabInfo()`。它返回 `{ sidebar, panel, tab }`:`sidebar` 包含 `expanded` 与 `fullscreen`,`panel.id` 标识所属窗格,`tab` 包含记录字段以及 `visible`、`navigation`、`signal` 和 `actions`。所有 tab 都要求 Session 位于前台。停靠正文还要求展开且选中;停靠标题要求展开;前台浮窗不随整栏收起。`signal` 在记录消失或插件卸载时中止,不因隐藏或切换 Session 而中止。`tab.actions` 提供绑定到标签所属 Session 的 `openResource`、`openTab` 与 `close`。打开位置缺省为当前所属窗格;`revealIfOpened` 缺省为 `true`,`replaceTab: true` 在同一历史项中替换本记录。菜单项保留普通的 `tab` 与 `dismiss` owner 参数。
正文、标题与引导页替换项接收框架注入的 `useTabInfo()`。它返回 `{ sidebar, panel, tab }`:`sidebar` 包含 `expanded` 与 `fullscreen`,`panel.id` 标识所属窗格,必填的 `panel.isFloating: boolean` 区分浮窗与停靠窗格。tab 移动时,两者通过 hook 更新,`tab.id` 保持不变。`tab` 还包含其他记录字段以及 `visible`、`navigation`、`signal` 和 `actions`。所有 tab 都要求 Session 位于前台。停靠正文还要求展开且选中;停靠标题要求展开;前台浮窗不随整栏收起。`signal` 在记录消失或插件卸载时中止,不因隐藏或切换 Session 而中止。`tab.actions` 提供绑定到标签所属 Session 的 `openResource`、`openTab` 与 `close`。打开操作返回 `void`,缺省落在当前所属停靠窗格,当前 tab 浮动时落在活动停靠窗格;`revealIfOpened` 缺省为 `true`,`replaceTab: true` 让新内容在同一历史项中替换本记录;带 `destination` 的 `openResource` 既不传本 tab 所在窗格,也不传本记录。菜单项保留普通的 `tab` 与 `dismiss` owner 参数。
`navigation.revision` 在每次导航到该 tab 时递增,`params` 不变也递增,正文可仅凭「又被导航了」行动;按地址打开的 tab 为 `1`,没有人按地址打开的记录——种入的引导、撤销恢复的 tab——为 `0`。Tab 域为每条打开的记录保有一个 occurrence:记录出现即在资源模型里钉住,因此切换 tab 卸载正文也不丢内容;记录消失即中止并丢弃;撤销恢复的记录是新的 occurrence([Tab 域](../../packages/client/ui-sidebar-right/README.zh.md#the-tab-domain))。
`navigation.revision` 在每次导航到该 tab 时递增,`params` 不变也递增,正文可仅凭「又被导航了」行动;按地址打开的 tab 为 `1`,没有人按地址打开的记录——种入的引导、刷新或撤销恢复的 tab——为 `0`。Tab 域为每条打开的记录保有一个 occurrence:记录出现即在资源模型里钉住,因此切换 tab 卸载正文也不丢内容;记录消失即中止并丢弃;撤销恢复的记录是新的 occurrence([Tab 域](../../packages/client/ui-sidebar-right/README.zh.md#the-tab-domain))。
`tab.actions.bindCommands(commands)` 为已挂载正文提供 `SidebarRightTabCommands`,目前包含可选的 `refresh` 回调。省略的操作不可用;返回的 disposer 释放本次注册,不会移除较新正文的命令。标签生命周期结束时也会释放这些命令。`tab.refreshShortcut` 为页面控件提供生效的刷新目录项;没有可用条目时该字段可省略。这些字段声明在 [slot owner 类型](../../packages/client/ui-sidebar-right/src/client/contract/slots.ts)中。
@@ -141,15 +142,15 @@ Host 的 `ctx.workspaceFiles` 服务与生成的 `workspaceFiles` Remote 命名
## 内置类型
- **`guide`**——`builtin`,以 `openTab('guide')` 打开。一枚弱化的罗盘位于各类型按 `order` 贡献的入口胶囊上方;入口较少时显示已注册的描述,未提供图标的入口统一使用内置占位符。点选胶囊即在引导 tab 的位置把贡献它的类型作为页面打开。每个 pane 最多一个引导 tab,tab 条的新增控件只在本 pane 没有引导时出现。新 pane 使用已注册的默认页:只有一个引导入口时直接使用该入口,否则使用引导页([引导](../../packages/client/ui-sidebar-right/README.zh.md#the-guide))。
- **`text`**——`fallback`,`dsh-resource://file/**`,只认领 Session 地址。Document Preview 通过 `useResource<'file'>` 观察元数据,经 Remote 回调加载内容,并拥有渲染器选择、工具栏、逐 tab 刷新、滚动与源码定位;未知扩展名按纯文本渲染([README](../../packages/client/ui-sidebar-documentpreview/README.zh.md))。
- **`files`**——`builtin`,以 `openTab('files')` 打开。工作区目录树,经 `list` 懒加载,按展开目录建立监听,用 `tab.actions.openResource(fileAddressFor(sessionId, root, path))` 在自己所在 pane 打开文件([README](../../packages/client/ui-sidebar-files/README.zh.md))。
- **`text`**——`fallback`,`dsh-resource://file/**`,只认领 Session 地址。Document Preview 通过 `useResource<'file'>` 观察元数据,经 Remote 回调加载内容,并拥有渲染器选择、工具栏、逐 tab 刷新、滚动与源码定位;未知扩展名按纯文本渲染。它在停靠与浮动窗格中直接渲染 files 插件的 `sidebar.files.navigator` 与 `sidebar.files.navigator.toggle` 两个 Factory;未注册时不绘制内容([README](../../packages/client/ui-sidebar-documentpreview/README.zh.md))。
- **`files`**——`builtin`,以 `openTab('files')` 打开。文件页与导航栏共享每个分栏的一棵工作区目录树。[包参考](../../packages/client/ui-sidebar-files/README.zh.md)定义懒加载目录列表、监听、打开回调及导航驱动的可见性。
- **`browser`**——可多开的 `builtin`,以 `openTab('browser', { params: { url? } })` 打开。Chat 的[链接偏好](../../packages/client/ui-chat/README.zh.md)决定 Assistant Markdown 是否在此打开 HTTP(S) 链接。它在默认 sandbox 下接受公共与 loopback HTTP(S) 目标,本地文件改用 Document Preview,并使用应用已知的 iframe history([README](../../packages/client/ui-sidebar-browser/README.zh.md))。
- **`subagentchat`**——`builtin`,`dsh-resource://subagentchat/session/<child>?parent=<parent>&mode=<mode>`。资源提供方保留一个显式寻址的 subagent Conversation,并通过共享 Conversation Factory 渲染([README](../../packages/client/ui-subagent/README.zh.md))。
<a id="not-built"></a>
## 不做
- 持久化:布局状态只在内存里;刷新后每个会话从折叠开始,任何会话的 tab 都不会出现在另一个会话里。
- 跨设备布局同步:布局保存在浏览器本地,各 Session 独立保存状态([状态](../../packages/client/ui-sidebar-right/README.zh.md#state))。
- `ctx.sidebarRight` 上的只读布局快照或订阅:服务只暴露操作,dockkit 的 `LayoutState`/`LayoutOp` 是内部的。
- 服务上的能力探测数组(`features`)。
- tab 类型的 `option` 优先级档:没有「只列出、不许认领」的 tab 类型。
+2 -2
View File
@@ -15,8 +15,8 @@
en: 1eb35b015a6c423f
zh: a8a3dceee2c9e943
/client-web-gui-browser-half/packages:
en: faf349da96142e7c
zh: 2565c9785b010940
en: d988c73b3bf44a97
zh: ba5bab5652214c66
/client-web-gui-browser-half/related-documentation:
en: d1d506e68fab7333
zh: 1a90fe6f3f8f24a9
+1 -1
View File
@@ -49,7 +49,7 @@ The kernel packages boot and serve the page; the UI feature packages present it.
| [`ui-sidebar-documentpreview/`](ui-sidebar-documentpreview/README.md) | Displays documents in right Sidebar tabs | `ctx.documentPreviews` |
| [`ui-sidebar-browser/`](ui-sidebar-browser/README.md) | Browses sandboxed HTTP(S) pages, including loopback services, in right-Sidebar tabs | — |
| [`resources/`](resources/README.md) | Unified resource model: protocol providers behind the `useResource` session standard hook | `ctx.resources` |
| [`ui-sidebar-files/`](ui-sidebar-files/README.md) | Right-Sidebar workspace file tree tab type | — |
| [`ui-sidebar-files/`](ui-sidebar-files/README.md) | The workspace files page and one workspace tree per pane beside docked and floating previews | — |
| [`ui-brand-official/`](ui-brand-official/README.md) | Fills the generic browser-brand slots with the official name and marks | — |
| [`ui-workspace/`](ui-workspace/README.md) | Provides workspace selection and creation surfaces | — |
| [`ui-conversation/`](ui-conversation/README.md) | Presents the active conversation and its input surface | — |
+1 -1
View File
@@ -49,7 +49,7 @@ kind: "package-group"
| [`ui-sidebar-documentpreview/`](ui-sidebar-documentpreview/README.zh.md) | 在右侧 Sidebar 的 tab 中显示文档 | `ctx.documentPreviews` |
| [`ui-sidebar-browser/`](ui-sidebar-browser/README.zh.md) | 在右侧 Sidebar tab 中浏览 sandboxed HTTP(S) 页面,包括 loopback 服务 | — |
| [`resources/`](resources/README.zh.md) | 统一资源模型:`useResource` 会话标准钩子背后的协议提供方 | `ctx.resources` |
| [`ui-sidebar-files/`](ui-sidebar-files/README.zh.md) | 右侧 Sidebar 的工作区文件树 tab 类型 | — |
| [`ui-sidebar-files/`](ui-sidebar-files/README.zh.md) | 工作区文件页,以及停靠与浮动文件预览旁每个分栏一棵的工作区目录树 | — |
| [`ui-brand-official/`](ui-brand-official/README.zh.md) | 用官方名称与标记填充通用浏览器品牌 slot | — |
| [`ui-workspace/`](ui-workspace/README.zh.md) | 提供工作区选择与创建界面 | — |
| [`ui-conversation/`](ui-conversation/README.zh.md) | 展示当前对话及其输入界面 | — |
+2 -2
View File
@@ -48,8 +48,8 @@
en: 6d16b74acb333e3b
zh: fcb3a20cc8c39e4a
/deepseek-ai-dsh-client-modules/understand-the-implementation/incremental-composition:
en: d2c42ee89ca84f7b
zh: 70ee9dd5e6aa5327
en: 99483432e6390ca4
zh: f92c018273aa07e4
/deepseek-ai-dsh-client-modules/understand-the-implementation/boot-manifest-injection:
en: 253d9f265cd83ed0
zh: 9d0f8df84d9d3ac9
+3 -1
View File
@@ -78,7 +78,9 @@ Executing a plugin bundle only registers its factory; every module-body side eff
### Incremental composition
The Node half scans incrementally per package — no full-rescan path. Every `internal/plugin` emission marks the fiber's entry name dirty; a microtask flush reconciles each dirty name against the live loader entries, and the activation pass seeds the same dirty set and flushes synchronously, so first scan and steady state share one implementation. Each reconciliation reads the package manifest and the bundle metadata again, so an entry that restarts after its package was reinstalled, upgraded, or moved publishes its current `dsh.client` declaration and client export; a reconciliation that finds the same manifest and bundle metadata publishes nothing and reads no bundle bytes, and an unchanged bundle keeps its revision, so the browser keeps the loaded plugin. The resolved manifest package name identifies the browser module. Distinct active Loader sources resolving to one package name are rejected; removing the conflict promotes the remaining source without requiring its fiber to restart. A bundle rebuilt without an entry restart reaches the graph through `rebuilt()` (the HMR hook).
The Node half reconciles packages by Loader entry name. Every `internal/plugin` emission marks the fiber's entry name dirty. Each nonempty flush, including the synchronous activation flush, traverses all Loader entries once to build a temporary index by entry name, then looks up the pending names for reconciliation. Package resolution must not synchronously add or remove Loader entries during that reconciliation. A pending name absent from the index is reconciled with an empty entry list to remove unloaded sources; all same-name entries remain available for source deduplication and conflict checks. The activation pass seeds the same pending names and flushes synchronously, so initial activation and steady state share one implementation. Each reconciliation reads the package manifest and the bundle metadata again, so an entry that restarts after its package was reinstalled, upgraded, or moved publishes its current `dsh.client` declaration and client export; a reconciliation that finds the same manifest and bundle metadata publishes nothing and reads no bundle bytes, and an unchanged bundle keeps its revision, so the browser keeps the loaded plugin. The resolved manifest package name identifies the browser module. Distinct active Loader sources resolving to one package name are rejected; removing the conflict promotes the remaining source without requiring its fiber to restart. A bundle rebuilt without an entry restart reaches the graph through `rebuilt()` (the HMR hook).
If Loader enumeration fails during a flush, the registry consumes the failed batch without changing package state or the published graph. Activation reports the failure through its composition error; steady state logs a warning. Another entry notification is required to retry that name.
The Node half snapshots each `client.js` entry before publication and creates combo descriptors without building response bodies. It groups resources into combo route keys (`/plugins/??...&rev=...`), with one bootstrap combo for the modules row and one or more application combos for the other rows; each phase is partitioned before a URL exceeds 3 KiB. The graph and batch descriptors carry the app-directory-relative form of the same key (`plugins/??...&rev=...`), which a browser document resolves under its own mount, while the response table stays keyed by the absolute route; the source-map trailer is resolved against the combo script's own directory. A script body is combined once on its first `GET` and ends with its map reference. The corresponding map files are read, validated, and combined separately on the first map `GET`; `HEAD` materializes neither body. The Host does not scan or preload sibling chunks: an exact `/plugins/<package>/client.<name>.js?rev=<rev>` request reads that script through the asynchronous file API, so a filesystem that materializes files on first asynchronous read can supply it, and caches it; its map remains uncomputed until the map URL is requested. The chunk reference the browser derives from its owner row is document-relative as well (`plugins/<package>/client.<name>.js?rev=<rev>`), and a chunk script's map trailer is the bare `client.<name>.js.map?rev=<rev>` name, which resolves against the script's own directory. Every combo or chunk map is Indexed Source Map v3 and uses an authored section when available or an identity section for the packaged bundle. Initial publication and HMR derive per-plugin revisions from the entry's `mtimeMs`, `ctimeMs`, and size, without hashing artifact contents. Status-change time distinguishes rewrites that preserve mtime and size. Unchanged artifacts therefore retain their revisions across Host restarts, so SSE reconnection does not replace their browser plugins. The shared preset stamps `client.js` after every package output is written, so a chunk-only rebuild changes the owner revision without a Host-side chunk scan. Combo revisions derive from the ordered row revisions. Advertised combo responses and requested chunk responses remain immutable across unrelated graph recomposition, and an unknown resource or revision returns 404.
+3 -1
View File
@@ -79,7 +79,9 @@ application combo 脚本只携带每个插件的 `client.js` 入口,并在启
### 增量组合
Node 半侧逐包增量扫描——没有全量重扫路径。每次发出 `internal/plugin` 事件时,系统都会把该 fiber 的 entry 名标脏;微任务 flush 会把每个脏名与当前 loader 条目对账,激活 pass 会初始化同一个脏集合并同步 flush,因此首次扫描与稳态共用同一实现。每次对账都会重新读取包 manifest 与 bundle 元数据,因此包在重新安装、升级或移动之后重启的条目会发布其当前的 `dsh.client` 声明与客户端导出;读到相同 manifest 与 bundle 元数据的对账不发布任何内容,也不读取 bundle 字节;未变化的 bundle 保留其 revision,因此浏览器会保留已加载的插件。解析出的 manifest 包名作为浏览器模块身份。若不同的 active Loader source 解析到同一包名,组合会失败;移除冲突来源后,剩余来源无需重启 fiber 即可接替。没有伴随条目重启的 bundle 重建经 `rebuilt()`(HMR 钩子)进入图。
Node 半侧按 Loader entry 名对包进行对账。每次发出 `internal/plugin` 事件时,系统都会把该 fiber 的 entry 名标脏。每次非空 flush(包括同步执行的激活 flush)遍历全部 Loader entries 一次,按 entry 名构建临时索引,再查询待处理名称进行对账。包解析过程不得在对账期间同步添加或移除 Loader entries。待处理名称若不在索引中,则以空 entry 列表对账,移除已卸载的来源;所有同名 entry 都保留,用于来源去重与冲突检查。激活 pass 初始化同一组待处理名称并同步 flush,因此首次激活与稳态共用同一实现。每次对账都会重新读取包 manifest 与 bundle 元数据,因此包在重新安装、升级或移动之后重启的条目会发布其当前的 `dsh.client` 声明与客户端导出;读到相同 manifest 与 bundle 元数据的对账不发布任何内容,也不读取 bundle 字节;未变化的 bundle 保留其 revision,因此浏览器会保留已加载的插件。解析出的 manifest 包名作为浏览器模块身份。若不同的 active Loader source 解析到同一包名,组合会失败;移除冲突来源后,剩余来源无需重启 fiber 即可接替。没有伴随条目重启的 bundle 重建经 `rebuilt()`(HMR 钩子)进入图。
如果 flush 期间 Loader 枚举失败,注册表会消费失败批次,但不修改包状态或已发布的图。激活阶段通过组合错误报告失败;稳态下记录警告。只有收到该名称的另一次条目通知后,才会重试。
Node 半侧会在发布前快照每个 `client.js` 入口,并在不构建响应 body 的情况下创建 combo descriptor。它把资源分组为 combo 路由键(`/plugins/??...&rev=...`):modules row 使用一个 bootstrap combo,其余 row 使用一个或多个 application combo;每个阶段都会在 URL 超过 3 KiB 之前分区。启动图与批次描述符携带同一路由键的应用目录相对形式(`plugins/??...&rev=...`),浏览器文档会按其自身挂载解析它,而响应表仍以绝对路由为键;source-map trailer 则相对该 combo 脚本自身目录解析。脚本 body 在首次 `GET` 时只组合一次,并以对应的 map 引用结尾;map 文件则在首次 map `GET` 时单独读取、校验并组合,`HEAD` 不会物化任一 body。Host 不扫描也不预加载同级 chunk:精确的 `/plugins/<package>/client.<name>.js?rev=<rev>` 请求会通过异步文件 API 读取该脚本并缓存,因此首次异步读取时才物化文件的文件系统也能提供它;其 map 仍会等到 map URL 被请求后才计算。浏览器从所属 row 派生出该 chunk 引用,因此它同样是文档相对形式(`plugins/<package>/client.<name>.js?rev=<rev>`);chunk 脚本携带的 map trailer 则是裸文件名 `client.<name>.js.map?rev=<rev>`,由该脚本自身目录解析。每个 combo 或 chunk map 都是 Indexed Source Map v3,并在可用时使用作者提供的 section,否则为已打包 bundle 生成 identity section。首次发布与 HMR 都从入口的 `mtimeMs`、`ctimeMs` 和大小派生逐插件 revision,不对产物内容求哈希。状态变更时间用于区分保留 mtime 和大小的重写。未变化的产物因此会跨 Host 重启保持 revision,SSE 重连不会替换其浏览器插件。共享预设会在该包所有输出写完后标记 `client.js`,因此仅 chunk 发生重建也会更换 owner revision,无需 Host 扫描 chunk。combo revision 从有序 row revision 派生。已公告的 combo 响应与已请求的 chunk 响应会跨无关图重组保持不可变;未知资源或 revision 返回 404。
+37 -14
View File
@@ -10,17 +10,19 @@
* `clientModules` service (the HMR node half's registration/notification
* face).
*
* Scanning is incremental per package — there is no full-rescan code path.
* Package reconciliation is incremental by Loader entry name.
* Every cordis `internal/plugin` emission (fiber construction/disposal) marks
* the fiber's entry name dirty; a microtask flush reconciles each dirty name
* against the live loader entries. The activation pass seeds the same dirty
* set with all current entries and flushes synchronously, so first scan and
* steady state share one implementation. Each reconciliation reads the
* package manifest and the bundle metadata again, so an entry that restarts
* after its package was reinstalled or moved publishes them as they are now;
* an unchanged result publishes nothing and does not read the bundle. The
* manifest package name identifies the browser module; distinct active Loader
* sources for that package are a composition error. Bundle rebuilds outside
* against the Loader entries indexed at the start of that flush. Synchronous
* package resolution must not add or remove Loader entries during reconciliation.
* The activation pass seeds the same pending names and flushes synchronously,
* so initial activation and steady state share one implementation. Each
* reconciliation reads the package manifest and the bundle metadata again,
* so an entry that restarts after its package was reinstalled or moved
* publishes them as they are now; an unchanged result publishes nothing and
* does not read the bundle. The manifest package name identifies the browser
* module; distinct active Loader sources for that package are a composition
* error. Bundle rebuilds outside
* an entry restart reach the graph through {@link ClientModuleRegistry.rebuilt}.
* @module @deepseek-ai/dsh-client-modules
*/
@@ -1031,11 +1033,17 @@ export class ClientModuleRegistry extends Service {
}
}
/** Reconcile one entry name against the live Loader sources. @returns whether the table changed. */
private processOne(entryName: string, onError: (err: Error) => void): boolean {
/**
* Reconcile one entry name against its sources at flush start.
* @param entryName - Loader entry name being reconciled.
* @param entries - same-name entries collected at flush start.
* @param onError - receives package reconciliation failures.
* @returns whether the table changed.
*/
private processOne(entryName: string, entries: readonly Entry[], onError: (err: Error) => void): boolean {
const nextSources = new Map<string, ClientPackageSource>()
for (const entry of this.ctx.loader.entries()) {
if (entry.options.name !== entryName || entry.fiber === undefined || entry.disabled) continue
for (const entry of entries) {
if (entry.fiber === undefined || entry.disabled) continue
const source = this.resolveSource(entry)
if (source !== undefined) nextSources.set(source.sourceKey, source)
}
@@ -1108,11 +1116,26 @@ export class ClientModuleRegistry extends Service {
}
private flush(onError: (err: Error) => void): void {
if (this.dirty.size === 0) return
const entriesByName = new Map<string, Entry[]>()
try {
for (const entry of this.ctx.loader.entries()) {
const name = entry.options.name
const entries = entriesByName.get(name)
if (entries) entries.push(entry)
else entriesByName.set(name, [entry])
}
} catch (error) {
// No package state changed; consume this failed batch and retain the published graph.
this.dirty.clear()
onError(error instanceof Error ? error : new Error(String(error)))
return
}
let changed = false
for (const entryName of [...this.dirty]) {
this.dirty.delete(entryName)
try {
if (this.processOne(entryName, onError)) changed = true
if (this.processOne(entryName, entriesByName.get(entryName) ?? [], onError)) changed = true
} catch (error) {
// Steady state: one broken package must not poison the others; the
// activation pass aggregates these into a loud throw instead.
@@ -164,6 +164,7 @@ function constructWithRoute(
contextBaseUrl?: string
entryBaseUrl?: string
internal?: NonNullable<Context['loader']['internal']>
onEntries?: () => void
} = {},
): { context: Context; service: ClientModuleRegistry; route: Promise<WebResourceRoute> } {
const ctx = new Context()
@@ -173,6 +174,7 @@ function constructWithRoute(
ctx.provide('loader', {
internal: options.internal,
*entries() {
options.onEntries?.()
for (const packageName of packageNames) {
yield {
options: { name: packageName },
@@ -329,6 +331,148 @@ describe('HTML bootstrap facade', () => {
})
describe('client bundle activation', () => {
it('enumerates Loader entries once per dirty-name batch and skips empty batches', async () => {
const names = ['@fixture/scan-first', '@fixture/scan-second', '@fixture/scan-retained']
for (const name of names) writeBuiltPackage(name, {})
const scans = vi.fn()
const { context, service } = constructWithRoute(names, { onEntries: scans })
expect(scans).toHaveBeenCalledTimes(2)
expect(service.graph().entries).toHaveLength(3)
scans.mockClear()
const removed = names.splice(0, 2)
for (const name of [...removed, removed[0]!]) emitLoaderEntryChange(context, name)
await Promise.resolve()
expect(scans).toHaveBeenCalledTimes(1)
expect(service.graph().entries.map(entry => entry.id)).toEqual(names)
for (const name of removed) expect(service.clientPath(name)).toBeUndefined()
scans.mockClear()
context.emit('internal/plugin', context.fiber)
await Promise.resolve()
expect(scans).not.toHaveBeenCalled()
const emptyScans = vi.fn()
const empty = constructWithRoute([], { onEntries: emptyScans })
expect(empty.service.graph().entries).toEqual([])
expect(emptyScans).toHaveBeenCalledTimes(1)
})
it('keeps a shared source until its last same-name Loader entry unloads', async () => {
const name = '@fixture/shared-source-owners'
writeBuiltPackage(name, {})
const names = [name, name]
const { context, service } = constructWithRoute(names)
const initial = service.graph()
names.pop()
emitLoaderEntryChange(context, name)
await Promise.resolve()
expect(service.graph()).toBe(initial)
expect(service.clientPath(name)).toBeDefined()
names.pop()
emitLoaderEntryChange(context, name)
await Promise.resolve()
expect(service.graph().entries).toEqual([])
expect(service.clientPath(name)).toBeUndefined()
})
it('reports an activation flush enumeration failure as a composition error', () => {
const name = '@fixture/failed-activation-scan'
writeBuiltPackage(name, {})
const scans = vi.fn()
.mockImplementationOnce(() => undefined)
.mockImplementationOnce(() => { throw new Error('Loader enumeration failed') })
expect(() => constructWithRoute([name], { onEntries: scans })).toThrow(AggregateError)
})
it.each([new Error('Loader enumeration failed'), 'Loader enumeration failed'])(
'retains the graph and consumes the failed batch when Loader enumeration throws %s',
async (failure) => {
const names = ['@fixture/failed-scan-first', '@fixture/failed-scan-second', '@fixture/failed-scan-retained']
for (const name of names) writeBuiltPackage(name, {})
const scans = vi.fn()
const { context, service, route } = constructWithRoute(names, { onEntries: scans })
await route
const initial = service.graph()
const warning = vi.spyOn(context.logger, 'warn').mockImplementation(() => undefined)
const removed = names.splice(0, 2)
scans.mockImplementationOnce(() => { throw failure })
const callbacks: VoidFunction[] = []
const enqueue = vi.spyOn(globalThis, 'queueMicrotask').mockImplementation(callback => callbacks.push(callback))
try {
for (const name of removed) emitLoaderEntryChange(context, name)
expect(callbacks).toHaveLength(1)
expect(callbacks[0]!).not.toThrow()
} finally {
enqueue.mockRestore()
}
expect(warning).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ message: 'Loader enumeration failed' }))
expect(service.graph()).toBe(initial)
for (const name of removed) expect(service.clientPath(name)).toBeDefined()
emitLoaderEntryChange(context, names[0]!)
await Promise.resolve()
expect(service.graph()).toBe(initial)
for (const name of removed) emitLoaderEntryChange(context, name)
await Promise.resolve()
expect(service.graph().entries.map(entry => entry.id)).toEqual(names)
for (const name of removed) expect(service.clientPath(name)).toBeUndefined()
},
)
it.each(['processing', 'processed'] as const)('consumes pending names once when a %s name is marked again', async (timing) => {
const firstName = '@fixture/reentrant-first'
const secondName = '@fixture/reentrant-second'
const thirdName = '@fixture/reentrant-third'
const names = [firstName, secondName, thirdName]
const hostUrls = new Map<string, string>()
for (const name of names) {
writeBuiltPackage(name, {})
const hostPath = join(root!, 'node_modules', ...name.split('/'), 'lib', 'index.js')
writeFileSync(hostPath, 'export default {}\n')
hostUrls.set(name, pathToFileURL(hostPath).href)
}
const resolved: string[] = []
let duringResolve = (_name: string) => {}
const internal = {
version: 'v2' as const,
resolveSync: (_baseUrl: string, { specifier }: { specifier: string }) => {
resolved.push(specifier)
duringResolve(specifier)
return { format: 'module' as const, url: hostUrls.get(specifier)! }
},
}
const { context, service } = constructWithRoute(names, {
internal: internal as NonNullable<Context['loader']['internal']>,
})
const initial = service.graph()
resolved.length = 0
duringResolve = (name) => {
if (name === firstName) {
emitLoaderEntryChange(context, secondName)
emitLoaderEntryChange(context, thirdName)
if (timing === 'processing') emitLoaderEntryChange(context, firstName)
} else if (name === secondName) {
if (timing === 'processed') emitLoaderEntryChange(context, firstName)
duringResolve = () => {}
}
}
emitLoaderEntryChange(context, firstName)
emitLoaderEntryChange(context, secondName)
await Promise.resolve()
expect(resolved.splice(0)).toEqual([firstName, secondName])
await Promise.resolve()
expect(resolved).toEqual([thirdName, firstName])
expect(service.graph()).toBe(initial)
})
it.each(['v1', 'v2'] as const)(
'resolves %s package metadata from the owning entry tree',
(version) => {
+2 -2
View File
@@ -18,8 +18,8 @@
en: 9386791a9e5efa00
zh: 7cb9f3bbc42319d6
/deepseek-ai-dsh-client-ui-chat/hidden-chat-rows:
en: cbf1f0a2205046a5
zh: 2e59d0815351349f
en: b0c4e6711d69d2f8
zh: 09add0ea1a243922
/deepseek-ai-dsh-client-ui-chat/command-and-failure-rows:
en: be90c24392fb021b
zh: 992b8745b385f9ab
+2
View File
@@ -58,6 +58,8 @@ Chat omits system-prompt, ordinary Context injection, and `permission` command r
When an Assistant attempt retires without a visible message, Chat hides its already-published Node instead of removing its key. A retry in the same Step reuses that key when visible content returns. This also applies when the loaded window lacks the Step start.
Text and reasoning contribute to Assistant visibility only when they contain a non-whitespace character. Streaming appends reuse each block's visibility; block replacement and finalized messages recalculate it while preserving the original text.
<a id="command-and-failure-rows"></a>
## Command and failure rows
+2
View File
@@ -58,6 +58,8 @@ Chat 在所有工作步骤展示模式下都不显示系统提示词行、普通
Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node,不移除其 key。同一 Step 的重试再次产生可见内容时,复用该 key。已加载窗口缺少 Step 起点时也遵循此规则。
只有包含非空白字符的文本和推理块才计入 Assistant 的可见内容。流式追加复用每个块的可见状态;块替换和最终消息重新计算可见性,同时保留原始文本。
<a id="command-and-failure-rows"></a>
## 指令与失败行
@@ -30,6 +30,8 @@ interface AssistantState {
readonly turn: number
readonly step: number
readonly blocks: readonly (AssistantBlock | undefined)[]
/** Missing indices are invisible; same-kind appends preserve already-visible blocks. */
readonly visibleByIndex: readonly boolean[]
readonly visibleBlocks: number
readonly firstVisibleSeq: number | undefined
readonly firstVisibleTime: number | undefined
@@ -43,6 +45,7 @@ function initialState(turn: number, step: number): AssistantState {
turn,
step,
blocks: [],
visibleByIndex: [],
visibleBlocks: 0,
firstVisibleSeq: undefined,
firstVisibleTime: undefined,
@@ -62,12 +65,6 @@ function blockIsVisible(block: AssistantBlock | undefined): boolean {
return true
}
function countVisibleBlocks(blocks: readonly AssistantBlock[]): number {
let count = 0
for (const block of blocks) if (blockIsVisible(block)) count++
return count
}
function hasVisibleContent(blocks: readonly AssistantBlock[]): boolean {
return blocks.some(blockIsVisible)
}
@@ -101,30 +98,34 @@ function updateChunk(
const blocks = [...state.blocks]
let changedIndex = -1
let previousVisible = false
let nextVisible = false
switch (chunk.type) {
case 'block-start':
changedIndex = chunk.index
previousVisible = blockIsVisible(blocks[chunk.index])
previousVisible = state.visibleByIndex[chunk.index] === true
blocks[chunk.index] = emptyAssistantBlock(chunk.blockType)
nextVisible = blockIsVisible(blocks[chunk.index])
break
case 'text-delta': {
const previous = blocks[chunk.index]
changedIndex = chunk.index
previousVisible = blockIsVisible(previous)
previousVisible = state.visibleByIndex[chunk.index] === true
blocks[chunk.index] = { kind: 'text', text: (previous?.kind === 'text' ? previous.text : '') + chunk.text }
nextVisible = (previous?.kind === 'text' && previousVisible) || chunk.text.trim() !== ''
break
}
case 'reasoning-delta': {
const previous = blocks[chunk.index]
changedIndex = chunk.index
previousVisible = blockIsVisible(previous)
previousVisible = state.visibleByIndex[chunk.index] === true
blocks[chunk.index] = { kind: 'reasoning', text: (previous?.kind === 'reasoning' ? previous.text : '') + chunk.text }
nextVisible = (previous?.kind === 'reasoning' && previousVisible) || chunk.text.trim() !== ''
break
}
case 'tool-call-delta': {
const previous = blocks[chunk.index]
changedIndex = chunk.index
previousVisible = blockIsVisible(previous)
previousVisible = state.visibleByIndex[chunk.index] === true
// Tool Nodes own streamed arguments; Assistant protocol blocks retain identity until full settlement.
blocks[chunk.index] = {
kind: 'tool-call',
@@ -136,8 +137,9 @@ function updateChunk(
}
case 'block-end':
changedIndex = chunk.index
previousVisible = blockIsVisible(blocks[chunk.index])
previousVisible = state.visibleByIndex[chunk.index] === true
blocks[chunk.index] = toAssistantBlock(chunk.block)
nextVisible = blockIsVisible(blocks[chunk.index])
break
case 'usage':
return { ...state, usage: chunk.usage }
@@ -146,11 +148,18 @@ function updateChunk(
}
const visibleBlocks = state.visibleBlocks
- Number(previousVisible)
+ Number(blockIsVisible(blocks[changedIndex]))
+ Number(nextVisible)
let visibleByIndex = state.visibleByIndex
if (previousVisible !== nextVisible) {
const next = [...visibleByIndex]
next[changedIndex] = nextVisible
visibleByIndex = next
}
const firstToken = isTokenDelta(chunk)
return {
...state,
blocks,
visibleByIndex,
visibleBlocks,
...visibleBlocks > 0 && state.firstVisibleSeq === undefined
? { firstVisibleSeq: seq, firstVisibleTime: time }
@@ -167,10 +176,12 @@ function settleMessage(
event: SessionEvent<'assistant/message'>,
): AssistantState {
const blocks = toAssistantBlocks(event.data.message.content)
const visibleByIndex = blocks.map(blockIsVisible)
return {
...state,
blocks,
visibleBlocks: countVisibleBlocks(blocks),
visibleByIndex,
visibleBlocks: visibleByIndex.reduce((count, visible) => count + Number(visible), 0),
final: match,
usage: event.data.usage,
}
@@ -13,7 +13,7 @@ import {
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { inspectSystemPrompt } from '../../ui-conversation/src/client/contract/system-prompt.ts'
import { AssistantStreamAccumulator } from '@deepseek-ai/dsh-llm/assistant-stream'
import { LlmAttemptId } from '@deepseek-ai/dsh-llm/brand'
import { LlmAttemptId, ToolCallId } from '@deepseek-ai/dsh-llm/brand'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
import { hasAssistantReplyContent } from '../src/client/contract/assistant-content.ts'
import { assistantDefinition } from '../src/client/conversation-nodes/assistant.ts'
@@ -169,6 +169,17 @@ function node(value: ChatSnapshot, kind: string): ChatConversationViewNode | und
return value.nodes.values().find(candidate => candidate.kind === kind)
}
function appendAssistantChunk(value: ConversationNodeAssembler, seq: number, chunk: StreamChunk): void {
value.append({
type: 'transient',
event: {
type: 'assistant/live-chunk', seq, time: 1_700_000_000_000 + seq,
data: { attemptId: LlmAttemptId('assistant-visibility'), turn: 1, step: 1, chunk },
},
})
value.flush()
}
function promptNodes(value: ChatSnapshot): ChatConversationViewNode[] {
const prompts = value.nodes.values().filter(candidate => candidate.kind === 'system-prompt')
for (const prompt of prompts) expect(value.order).not.toContain(prompt.key)
@@ -1128,6 +1139,94 @@ describe('built-in conversation node Definitions', () => {
.toMatchObject({ answerAnchorSeq: null, answerStep: null })
})
it.each(['text', 'reasoning'] as const)('retains %s whitespace and anchors the first visible delta', (kind) => {
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'step/start', { turn: 1, step: 1 }),
])
const whitespace = '\t\n\v\f\r \u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007'
+ '\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff'
appendAssistantChunk(value, 3, { type: 'block-start', index: 0, blockType: kind })
appendAssistantChunk(value, 4, { type: `${kind}-delta`, index: 0, text: whitespace })
expect(node(snapshot(value), 'assistant-step')).toBeUndefined()
appendAssistantChunk(value, 5, { type: `${kind}-delta`, index: 0, text: '\u200b' })
const visible = node(snapshot(value), 'assistant-step')
expect(visible).toMatchObject({ anchorSeq: 5, visibility: 'visible', data: { time: 1_700_000_000_005 } })
appendAssistantChunk(value, 6, { type: `${kind}-delta`, index: 0, text: `${whitespace}answer` })
const content = `${whitespace}\u200b${whitespace}answer`
expect(node(snapshot(value), 'assistant-step')).toMatchObject({
key: visible?.key, anchorSeq: 5, visibility: 'visible',
data: { blocks: [{ kind, text: content }] },
})
value.append(at(7, 'assistant/message', {
turn: 1, step: 1,
message: { ...assistantMessage('whitespace-answer', ''), content: [{ type: kind, text: content }] },
}, { surfaceOp: 'append' }))
value.flush()
const settled = node(snapshot(value), 'assistant-step')?.data as AssistantChatData
expect(settled.finalNode?.timing?.firstTokenTime).toBe(1_700_000_000_004)
expect(settled.blocks).toEqual([{ kind, text: content }])
})
it.each(['text', 'reasoning'] as const)('updates visibility when a sparse Assistant block becomes %s', (kind) => {
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'step/start', { turn: 1, step: 1 }),
])
appendAssistantChunk(value, 3, { type: 'text-delta', index: 0, text: 'answer' })
const first = node(snapshot(value), 'assistant-step')
const previousKind = kind === 'text' ? 'reasoning' : 'text'
appendAssistantChunk(value, 4, { type: `${previousKind}-delta`, index: 2, text: 'thinking' })
appendAssistantChunk(value, 5, { type: 'block-start', index: 0, blockType: 'text' })
expect(node(snapshot(value), 'assistant-step')?.visibility).toBe('visible')
appendAssistantChunk(value, 6, { type: `${previousKind}-delta`, index: 2, text: ' ' })
expect(node(snapshot(value), 'assistant-step')?.visibility).toBe('visible')
appendAssistantChunk(value, 7, { type: `${kind}-delta`, index: 2, text: ' ' })
expect(node(snapshot(value), 'assistant-step')).toMatchObject({
key: first?.key, anchorSeq: 3, visibility: 'hidden',
data: { blocks: [{ kind: 'text', text: '' }, { kind, text: ' ' }] },
})
appendAssistantChunk(value, 8, { type: 'block-end', index: 0, block: { type: 'text', text: 'replacement' } })
expect(node(snapshot(value), 'assistant-step')?.visibility).toBe('visible')
appendAssistantChunk(value, 9, { type: 'block-end', index: 0, block: { type: 'text', text: ' ' } })
expect(node(snapshot(value), 'assistant-step')?.visibility).toBe('hidden')
appendAssistantChunk(value, 10, { type: 'block-start', index: 0, blockType: 'image' })
expect(node(snapshot(value), 'assistant-step')?.visibility).toBe('visible')
appendAssistantChunk(value, 11, {
type: 'tool-call-delta', index: 0, id: ToolCallId('visibility-tool'), name: 'read', argumentsDelta: '',
})
expect(node(snapshot(value), 'assistant-step')?.visibility).toBe('hidden')
appendAssistantChunk(value, 12, { type: 'block-start', index: 0, blockType: 'image' })
expect(node(snapshot(value), 'assistant-step')?.visibility).toBe('visible')
appendAssistantChunk(value, 13, { type: 'block-start', index: 0, blockType: 'tool-call' })
expect(node(snapshot(value), 'assistant-step')).toMatchObject({
key: first?.key, anchorSeq: 3, visibility: 'hidden',
})
})
it('waits for visible content again after an Assistant retry', () => {
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'step/start', { turn: 1, step: 1 }),
])
appendAssistantChunk(value, 3, { type: 'text-delta', index: 0, text: 'first attempt' })
const first = node(snapshot(value), 'assistant-step')
value.append(at(4, 'llm/retry', {
retryId: 'visibility-retry', turn: 1, step: 1, provider: 'fake', mode: 'normal',
policyKey: 'fake-normal', retry: 1, maxRetries: 2, delayMs: 10,
failure: { code: 'TRANSPORT', message: 'temporary' },
}))
appendAssistantChunk(value, 5, { type: 'reasoning-delta', index: 0, text: '\u00a0 ' })
expect(node(snapshot(value), 'assistant-step')?.visibility).toBe('hidden')
appendAssistantChunk(value, 6, { type: 'reasoning-delta', index: 0, text: 'replacement' })
expect(node(snapshot(value), 'assistant-step')).toMatchObject({
key: first?.key, anchorSeq: 6, visibility: 'visible',
data: { time: 1_700_000_000_006, blocks: [{ kind: 'reasoning', text: '\u00a0 replacement' }] },
})
})
it('keeps one keyed Assistant node while streaming settles and materializes interruption from Location', () => {
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
@@ -172,7 +172,7 @@ describe('ReviewTab', () => {
const runtime = {
renderSlot: renderFileActions,
useTabInfo: () => ({
sidebar: { expanded: true, fullscreen: false }, panel: { id: 'pane-1' },
sidebar: { expanded: true, fullscreen: false }, panel: { id: 'pane-1', isFloating: false },
tab: { id: TAB, kind: 'changes-review', contentId: options.address ?? ADDRESS, title: 'Review', visible: true, navigation, signal: aborter.signal, actions: tabActions },
}),
sessionId: SESSION,
@@ -18,8 +18,8 @@
en: 056e2bd9e3c78dd8
zh: be35d2ba53be0442
/deepseek-ai-dsh-client-ui-open-in-app/use-this-package/what-to-expect:
en: 6f3934925cfda953
zh: 9e3126bce73cf344
en: 51e6019f011f394e
zh: 818391262086ae10
/deepseek-ai-dsh-client-ui-open-in-app/understand-the-implementation:
en: ec7a7c56aaeafce6
zh: 4cee0cd2cb7531df
+2
View File
@@ -45,6 +45,8 @@ Context menus contribute file preview, native application opening, original-file
File menus copy absolute paths without line numbers; URL copying preserves query and fragment. Image files also expose image copy and original download, alongside the existing file actions. Successful downloads and saves show the shared Toast; picker cancellation is silent.
A file menu offers “Open in sidebar” and “Open in floating panel”, which call `ctx.sidebarRight.openResourceIn` with `destination: 'sidebar'` or `'float'` and the target's line, never the pane the menu was raised in. A file already open in the requested place is revealed; one open in the other place moves there with its reading position, so the menu can move a preview between the docked column and a floating panel. Left-click opens files as before.
-----
<a id="understand-the-implementation"></a>
@@ -45,6 +45,8 @@ kind: "package-reference"
文件菜单复制不含行号的绝对路径;网址复制保留查询参数与锚点。图片文件在既有文件操作之外,还提供复制图片与下载原图。下载和保存成功后显示共享 Toast,取消选择器时不提示。
文件菜单提供“在侧边栏打开”与“在浮窗中打开”,以 `destination: 'sidebar'` 或 `'float'` 和目标的行号调用 `ctx.sidebarRight.openResourceIn`,从不传菜单所在的分栏。文件已在请求的位置打开时揭示它;在另一位置打开时连同阅读位置移过去,因此菜单可以把预览在停靠列和浮窗之间移动。左键打开文件的行为不变。
-----
<a id="understand-the-implementation"></a>
@@ -78,7 +78,7 @@ export function registerContextMenus(
ctx.effect(() => ctx.contextMenus.register({
id: 'open-in-app.file', target: 'file', order: 100,
resolve: async (target, signal) => {
const { sessionId, path, line, paneId } = target
const { sessionId, path, line } = target
await paths.load()
const absolutePath = await resolvePath(sessionId, path, signal)
const available = absolutePath !== undefined
@@ -86,14 +86,16 @@ export function registerContextMenus(
const apps = desktop ? await paths.applications(absolutePath, signal) : []
signal.throwIfAborted()
const application = apps?.find(app => app.default)
// The menu names a host, not the pane it was raised in: a floating pane is never a docked landing place.
const openIn = (destination: 'sidebar' | 'float') => () => {
const cwd = ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd
ctx.sidebarRight.openResourceIn(sessionId, fileAddressFor(sessionId, cwd, path), {
destination, ...(line === undefined ? {} : { params: { line } }),
})
}
return [
{ id: 'sidebar', icon: { key: 'open-in-app.sidebar' }, label: t('menu.sidebar'), disabled: !available,
run: () => {
const cwd = ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd
ctx.sidebarRight.openResourceIn(sessionId, fileAddressFor(sessionId, cwd, path), {
...(paneId === undefined ? {} : { paneId }), ...(line === undefined ? {} : { params: { line } }),
})
} },
{ id: 'sidebar', icon: { key: 'open-in-app.sidebar' }, label: t('menu.openInSidebar'), disabled: !available, run: openIn('sidebar') },
{ id: 'float', icon: { key: 'open-in-app.float' }, label: t('menu.openInFloat'), disabled: !available, run: openIn('float') },
{ id: 'reveal', icon: { key: 'open-in-app.reveal' }, label: fileManager(), disabled: !desktop,
...absolutePath === undefined ? {} : { run: () => open(absolutePath, 'reveal') } },
{ id: 'open', label: application ? t('open.title', { app: application.name }) : t('menu.defaultApp'),
@@ -112,7 +114,7 @@ export function registerContextMenus(
ctx.effect(() => ctx.contextMenus.register({
id: 'open-in-app.url', target: 'url', order: 100,
resolve: ({ sessionId, url }) => [
{ id: 'sidebar', icon: { key: 'open-in-app.sidebar' }, label: t('menu.sidebar'), disabled: ctx.sidebarRightTabs.get('browser') === undefined,
{ id: 'sidebar', icon: { key: 'open-in-app.sidebar' }, label: t('menu.openInSidebar'), disabled: ctx.sidebarRightTabs.get('browser') === undefined,
run: () => { ctx.sidebarRight.openTabIn(sessionId, 'browser', { params: { url } }) } },
{ id: 'external', label: t('menu.external'),
run: () => { window.open(url, '_blank', 'noopener,noreferrer') } },
@@ -1,4 +1,4 @@
import { IconFolderOpenOutlineRegular, IconCopyOutlineRegular, IconPanelLeftOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconFolderOpenOutlineRegular, IconCopyOutlineRegular, IconPanelLeftOutlineRegular, IconRightUpOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import { ContextMenuApplicationIcon, registerContextMenus } from './context-menus.tsx'
import type {} from '@deepseek-ai/dsh-client-ui-context-menu/client'
/**
@@ -65,7 +65,7 @@ export function apply(ctx: ClientContext): void {
ctx.slots.inject('contextMenu.icon', function* () {
for (const [key, Icon] of Object.entries({
'open-in-app.reveal': IconFolderOpenOutlineRegular, 'open-in-app.copy': IconCopyOutlineRegular,
'open-in-app.sidebar': IconPanelLeftOutlineRegular,
'open-in-app.sidebar': IconPanelLeftOutlineRegular, 'open-in-app.float': IconRightUpOutlineRegular,
})) yield ctx.slots.register({ name: 'contextMenu.icon', key }, Icon)
yield ctx.slots.register({ name: 'contextMenu.icon', key: 'open-in-app.application' }, ContextMenuApplicationIcon)
})
@@ -39,7 +39,8 @@ const PRODUCT_NAMES = {
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'menu.sidebar': '在侧边栏打开',
'menu.openInSidebar': '在侧边栏打开',
'menu.openInFloat': '在浮窗中打开',
'menu.finder': '在 Finder 中显示',
'menu.reveal': '在文件夹显示',
'menu.defaultApp': '使用默认应用打开',
@@ -72,7 +73,8 @@ export const zh = {
/** English dictionary, key-identical to the Chinese source of truth. */
export const en: Record<OpenInAppKey, string> = {
'menu.sidebar': 'Open in sidebar',
'menu.openInSidebar': 'Open in sidebar',
'menu.openInFloat': 'Open in floating panel',
'menu.finder': 'Show in Finder',
'menu.reveal': 'Show in folder',
'menu.defaultApp': 'Open in default application',
@@ -108,7 +110,8 @@ export type OpenInAppKey = keyof typeof zh
/** Japanese copy, checked against the English keys. */
export const ja = {
'menu.sidebar': 'サイドバーで開く',
'menu.openInSidebar': 'サイドバーで開く',
'menu.openInFloat': 'フローティングパネルで開く',
'menu.finder': 'Finder に表示',
'menu.reveal': 'フォルダーに表示',
'menu.defaultApp': '既定のアプリで開く',
@@ -141,7 +144,8 @@ export const ja = {
/** Spanish dictionary, checked against the English keys. */
export const es = {
'menu.sidebar': 'Abrir en la barra lateral',
'menu.openInSidebar': 'Abrir en la barra lateral',
'menu.openInFloat': 'Abrir en un panel flotante',
'menu.finder': 'Mostrar en Finder',
'menu.reveal': 'Mostrar en la carpeta',
'menu.defaultApp': 'Abrir en la aplicación predeterminada',
@@ -173,7 +177,8 @@ export const es = {
/** French dictionary, checked against the English keys. */
export const fr = {
'menu.sidebar': 'Ouvrir dans la barre latérale',
'menu.openInSidebar': 'Ouvrir dans la barre latérale',
'menu.openInFloat': 'Ouvrir dans un panneau flottant',
'menu.finder': 'Afficher dans le Finder',
'menu.reveal': 'Afficher dans le dossier',
'menu.defaultApp': 'Ouvrir dans l’application par défaut',
@@ -205,7 +210,8 @@ export const fr = {
/** German dictionary, checked against the English keys. */
export const de = {
'menu.sidebar': 'In der Seitenleiste öffnen',
'menu.openInSidebar': 'In der Seitenleiste öffnen',
'menu.openInFloat': 'In schwebendem Panel öffnen',
'menu.finder': 'Im Finder anzeigen',
'menu.reveal': 'Im Ordner anzeigen',
'menu.defaultApp': 'In der Standardanwendung öffnen',
@@ -237,7 +243,8 @@ export const de = {
/** Russian dictionary, checked against the English keys. */
export const ru = {
'menu.sidebar': 'Открыть на боковой панели',
'menu.openInSidebar': 'Открыть на боковой панели',
'menu.openInFloat': 'Открыть на плавающей панели',
'menu.finder': 'Показать в Finder',
'menu.reveal': 'Показать в папке',
'menu.defaultApp': 'Открыть в приложении по умолчанию',
@@ -269,7 +276,8 @@ export const ru = {
/** Portuguese dictionary, checked against the English keys. */
export const pt = {
'menu.sidebar': 'Abrir na barra lateral',
'menu.openInSidebar': 'Abrir na barra lateral',
'menu.openInFloat': 'Abrir em painel flutuante',
'menu.finder': 'Mostrar no Finder',
'menu.reveal': 'Mostrar na pasta',
'menu.defaultApp': 'Abrir no aplicativo padrão',
@@ -138,7 +138,7 @@ describe('open-in-app browser half', () => {
const toast = toastSource(ctx)
try {
expect(ctx.slots.entries('contextMenu.icon').map(entry => entry.options.key)).toEqual([
'open-in-app.reveal', 'open-in-app.copy', 'open-in-app.sidebar', 'open-in-app.application',
'open-in-app.reveal', 'open-in-app.copy', 'open-in-app.sidebar', 'open-in-app.float', 'open-in-app.application',
])
expect(ctx.slots.entries('sidebar.workspaces.session.menu.item')).toHaveLength(0)
await vi.waitFor(() => { expect(menu.source.getSnapshot()[0]).toMatchObject({ label: 'Show in Finder', disabled: false }) })
@@ -78,12 +78,24 @@ async function boot() {
sidebar, browserTab, notify, failure, writeText, report }
}
it('routes file actions through the captured pane, line, native association and original bytes', async () => {
it('routes file actions through an explicit host, the line, native association and original bytes', async () => {
const h = await boot()
const file = { kind: 'file', value: { sessionId, path: 'file.svg', paneId: 'pane' as never, line: 24 } } as const
// A menu raised inside a floating panel names that panel; the open names a host instead, never a pane.
const file = { kind: 'file', value: { sessionId, path: 'file.svg', paneId: 'float7' as never, line: 24 } } as const
const items = (await h.resolve(file)).source.getSnapshot().filter(item => item.id.startsWith('open-in-app.file/'))
expect(items.map(item => [item.id, item.label, item.icon?.key])).toEqual([
['open-in-app.file/sidebar', 'menu.openInSidebar', 'open-in-app.sidebar'],
['open-in-app.file/float', 'menu.openInFloat', 'open-in-app.float'],
['open-in-app.file/reveal', 'menu.reveal', 'open-in-app.reveal'],
['open-in-app.file/open', 'open.title', 'open-in-app.application'],
['open-in-app.file/copyPath', 'menu.copyPath', 'open-in-app.copy'],
])
await h.choose(file, 'open-in-app.file/sidebar')
expect(h.sidebar.openResourceIn).toHaveBeenCalledWith(sessionId, expect.stringContaining('file.svg'),
{ paneId: 'pane', params: { line: 24 } })
expect(h.sidebar.openResourceIn).toHaveBeenLastCalledWith(sessionId, expect.stringContaining('file.svg'),
{ destination: 'sidebar', params: { line: 24 } })
await h.choose(file, 'open-in-app.file/float')
expect(h.sidebar.openResourceIn).toHaveBeenLastCalledWith(sessionId, expect.stringContaining('file.svg'),
{ destination: 'float', params: { line: 24 } })
const opened = await h.choose(file, 'open-in-app.file/open')
expect(opened?.icon).toEqual({ key: 'open-in-app.application', src: 'data:image/png;base64,a' })
expect(h.remote.session.openWorkspacePath).toHaveBeenCalledWith({ path: '/workspace/file.svg', application: 'preview' })
@@ -107,7 +119,11 @@ it('routes file actions through the captured pane, line, native association and
expect(menu.source.getSnapshot().filter(item => item.id.startsWith('file-actions.')).map(item => item.id))
.toEqual(['file-actions.file/saveAs'])
menu.select('open-in-app.file/sidebar')
expect(h.sidebar.openResourceIn).toHaveBeenLastCalledWith(SessionId('absent'), expect.any(String), {})
expect(h.sidebar.openResourceIn).toHaveBeenLastCalledWith(SessionId('absent'), expect.any(String), { destination: 'sidebar' })
h.remote.session.workspacePathApplications.mockResolvedValueOnce({ ok: true, value: [] })
const floating = await h.resolve(simple)
floating.select('open-in-app.file/float')
expect(h.sidebar.openResourceIn).toHaveBeenLastCalledWith(SessionId('absent'), expect.any(String), { destination: 'float' })
})
it('keeps copy-path available on failed metadata and reports native action failures', async () => {
@@ -136,6 +152,7 @@ it('keeps copy-path available on failed metadata and reports native action failu
it('opens links in the registered browser, externally, and on the clipboard', async () => {
const h = await boot()
const target = { kind: 'url', value: { sessionId, url: 'https://example.com/page' } } as const
expect((await h.resolve(target)).source.getSnapshot().find(item => item.id === 'open-in-app.url/sidebar')?.label).toBe('menu.openInSidebar')
await h.choose(target, 'open-in-app.url/sidebar')
expect(h.sidebar.openTabIn).toHaveBeenCalledWith(sessionId, 'browser', { params: { url: target.value.url } })
const external = vi.spyOn(window, 'open').mockReturnValue(null)
@@ -48,7 +48,7 @@ it('offers image actions on file targets, copies absolute paths, and keeps save
const copying = registry.resolve(file)
await copying.ready
expect(copying.source.getSnapshot().map(action => action.id)).toEqual([
'open-in-app.file/sidebar', 'open-in-app.file/reveal', 'open-in-app.file/open',
'open-in-app.file/sidebar', 'open-in-app.file/float', 'open-in-app.file/reveal', 'open-in-app.file/open',
'open-in-app.file/copyPath', 'file-actions.file/copyImage', 'file-actions.file/download', 'file-actions.file/saveAs',
])
copying.select('open-in-app.file/copyPath')
@@ -66,5 +66,10 @@ it('offers image actions on file targets, copies absolute paths, and keeps save
expect(image.source.getSnapshot().find(action => action.id.endsWith('/reveal'))?.disabled).toBe(true)
image.dispose()
expect(failed).not.toHaveBeenCalled()
await fiber.dispose()
const unloaded = registry.resolve(file)
await unloaded.ready
expect(unloaded.source.getSnapshot().filter(action => action.id.startsWith('open-in-app.'))).toEqual([])
unloaded.dispose()
} finally { registry.dispose(); await fiber.dispose() }
})
@@ -15,8 +15,8 @@
en: 76368b6b1da79c80
zh: 52384aee991ac542
/deepseek-ai-dsh-client-ui-plugin-market/use-this-package:
en: 868b100459e30aad
zh: 8a3a01199dc3c14c
en: 4e582bcd8434b513
zh: 39334bf8ae435e09
/deepseek-ai-dsh-client-ui-plugin-market/understand-the-implementation:
en: e1a480caaff8efea
zh: 0b725fa5bc929f22
+1 -1
View File
@@ -26,7 +26,7 @@ Discover is the second tab of the **Plugins** page, after **Installed**. It list
Open **Plugins** and select **Discover**. With no index source, the tab shows an empty state whose **Add index source** button opens the **Index sources** dialog. The dialog lists each source by index title, or by host before the first read, with its URL, the error of its last failed read, and a remove button, and keeps a notice that indexes come from third parties. A refused URL keeps the draft and says why: an unsupported scheme, credentials, or fragment; a URL already listed; or a deployment that cannot save sources. A newly added source becomes the selected index.
The toolbar holds the index menu, which switches indexes and ends with **Manage index sources…**; the search field; a **Labels** menu with multi-select checkmarks when the index declares labels; the **Sort** menu; and a refresh button. Category pills, with **All** first, follow when the index declares categories.
The toolbar holds the index menu, which switches indexes and ends with **Manage index sources…**; the search field; a **Labels** menu with multi-select checkmarks when the index declares labels; the **Sort** menu; and a refresh button. Controls wrap onto additional rows when space is limited, keeping search, filtering, sorting, and refresh available on mobile. Category pills, with **All** first, follow when the index declares categories.
Each card shows the icon, localized title, label tags, a one-line description, and the default version, author, and publish date, with one action: **Install** for an installable entry; a disabled **Install** whose tooltip says no version is compatible with the running DSH when the plugin has no default version and its highest version is neither compatible nor exempted; or **Installed**, **Update available**, or **Provided by DSH**, which switches to the Installed tab and opens that bundle's page when that tab lists it. Selecting a card opens the entry's page with its full description, compatibility notice, and, under the source heading, the default version's facts: author; homepage and source code repository, as links that open in a new tab, with the package directory beside the repository; the npm package and registry, or the Git repository, full commit, and directory; publish date; category; labels; and index title. The page shows the long description first, without a heading of its own, then **Versions**, then the source facts. The **Versions** list is in descending SemVer order; each row tags the recommended, prerelease, installed, incompatible, and exempted versions and offers **Install** for any other version installation accepts, except for a bundle DSH provides. The page's back link returns to the list with its search, filters, and sort intact.
@@ -26,7 +26,7 @@ kind: "package-reference"
打开**插件**并选择**发现**。没有索引源时,该标签页显示空状态,其中的**添加索引源**按钮打开**索引源**对话框。对话框按插件索引标题列出每个索引源(首次读取前按主机名),并显示其 URL、最近一次读取失败的错误和删除按钮,同时保留一条提示,说明索引由第三方提供。URL 被拒绝时保留草稿并说明原因:不支持的协议、包含凭据或片段、URL 已在列表中,或当前部署无法保存索引源。新添加的索引源成为所选插件索引。
工具栏包含:插件索引菜单,用于切换插件索引,最后一项为**管理索引源…**;搜索框;插件索引声明了标签时显示的**标签**菜单,以勾选标记支持多选;**排序**菜单;以及刷新按钮。插件索引声明了分类时,其后显示分类按钮,**全部**排在第一位。
工具栏包含:插件索引菜单,用于切换插件索引,最后一项为**管理索引源…**;搜索框;插件索引声明了标签时显示的**标签**菜单,以勾选标记支持多选;**排序**菜单;以及刷新按钮。空间不足时,控件自动换行,确保手机端仍可使用搜索、筛选、排序和刷新。插件索引声明了分类时,其后显示分类按钮,**全部**排在第一位。
每张卡片显示图标、本地化标题、标签、一行描述,以及默认版本、作者和发布日期,并只有一个操作:可安装的条目显示**安装**;没有默认版本、且最高版本既不兼容也未豁免的条目显示禁用的**安装**,其提示框说明没有与当前运行的 DSH 兼容的版本;其余条目显示**已安装**、**有新版本**或**随 DSH 提供**,点击后切换到「已安装」标签页,并在该标签页列出该组合包时打开其页面。选择卡片会打开条目页面,显示其完整描述、兼容性提示,并在来源标题下显示默认版本的信息:作者;主页和源代码仓库(在新标签页中打开的链接,仓库旁显示包所在目录);npm 包和注册表,或 Git 仓库、完整提交和目录;发布日期;分类;标签;以及插件索引标题。页面先显示详细介绍,不带单独的标题,然后是**版本**,然后是来源信息。**版本**列表按 SemVer 降序排列;每行标出推荐、预发布、已安装、不兼容和已豁免的版本,并为安装检查接受的其他版本提供**安装**,DSH 随附的组合包除外。页面的返回链接回到列表,搜索、筛选和排序保持不变。
@@ -9,6 +9,7 @@
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
@@ -460,7 +460,7 @@ function Browser({ state, listing, t, face, hooks, language, onInstall, openPack
const busy = listing.status === 'loading' && value !== null
return (
<>
<div className={css.toolbar}>
<div className={css.toolbar} data-discover-toolbar>
<Menu open={menu === 'market'} onClose={() => { setMenu(null) }} portal autoFocus selectedId={listing.url}
items={state.indexes.map(index => ({ id: index.url, label: indexLabel(index, face.resolveText) }))}
footer={[{ id: 'manage', label: t('manageSources'), icon: <IconSlidersTwoOutlineRegular size={14} /> }]}
@@ -65,7 +65,7 @@ function tabInfo(params: unknown, revision = 1): SidebarRightTabInfo {
}
return {
sidebar: { expanded: true, fullscreen: false },
panel: { id: 'pane-1' as PaneId },
panel: { id: 'pane-1' as PaneId, isFloating: false },
tab: {
...TAB,
title: '',
@@ -21,8 +21,8 @@
en: f4817eb2e3537ed9
zh: e589c193fa85473f
/deepseek-ai-dsh-client-ui-settings-models/use-this-package/editing-a-provider:
en: a4ac56de00a7e59b
zh: 9b3f914dc43393c7
en: ce52da7e6065ccc4
zh: c63690cb5de5ea85
/deepseek-ai-dsh-client-ui-settings-models/use-this-package/adding-and-deleting-providers:
en: c0b04b2b11af60d3
zh: d195eec281e445cb
+1 -1
View File
@@ -59,7 +59,7 @@ The DeepSeek card edits the `llm-deepseek` endpoint, credentials, and model cata
Drag a model’s reorder handle to change its position within the provider, or focus the handle and use the up and down arrow keys. Apply saves the order; Cancel discards it. Each model keeps its settings, expanded options, and unfinished capacity text when moved. The full-row drag preview and reduced-motion-aware row animations share primitives with workspace reordering; before/after insertion markers follow the same style.
Expand **Customized settings → Model options** to edit each model. Both provider families share the same row layout, labels, and icons: context window and max output tokens occupy two columns, and **Input types** occupies a separate row with **Text** and **Image** checkboxes. A row without an input declaration displays the installed model’s input types, then the provider default, then Text. Known pi-ai providers load their installed catalog without endpoint I/O; opening a row does not write an override. Explicit input selections take precedence, including text-only overrides of vision models. Checkbox edits save the selected types, with at least one type required. DeepSeek writes `inputModalities`; pi-ai writes `input`. Unchecking Image for DeepSeek also removes `imagePixelBudget` and `imageMaxBytes`, because the adapter rejects those limits without image input. Clearing the input field in `cordis.patch.yml` restores adapter inheritance; **Restore default models** resets the entire catalog override. Declare only input types the upstream model can actually process.
Expand **Customized settings → Model options** to edit each model. When the model row is narrow, the main row shows only the model ID; expand the row to view or edit its display name. Wider rows show both fields side by side. Focusing a display name expands its row and keeps it open until explicitly collapsed. Tab reaches the option and delete buttons before the display name at every width. Both provider families share the same row layout, labels, and icons: context window and max output tokens occupy two columns, and **Input types** occupies a separate row with **Text** and **Image** checkboxes. A row without an input declaration displays the installed model’s input types, then the provider default, then Text. Known pi-ai providers load their installed catalog without endpoint I/O; opening a row does not write an override. Explicit input selections take precedence, including text-only overrides of vision models. Checkbox edits save the selected types, with at least one type required. DeepSeek writes `inputModalities`; pi-ai writes `input`. Unchecking Image for DeepSeek also removes `imagePixelBudget` and `imageMaxBytes`, because the adapter rejects those limits without image input. Clearing the input field in `cordis.patch.yml` restores adapter inheritance; **Restore default models** resets the entire catalog override. Declare only input types the upstream model can actually process.
A model row on a hand-declared pi-ai route also edits its **Reasoning levels**, the `reasoningEfforts` the adapter already reads from `cordis.patch.yml`; the **?** beside the label explains the two columns. With the switch off the field is absent, so the model does not reason and the model picker offers no level; a stored `false` is kept until the switch is used. Turning it on always starts from rows for off, low, medium, and high; it does not restore levels removed earlier, and **Cancel** discards the edit instead. Each row pairs a level select — the keys `llm-pi-ai`'s configuration schema accepts in `reasoningEfforts`, read from the namespace schema like the protocol choices and today `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`, each usable once — with the **API value** sent for it, which starts as the level name; a field cleared while typing stores the level name and shows it once the field is left. Off only turns reasoning off, so its value field is disabled; a value already written for it in `cordis.patch.yml` is shown and kept. Changing a row's level resets a value still spelled as the old level; a custom value carries over only between two thinking levels, so changing a row to or from Off resets it. A stored key the schema does not list keeps its row and stays selectable there. Rows can be added and deleted and are saved in row order, while the model picker lists the levels in escalation order. The section refuses what the adapter refuses: no level besides Off — including a `reasoningEfforts:` left without a value, which shows the switch on with no rows — or a value that is missing on a level besides Off or empty on any level. A refused value marks its field invalid, and leaving that field fills in the level name. The rows keep no state of their own, so a model row reused after a deletion, a reset, or a fold shows only its own model. Routes served from the installed catalog and the DeepSeek card keep the adapter's levels and show no reasoning control.
@@ -59,7 +59,7 @@ API 密钥输入框初始为空,并通过 `autocomplete="new-password"` 请求
拖动模型的排序手柄可调整它在当前提供商内的位置,也可以聚焦手柄后使用上下方向键。保存会保留顺序,取消会放弃修改。移动时,每个模型的参数、已展开选项和未完成的容量输入都会随模型保留。整行拖拽预览及遵循减少动态效果设置的行动画,与工作区排序共用基础组件;前后插入标记沿用相同样式。
展开**自定义设置 → 模型选项**编辑模型。两个提供商家族共用相同的模型行布局、标签和图标:上下文窗口与最大输出 token 数分为两列,**输入类型**独占下一行,提供**文本**和**图片**复选框。未声明输入类型的模型行优先显示已安装模型的输入类型,其次是提供商默认值,最后回退为文本。已知 pi-ai 提供商会加载已安装目录,不向端点发送请求;打开模型行不会写入覆盖值。显式输入选择具有优先权,包括为视觉模型设置的仅文本覆盖。修改复选框会保存所选类型,且至少保留一种。DeepSeek 写入 `inputModalities`,pi-ai 写入 `input`。DeepSeek 取消勾选图片时,还会移除 `imagePixelBudget` 和 `imageMaxBytes`,因为适配器在没有图片输入时拒绝这些限制。在 `cordis.patch.yml` 中清除输入字段可恢复适配器继承;**恢复默认模型**会重置整个模型目录覆盖。仅声明上游模型实际能够处理的输入类型。
展开**自定义设置 → 模型选项**编辑模型。模型行较窄时,主行仅显示模型 ID,展开模型行后可查看或编辑显示名称;宽度充足时,两个输入框并排显示。显示名称获得焦点时会展开该行,并保持展开,直到显式收起。所有宽度下,Tab 都先经过模型选项和删除按钮,再到显示名称。两个提供商家族共用相同的模型行布局、标签和图标:上下文窗口与最大输出 token 数分为两列,**输入类型**独占下一行,提供**文本**和**图片**复选框。未声明输入类型的模型行优先显示已安装模型的输入类型,其次是提供商默认值,最后回退为文本。已知 pi-ai 提供商会加载已安装目录,不向端点发送请求;打开模型行不会写入覆盖值。显式输入选择具有优先权,包括为视觉模型设置的仅文本覆盖。修改复选框会保存所选类型,且至少保留一种。DeepSeek 写入 `inputModalities`,pi-ai 写入 `input`。DeepSeek 取消勾选图片时,还会移除 `imagePixelBudget` 和 `imageMaxBytes`,因为适配器在没有图片输入时拒绝这些限制。在 `cordis.patch.yml` 中清除输入字段可恢复适配器继承;**恢复默认模型**会重置整个模型目录覆盖。仅声明上游模型实际能够处理的输入类型。
手工声明的 pi-ai 路由上,模型行还会编辑**推理等级**,即适配器本来就从 `cordis.patch.yml` 读取的 `reasoningEfforts`;标签旁的 **?** 说明两列的含义。开关关闭时该字段不存在,模型不推理,模型选择器不提供任何档位;已存储的 `false` 在开关被操作前保持不变。打开开关总是从 off、low、medium 和 high 四行开始,不会恢复之前删掉的档位;要放弃修改请点**取消**。每行把一个档位选择框——可选值是 `llm-pi-ai` 配置 schema 允许作为 `reasoningEfforts` 键的档位,与协议选项一样从命名空间 schema 读取,目前为 `off`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`,每个档位只能用一次——与发送给它的 **API 参数值**配成一对,参数值初始为档位名;输入时清空的字段会存为档位名,离开字段后显示出来。Off 只表示关闭思考,因此它的参数值输入框是禁用的;`cordis.patch.yml` 中已为它写好的值会显示并保留。修改某行的档位时,仍是旧档位名的参数值会随之重置;自定义值只在两个思考档位之间保留,切到或切离 Off 都会重置。schema 未列出的已存键会保留自己的行,并可在该行中继续选择。行可以增删,并按行的顺序保存,模型选择器则按升级顺序列出档位。本节拒绝适配器会拒绝的配置:没有 Off 以外的档位(包括未写值的 `reasoningEfforts:`,此时开关显示为打开且没有任何行),或者 Off 以外的档位缺少参数值、任意档位的参数值为空字符串。被拒绝的值会把对应输入框标为无效,离开该输入框时会填入档位名。各行不保留自己的状态,因此删除、恢复默认或折叠之后被复用的模型行只显示它自己的模型。走已安装目录的路由和 DeepSeek 卡片沿用适配器的档位,不显示推理等级控件。
@@ -49,13 +49,34 @@ function rowHalf(event: DragEvent<HTMLElement>): 'before' | 'after' {
}
/**
* Render consistent model identity, capacity, and input-type controls.
* Render model fields with the display name in the expanded area of narrow rows.
* @param props - drafted fields and their owning editor's actions.
* @returns one expandable model entry.
*/
export function ModelRow(props: ModelRowProps): ReactNode {
const { model, position, t, disabled } = props
const preview = useRowDragPreview(styles['modelDragPreview'] as string)
const modelField = (field: 'id' | 'name') => (
<label className={`${styles['modelField']} ${field === 'name' ? styles['modelName'] : ''}`}>
{field === 'name'
? <span className={`${styles['modelFieldLabel']} ${styles['modelNameLabel']}`}>{t('modelName')}</span>
: null}
<input
className={styles['input']}
type="text"
value={typeof model[field] === 'string' ? model[field] : ''}
placeholder={t(field === 'id' ? 'modelId' : 'modelName')}
aria-label={`${t(field === 'id' ? 'modelId' : 'modelName')} ${String(position)}`}
disabled={disabled}
onFocus={field === 'name' && !props.expanded ? props.onToggle : undefined}
onChange={(event) => {
const value = event.target.value
props.onFieldChange(field, field === 'name' && value === '' ? undefined : value)
}}
onBlur={field === 'id' ? event => props.onIdBlur?.(event.target.value) : undefined}
/>
</label>
)
return (
<div
className={styles['modelEntry']}
@@ -78,8 +99,8 @@ export function ModelRow(props: ModelRowProps): ReactNode {
props.order.onDrop(rowHalf(event))
}}
>
<div className={styles['modelRow']}>
<Tooltip label={t('reorderModelHelp')}>
<div className={styles['modelRow']} data-expanded={props.expanded || undefined}>
<Tooltip label={t('reorderModelHelp')} portal>
<button
type="button"
className={`${styles['iconButton']} ${styles['modelDragHandle']}`}
@@ -105,29 +126,14 @@ export function ModelRow(props: ModelRowProps): ReactNode {
<IconDragHandleOutlineRegular size={14} />
</button>
</Tooltip>
{(['id', 'name'] as const).map(field => (
<input
key={field}
className={styles['input']}
type="text"
value={typeof model[field] === 'string' ? model[field] : ''}
placeholder={t(field === 'id' ? 'modelId' : 'modelName')}
aria-label={`${t(field === 'id' ? 'modelId' : 'modelName')} ${String(position)}`}
disabled={disabled}
onChange={(event) => {
const value = event.target.value
props.onFieldChange(field, field === 'name' && value === '' ? undefined : value)
}}
onBlur={field === 'id' ? event => props.onIdBlur?.(event.target.value) : undefined}
/>
))}
{modelField('id')}
<button
type="button"
className={styles['iconButton']}
aria-label={`${t('modelAdvanced')} ${String(position)}`}
aria-expanded={props.expanded}
title={t('modelAdvanced')}
onClick={props.onToggle}
onClick={(event) => { event.currentTarget.focus(); props.onToggle() }}
>
{props.expanded ? <IconChevronDownOutlineRegular /> : <IconChevronRightOutlineRegular />}
</button>
@@ -141,40 +147,41 @@ export function ModelRow(props: ModelRowProps): ReactNode {
>
<IconTrashOutlineRegular size={14} />
</button>
{modelField('name')}
{props.expanded
? (
<div className={styles['modelAdvanced']}>
{(['contextWindow', 'maxTokens'] as const).map(field => (
<label className={styles['modelField']} key={field}>
<span className={styles['modelFieldLabel']}>{t(field)}</span>
<input
className={styles['input']}
type="text"
inputMode="numeric"
value={props[field].value}
placeholder={props[field].placeholder}
aria-label={`${t(field)} ${String(position)}`}
disabled={disabled}
onChange={(event) => { props[field].onChange(event.target.value) }}
onBlur={props[field].onBlur}
/>
</label>
))}
<ModelInputTypes
model={model} field={props.inputField} position={position}
fallback={props.inputFallback} disabled={disabled || props.inputLoading === true} t={t} onChange={props.onChange}
/>
{props.reasoningLevels !== undefined && props.reasoningLevels.length > 0
? (
<ModelReasoningEfforts
model={model} levels={props.reasoningLevels} position={position} disabled={disabled} t={t} onChange={props.onChange}
/>
)
: null}
</div>
)
: null}
</div>
{props.expanded
? (
<div className={styles['modelAdvanced']}>
{(['contextWindow', 'maxTokens'] as const).map(field => (
<label className={styles['modelField']} key={field}>
<span className={styles['modelFieldLabel']}>{t(field)}</span>
<input
className={styles['input']}
type="text"
inputMode="numeric"
value={props[field].value}
placeholder={props[field].placeholder}
aria-label={`${t(field)} ${String(position)}`}
disabled={disabled}
onChange={(event) => { props[field].onChange(event.target.value) }}
onBlur={props[field].onBlur}
/>
</label>
))}
<ModelInputTypes
model={model} field={props.inputField} position={position}
fallback={props.inputFallback} disabled={disabled || props.inputLoading === true} t={t} onChange={props.onChange}
/>
{props.reasoningLevels !== undefined && props.reasoningLevels.length > 0
? (
<ModelReasoningEfforts
model={model} levels={props.reasoningLevels} position={position} disabled={disabled} t={t} onChange={props.onChange}
/>
)
: null}
</div>
)
: null}
</div>
)
}
@@ -507,6 +507,7 @@
}
.modelEntry {
container: model-row / inline-size;
position: relative;
border: 0.5px solid var(--dsw-alias-border-l4);
border-radius: var(--dsw-radius-lg);
@@ -520,6 +521,36 @@
gap: 6px;
}
.modelName {
grid-column: 3;
grid-row: 1;
}
.modelNameLabel {
display: none;
}
@container model-row (max-width: 440px) {
.modelRow {
grid-template-columns: auto minmax(0, 1fr) auto auto;
}
.modelRow .modelName {
display: none;
grid-column: 1 / -1;
grid-row: 2;
padding: 2px 4px 0;
}
.modelRow[data-expanded] .modelName {
display: flex;
}
.modelNameLabel {
display: block;
}
}
.modelDragHandle:not(:disabled) {
cursor: grab;
}
@@ -528,6 +559,10 @@
opacity: 0.5;
}
.modelEntry[data-drop-target] {
z-index: 1;
}
.modelDragPreview {
background: var(--dsw-alias-bg-module-platform);
}
@@ -618,9 +653,10 @@
.modelAdvanced {
display: grid;
grid-column: 1 / -1;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
padding: 8px 4px 2px;
padding: 2px 4px;
}
.modelField {
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
/** Provider-local ordering preserves row instances and unfinished model edits. */
import { useState } from 'react'
import { cleanup, createEvent, fireEvent, render, screen, within } from '@testing-library/react'
import { act, cleanup, createEvent, fireEvent, render, screen, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { DeepSeekModelsEditor } from '../src/client/DeepSeekModelsEditor.tsx'
import type { DeepSeekModelDraft } from '../src/client/DeepSeekModelsEditor.tsx'
@@ -62,6 +62,21 @@ function leave(target: HTMLElement, relatedTarget: Node | null = null) {
}
describe.each(['deepseek', 'pi-ai'] as const)('%s model ordering', (family) => {
it('keeps name editing expanded after blur and restores disclosure focus on collapse', () => {
render(<Editor family={family} initial={[{ id: 'first', name: 'First' }]} changed={vi.fn()} />)
const name = screen.getByLabelText<HTMLInputElement>(`${en.modelName} 1`)
const toggle = screen.getByRole('button', { name: `${en.modelAdvanced} 1` })
act(() => { name.focus() })
expect(toggle.getAttribute('aria-expanded')).toBe('true')
act(() => { handle(1).focus() })
expect(toggle.getAttribute('aria-expanded')).toBe('true')
act(() => { name.focus() })
fireEvent.click(toggle)
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(document.activeElement).toBe(toggle)
expect(screen.getByLabelText(`${en.modelName} 1`)).toBe(name)
})
it('moves a complete model and its expanded capacity draft to the drop position', () => {
const first = { id: 'first', name: 'First', contextWindow: 4096, input: ['image'], hidden: { preserved: true } }
const second = { id: 'second', maxTokens: 1024 }
@@ -51,7 +51,7 @@ function mountBrowser(navigation?: { readonly url?: string },
& Omit<BrowserInjected, 'keyedHooks'> = {
sessionId: SESSION,
useTabInfo: () => ({
sidebar: { expanded: true, fullscreen: false }, panel: { id: 'pane' as PaneId },
sidebar: { expanded: true, fullscreen: false }, panel: { id: 'pane' as PaneId, isFloating: false },
tab: {
id: TAB, kind: 'browser', title: 'Browser', contentId: 'sidebar://browser/1', visible: true,
navigation: { address: 'sidebar://browser/1', params: navigation, revision: 0 },
@@ -15,14 +15,14 @@
en: 0dd8c3a22e28aacf
zh: c27c11bf4b5cceba
/deepseek-ai-dsh-client-ui-sidebar-documentpreview/what-it-registers:
en: a02e6fd89e332967
zh: b5c61cb75458d1a2
en: 5686f49d98437f49
zh: 40c154f8cc7b8d14
/deepseek-ai-dsh-client-ui-sidebar-documentpreview/addresses:
en: 57942270e23d3a8e
zh: 6617124e652b3f67
/deepseek-ai-dsh-client-ui-sidebar-documentpreview/how-it-reads:
en: e197303166b5a86f
zh: 8d151e49321d67aa
en: e3ca76083e667293
zh: 4b52ffa2848d9f87
/deepseek-ai-dsh-client-ui-sidebar-documentpreview/excel-preview:
en: 595499992ceea693
zh: 83c6147c46a80dca
@@ -30,8 +30,8 @@
en: 372d8ce6492e6896
zh: 912f613bc9221aa3
/deepseek-ai-dsh-client-ui-sidebar-documentpreview/navigation:
en: 7313ef5591ef162d
zh: fbc3948329ac5bab
en: 33215200b3e4a8ed
zh: 499e4b834f2db95a
/deepseek-ai-dsh-client-ui-sidebar-documentpreview/model-experience:
en: ca1deb99d0e44755
zh: f285fc56cee669ec
@@ -39,8 +39,8 @@
en: 7edb4c0bb5f13847
zh: ae5d0bbebe4aa563
/deepseek-ai-dsh-client-ui-sidebar-documentpreview/known-limitations-and-deferred-work:
en: 7be2ad2e198131f8
zh: 21b35eae72a49ad2
en: ad44f28dc14beb4a
zh: e9f096bd9e63e72e
/deepseek-ai-dsh-client-ui-sidebar-documentpreview/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: e88aeb617ffb74ea
@@ -31,10 +31,10 @@ Preview files in the right Sidebar and choose among registered renderers. Markdo
Image drag actions arrive through the image entry’s inject face from [ui-file-drag](../ui-file-drag/README.md).
- **The type** — `ctx.sidebarRightTabs.register(...)` with id `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` (this implementation's identity in the tab system, and the key its body registers under), kind `text`, a whole-address file-resource pattern, band `fallback`. `canOpen` accepts only Session addresses, whose paths may be relative or absolute; bare `absolute` addresses are not claimed. A type registered at the `extension` or `builtin` band for a narrower pattern (say `*.png`) takes those addresses; other supported files land here. The whole address is the content identity, so two files with one name in different directories, or one path under two sessions, are two tabs; the decoded basename is the tab title, and the keyed `sidebar.right.pane.tab.title` seat places its extension-specific `FileTypeIcon` before that title.
- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path; the shared [`PathLabel`](../ui-primitives/README.md#component-catalog) subdues directories, preserves a clipped path’s trailing characters with a left-edge fade, and exposes the full value on hover. A dropdown appears when multiple supported renderers are available. Plain text is offered only for text-compatible sources; a single renderer shows no viewer control. A known binary container suffix without a registered renderer shows the file-type icon and an unsupported-preview message under the path header, without issuing a read. Two Session-scoped list child slots hand the previewed file to other plugins once its metadata reports a Host path, both with `absolutePath` as the owner prop: `sidebar.right.tab.document.actions` renders after the header's own controls in every state that shows the header, and `sidebar.right.tab.document.unpreviewable` renders in the unsupported empty state and in the empty state of a readable file the preview cannot render (`not-text`, `too-large`), where Retry would otherwise stand; a carrier or unclassified read failure keeps Retry, and a missing or non-regular path offers only its explanation. The shipped occupant of both is [`ui-open-in-app`](../ui-open-in-app/README.md). A wrap toggle appears only when the selected renderer declares `wrap: true`; its glyph describes the mode the click selects, and the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. Automatic refresh defaults to enabled; its separate toggle is hidden, with state, labels, styles, and toggle logic retained. The body reaches every pane edge; each renderer owns its content inset and may own an inner scrollport. This intentionally differs from the Files tab's 2px right-side scrollbar offset: previews keep the full pane width so edge-to-edge HTML and code scrollports end at the pane edge.
- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path; the shared [`PathLabel`](../ui-primitives/README.md#component-catalog) subdues directories, preserves a clipped path’s trailing characters with a left-edge fade, and exposes the full value on hover. A dropdown appears when multiple supported renderers are available. Plain text is offered only for text-compatible sources; a single renderer shows no viewer control. A known binary container suffix without a registered renderer shows the file-type icon and an unsupported-preview message under the path header, without issuing a read. Two Session-scoped list child slots hand the previewed file to other plugins once its metadata reports a Host path, both with `absolutePath` as the owner prop: `sidebar.right.tab.document.actions` renders after the header's own controls and before the navigator toggle, at the same position in every state that shows the header so it is not remounted when the state changes, and `sidebar.right.tab.document.unpreviewable` renders in the unsupported empty state and in the empty state of a readable file the preview cannot render (`not-text`, `too-large`), where Retry would otherwise stand; a carrier or unclassified read failure keeps Retry, and a missing or non-regular path offers only its explanation. The shipped occupant of both is [`ui-open-in-app`](../ui-open-in-app/README.md). The preview directly renders [`ui-sidebar-files`](../ui-sidebar-files/README.md#the-navigator)'s Session-scoped Factories: `sidebar.files.navigator` after the body in its content row with `{ useTabInfo, page: false, onOpen }`, where `onOpen(address)` calls `tab.actions.openResource(address, { replaceTab: true })`, and `sidebar.files.navigator.toggle` at the header's end with `{ useTabInfo }`. Both stay mounted from loading to loaded in docked and floating panes; unregistered Factories render nothing. The navigator owns its width within the row's positioning box and stacking context, narrowing or overlaying the body without covering the floating pane's resize handle. A renderer layer handling Escape calls `preventDefault()` to keep the navigator from also closing. A wrap toggle appears only when the selected renderer declares `wrap: true`; its glyph describes the mode the click selects, and the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. Automatic refresh defaults to enabled; its separate toggle is hidden, with state, labels, styles, and toggle logic retained. The body reaches every pane edge; each renderer owns its content inset and may own an inner scrollport. This intentionally differs from the file tree's 2px right-side scrollbar offset: previews keep the full pane width so edge-to-edge HTML and code scrollports end at the pane edge.
- **Shared loading and view state**, session-scoped and bucketed by tab id. The store holds accumulated pages or complete bytes, read and observed versions, pending Resource changes, automatic-refresh state, loading/failure state, renderer choice, scroll offset, wrap, and the answered navigation revision. The ordinary inject face calls Remote readers and writes through declared store actions. Reloads and loading-mode changes retire older requests; the tab's abort signal forgets its state.
Document implementations register metadata with `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap? })` and a body under the same `id` in the keyed, Session-scoped `sidebar.right.tab.document` child slot. `binaryExtensions` lists suffixes in `extensions` that cannot be read as text and omit the plain-text option. Own both registrations with effects and wait for the child slot through `ctx.slots.inject`. Bodies receive `resourceAddress`, `content`, `wrap`, `scrollportRef`, `addResource`, `setResources`, and the standard `useTabInfo`/`useResource` hooks. Dependency membership belongs to a tab-owned `ResourceGroup`; replacing dependencies always retains the root Resource. An inner scrolling element attaches `scrollportRef`; unmounting restores the shared body as scroll owner. The registry retains all matching alternatives: `extension` (the default) ranks above `builtin`, then longer suffixes rank first, then registration order. The dropdown preserves a selected implementation while it remains available. HTML, SVG, and unmatched extensions retain the plain-text fallback independently of loading mode.
Document implementations register metadata with `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap?, sourceLines? })` and a body under the same `id` in the keyed, Session-scoped `sidebar.right.tab.document` child slot. `binaryExtensions` lists suffixes in `extensions` that cannot be read as text and omit the plain-text option. `sourceLines` declares that a `text-pages` body exposes source-line anchors for [navigation](#navigation). Own both registrations with effects and wait for the child slot through `ctx.slots.inject`. Bodies receive `resourceAddress`, `content`, `wrap`, `scrollportRef`, `addResource`, `setResources`, and the standard `useTabInfo`/`useResource` hooks. Dependency membership belongs to a tab-owned `ResourceGroup`; replacing dependencies always retains the root Resource. An inner scrolling element attaches `scrollportRef`; unmounting restores the shared body as scroll owner. The registry retains all matching alternatives: `extension` (the default) ranks above `builtin`, then longer suffixes rank first, then registration order. The dropdown preserves a selected implementation while it remains available. HTML, SVG, and unmatched extensions retain the plain-text fallback independently of loading mode.
`loading: 'text-pages'` and `'bytes-complete'` use the shared file reader. With `'renderer'`, the selected body mounts before any bytes are read and receives `content: { kind: 'renderer', revision, loaded, failed, reload }`. Its injected face owns content loading, errors, store updates, and cancellation. `loaded(version)` records the displayed source version and ends loading. `failed()` ends loading without recording a successful version, allowing a later file change to retry; reports from replaced revisions are ignored. `reload()` increments the revision, which the body observes to cancel and replace its request. The body also cancels on unmount and tab closure, retains settled content in its declared tab store, and releases that state when the tab ends. [Office previews](#office-preview) use this mode without putting converted bytes or font metadata in the shared file store.
@@ -57,7 +57,7 @@ The body reads its record, navigation and lifetime through `useTabInfo().tab`. `
- 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; loaded content remains while metadata is unavailable.
- **Text pages** — plain text, Markdown, and code read through an inject callback to `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`. The first mount reads page one; scrolling to the body end or **Load more** requests the next page until `eof`. The owner delivers the accumulated prefix as `{ kind: 'text', text, pages, eof }`, including source offsets and line counts. Markdown and code render that prefix incrementally; they do not render each page as a separate document. A newer-version page past page one restarts from the beginning rather than mixing versions. A failure before any content fills the body with the file-type icon, the explanation, and the recourse the failure names (Retry, the unpreviewable child slot, or nothing); a later failure retains loaded content and adds the retry below it.
- **Complete bytes** — PDF, HTML, common images, and spreadsheets call `remote.workspaceFiles.readBytes(sessionId, path, {}, signal)`. The binary Remote returns `data: Uint8Array<ArrayBuffer>` directly for `{ kind: 'bytes', data }`. The Host's `maxFileBytes` cap rejects oversized files rather than truncating them. PDF and spreadsheet renderers copy 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** — manual reload rereads only the current Preview tab through its Remote callbacks, preserving its scroll preference and retiring older requests. Automatic refresh uses the same callbacks after a `ResourceGroup` member changes. Each member's first metadata establishes a baseline without triggering reload or first-read version reconciliation; later changes received during a read remain pending for another refresh. Reads neither refresh shared metadata nor clear another tab's notice.
- **Reload** — manual reload rereads only the current Preview tab through its Remote callbacks, preserving its scroll preference and retiring older requests. Automatic refresh uses the same callbacks after a `ResourceGroup` member changes. Each tab remembers its scroll offset; new content moves the view down to that offset when it is below the current one, and text previews read further pages until the offset is reachable or the file ends. A scroll event at the end of content shorter than the remembered offset is a layout clamp and leaves the offset unchanged, so a file that shrinks and grows back returns to it. The offset is a pixel distance; it does not follow a paragraph that moves when earlier content changes. Each member's first metadata establishes a baseline without triggering reload or first-read version reconciliation; later changes received during a read remain pending for another refresh. Reads neither refresh shared metadata nor clear another tab's notice.
When Coding 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 injected Remote callback, with fixed safety limits of 4 MiB per asset, 32 MiB total, and 64 distinct assets. Host code resolves the related path, and `readBytes` with `baseFile` returns native bytes. The dependency Resource is added after the read returns, using its `absolutePath` or a string `error.details.path` on failure; without a Host path, the Client does not guess one. 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.
@@ -125,7 +125,7 @@ The page refresh shortcut reuses the selected preview’s reload operation, incl
<a id="navigation"></a>
## Navigation
`ctx.sidebarRight.openResource(address, { params: { line } })` carries a 1-based source line through the `file` parameters. In `text-pages` mode, the owner loads sequential pages until that line or EOF. Plain-text and code renderers expose source-line anchors; Markdown does not. A navigation remains pending while its selected renderer has no anchor and runs if the user switches to plain text or code. Code navigation scrolls the inner source viewport directly. The landed line receives a band that holds for 0.6 s and then fades out over 1.4 s; toggling wrap or resizing the preview removes it early; restoring a remounted tab's scroll position adds no band. Byte-mode renderers do not consume source-line navigation. Each completed navigation revision is answered once. Opening the same file without `revealIfOpened: false` focuses its existing tab and delivers a new revision.
`ctx.sidebarRight.openResource(address, { params: { line } })` carries a 1-based source line through the `file` parameters. In `text-pages` mode, the owner loads sequential pages until that line or EOF. Plain-text and code renderers declare `sourceLines`; Markdown does not. A renderer without `sourceLines` answers the navigation without moving the body, and a later switch to plain text or code does not replay it. A navigation stays pending while a `sourceLines` renderer has not drawn the line. Code navigation scrolls the inner source viewport directly. The landed line receives a band that holds for 0.6 s and then fades out over 1.4 s; toggling wrap or resizing the preview removes it early; restoring a remounted tab's scroll position adds no band. Byte-mode renderers do not consume source-line navigation. Each completed navigation revision is answered once. Opening the same file without `revealIfOpened: false` focuses its existing tab and delivers a new revision.
Document path labels expose a file [context menu](../ui-context-menu/README.md), retaining the selected line and current pane.
@@ -147,7 +147,7 @@ No direct effect; what the user reads here never enters a model request.
- **PDF raster allocation is bounded.** Each page bitmap is capped at 16,777,216 pixels; very large pages or high zoom on high-density displays can still render below device resolution.
- **Byte-view scroll state is not restored.** PDF, HTML, and images can return to the top when their renderer remounts or reloads; fixed PDF and image zoom can overflow horizontally, and HTML iframe scrolling belongs to its opaque browsing context.
- **Finite local HTML dependencies.** Only direct classic `.js` and stylesheet `.css` references are packed. Browser-resolved resources retain browser origin and network restrictions; no runtime file-read bridge is exposed to the iframe.
- **Scroll writes are unthrottled.** Every scroll event records its offset in the store; the line blocks are memoized so the resulting re-render hands React the same elements back.
- **Scroll writes are unthrottled.** Every scroll event except a layout clamp records its offset in the store; the line blocks are memoized so the resulting re-render hands React the same elements back.
- **A failed PDF chunk load requires a page reload.** React caches a rejected lazy import for the page lifetime; ordinary PDF open or render failures remain retryable inside the loaded body.
<a id="dev-note"></a>
@@ -31,10 +31,10 @@ kind: "package-reference"
图片拖放回调通过图片入口的注入接口从 [ui-file-drag](../ui-file-drag/README.zh.md) 获取。
- **类型** —— `ctx.sidebarRightTabs.register(...)`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`(这个实现在 tab 系统中的身份,也是其正文注册所用的键),kind `text`,匹配完整文件资源地址的 pattern,档位 `fallback`。`canOpen` 只接受 Session 地址,其中路径可为相对或绝对路径;不认领裸 `absolute` 地址。在 `extension` 或 `builtin` 档以更窄 pattern(比如 `*.png`)注册的类型接走那些地址;其他受支持文件落到这里。整个地址就是内容身份,所以不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab;解码后的 basename 是 tab 标题,keyed slot `sidebar.right.pane.tab.title` 会在标题前放置按扩展名选择的 `FileTypeIcon`。
- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;共享的 [`PathLabel`](../ui-primitives/README.zh.md#component-catalog) 弱化目录颜色,以左侧渐隐保留被裁剪路径的尾部字符,并在悬停时展示完整值。有多个受支持的渲染器时才显示下拉菜单。仅文本兼容的源文件提供纯文本选项;只有一个渲染器时不显示查看器控件。已知的二进制容器后缀没有注册渲染器时,在路径头部下方显示文件类型图标和不支持预览的说明,并且不会发起读取。两个 Session 作用域的 list 子 slot 在文件元数据报出 Host 路径后把当前文件交给其他插件,owner prop 为 `absolutePath`:`sidebar.right.tab.document.actions` 在每个显示头部的状态里渲染在头部自有控件之后;`sidebar.right.tab.document.unpreviewable` 渲染在不支持预览的空态里,以及可读但本预览无法渲染的文件(`not-text`、`too-large`)的空态里,占据原本放重试的位置;载体或未分类的读取失败仍保留重试,不存在或非普通文件的路径只给说明。两者已发布的占位者都是 [`ui-open-in-app`](../ui-open-in-app/README.zh.md)。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。自动刷新默认开启;独立开关暂时隐藏,状态、文案、样式与切换逻辑保留。正文贴合格的每条边,各渲染器自行提供内容留白,并可拥有内部滚动区。这与 Files tab 右侧预留 2px 滚动条间距的布局有意不同:Preview 使用格的完整宽度,使贴边 HTML 与代码滚动区终止于格的边缘。
- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;共享的 [`PathLabel`](../ui-primitives/README.zh.md#component-catalog) 弱化目录颜色,以左侧渐隐保留被裁剪路径的尾部字符,并在悬停时展示完整值。有多个受支持的渲染器时才显示下拉菜单。仅文本兼容的源文件提供纯文本选项;只有一个渲染器时不显示查看器控件。已知的二进制容器后缀没有注册渲染器时,在路径头部下方显示文件类型图标和不支持预览的说明,并且不会发起读取。两个 Session 作用域的 list 子 slot 在文件元数据报出 Host 路径后把当前文件交给其他插件,owner prop 为 `absolutePath`:`sidebar.right.tab.document.actions` 在每个显示头部的状态里渲染在头部自有控件之后、导航栏开关之前,位置在各状态间相同,因此状态切换时不会重新挂载;`sidebar.right.tab.document.unpreviewable` 渲染在不支持预览的空态里,以及可读但本预览无法渲染的文件(`not-text`、`too-large`)的空态里,占据原本放重试的位置;载体或未分类的读取失败仍保留重试,不存在或非普通文件的路径只给说明。两者已发布的占位者都是 [`ui-open-in-app`](../ui-open-in-app/README.zh.md)。预览直接渲染 [`ui-sidebar-files`](../ui-sidebar-files/README.zh.md#the-navigator) 的 Session 作用域 Factory:`sidebar.files.navigator` 位于内容行的正文之后,传入 `{ useTabInfo, page: false, onOpen }`,其中 `onOpen(address)` 调用 `tab.actions.openResource(address, { replaceTab: true })`;`sidebar.files.navigator.toggle` 位于头部末端,传入 `{ useTabInfo }`。两者在停靠与浮动分栏中从加载到完成保持挂载;未注册的 Factory 不绘制内容。导航栏在内容行的定位框与层叠上下文内决定宽度,让正文变窄或覆盖正文,不遮挡浮窗缩放手柄。自行处理 Escape 的渲染器浮层需调用 `preventDefault()`,避免同时关闭导航栏。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。自动刷新默认开启;独立开关暂时隐藏,状态、文案、样式与切换逻辑保留。正文贴合格的每条边,各渲染器自行提供内容留白,并可拥有内部滚动区。这与文件树右侧预留 2px 滚动条间距的布局有意不同:Preview 使用格的完整宽度,使贴边 HTML 与代码滚动区终止于格的边缘。
- **共享加载与视图状态**,会话作用域、按 tab id 分桶。store 持有累计页或完整字节、读取与观察版本、待处理 Resource 变化、自动刷新状态、加载/失败状态、渲染器选择、滚动位置、换行和已响应的导航 revision。普通 inject face 调用 Remote 读取,并经声明的 store action 写入。重新载入和加载模式变化会淘汰旧请求;tab 的中止信号清理其状态。
文档实现在 `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap? })` 注册元数据,并以相同 `id` 向 keyed、Session 作用域的子 slot `sidebar.right.tab.document` 注册正文。`binaryExtensions` 列出 `extensions` 中不可按文本阅读的后缀,这些后缀不提供纯文本选项。两处注册都由 effect 持有,通过 `ctx.slots.inject` 等待子 slot。正文接收 `resourceAddress`、`content`、`wrap`、`scrollportRef`、`addResource`、`setResources` 和标准 `useTabInfo`/`useResource` 钩子。依赖成员由 tab 持有的 `ResourceGroup` 管理;替换依赖始终保留根 Resource。内部滚动元素挂载 `scrollportRef`;卸载时恢复共享正文的滚动职责。注册表保留所有匹配备选:`extension`(默认)优先于 `builtin`,随后按更长的后缀、再按注册顺序排列。所选实现仍可用时,下拉选择保持不变。HTML、SVG 和未匹配的扩展名保留纯文本回退,与加载方式无关。
文档实现在 `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap?, sourceLines? })` 注册元数据,并以相同 `id` 向 keyed、Session 作用域的子 slot `sidebar.right.tab.document` 注册正文。`binaryExtensions` 列出 `extensions` 中不可按文本阅读的后缀,这些后缀不提供纯文本选项。`sourceLines` 声明 `text-pages` 正文为[导航](#navigation)提供源码行锚点。两处注册都由 effect 持有,通过 `ctx.slots.inject` 等待子 slot。正文接收 `resourceAddress`、`content`、`wrap`、`scrollportRef`、`addResource`、`setResources` 和标准 `useTabInfo`/`useResource` 钩子。依赖成员由 tab 持有的 `ResourceGroup` 管理;替换依赖始终保留根 Resource。内部滚动元素挂载 `scrollportRef`;卸载时恢复共享正文的滚动职责。注册表保留所有匹配备选:`extension`(默认)优先于 `builtin`,随后按更长的后缀、再按注册顺序排列。所选实现仍可用时,下拉选择保持不变。HTML、SVG 和未匹配的扩展名保留纯文本回退,与加载方式无关。
`loading: 'text-pages'` 和 `'bytes-complete'` 使用共享文件读取器。选择 `'renderer'` 时,所选正文在读取任何字节前挂载,并接收 `content: { kind: 'renderer', revision, loaded, failed, reload }`。其注入 face 负责内容加载、错误、store 更新和取消。`loaded(version)` 记录已展示的源版本并结束加载。`failed()` 结束加载但不记录成功版本,使后续文件变化可以重试;已被替换的 revision 所发出的报告会被忽略。`reload()` 增加 revision,正文据此取消并替换当前请求。正文也在卸载和 tab 关闭时取消请求,将已完成内容保留在自己声明的 tab store 中,并在 tab 结束时释放。[Office 预览](#office-preview) 使用此模式,转换后的字节和字体元数据不会进入共享文件 store。
@@ -57,7 +57,7 @@ Web 和桌面端均通过代码工作工具选择 HTML 预览策略。渲染器
- 资源快照仅包含 `status`、`value` 和 `failure`;`value` 是 `WorkspaceFileStat` 元数据。提供方可用后,内容读取无需等待首个元数据帧。观察失败优先于 Preview 的变更提示显示;元数据不可用时保留已加载内容。
- **文本页** —— 纯文本、Markdown 和代码通过 inject 回调调用 `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`。首次挂载读取第一页;滚动到正文末尾或点击 **加载更多** 会读取下一页,直到 `eof`。owner 以 `{ kind: 'text', text, pages, eof }` 提供累计前缀,包含源码偏移和行数。Markdown 和代码增量渲染此前缀,不把每页当成独立文档。第一页之后到达的更新版本页会使读取从头开始,避免混合版本。尚无内容时,失败会以文件类型图标、说明和该失败对应的出路(重试、unpreviewable 子 slot,或什么都不给)填满正文;较晚的失败保留已有内容并在其下提供重试。
- **完整字节** —— PDF、HTML、常见图片和表格调用 `remote.workspaceFiles.readBytes(sessionId, path, {}, signal)`。二进制 Remote 直接返回 `data: Uint8Array<ArrayBuffer>`,供 `{ kind: 'bytes', data }` 使用。Host 的 `maxFileBytes` 上限拒绝超大文件,不截断。PDF 和表格渲染器在传给 Worker 前复制保留的字节,使 Preview 缓冲区仍可使用。字节仅保存在临时视图状态中,绝不进入持久布局或 Session JSONL。加载模式变化会淘汰先前结果。
- **重新载入** —— 手动重新载入仅让当前 Preview tab 通过自己的 Remote 回调重读,保留滚动偏好并淘汰旧请求。`ResourceGroup` 成员变化后,自动刷新使用相同回调。各成员首次元数据仅建立基线,不触发重新载入或首读版本对账;后续在读取期间收到的变化仍会留待下一次刷新。读取既不刷新共享元数据,也不清除其它 tab 的提示。
- **重新载入** —— 手动重新载入仅让当前 Preview tab 通过自己的 Remote 回调重读,保留滚动偏好并淘汰旧请求。`ResourceGroup` 成员变化后,自动刷新使用相同回调。每个 tab 记忆自己的滚动偏移;新内容到达时,若记忆偏移在当前位置之下,视图移到该偏移,文本预览会继续读取后续页,直到该偏移可达或文件结束。内容短于记忆偏移时,停在内容末尾的滚动事件属于布局夹紧,不改写记忆偏移,因此文件变短后再变长仍会回到该位置。记忆的是像素距离,前文变化导致段落移动时不跟随段落。各成员首次元数据仅建立基线,不触发重新载入或首读版本对账;后续在读取期间收到的变化仍会留待下一次刷新。读取既不刷新共享元数据,也不清除其它 tab 的提示。
开启代码工作工具时,HTML 以贴合正文四边的 Blob iframe 运行,沙箱属性严格为 `sandbox="allow-scripts"`,不含 `allow-same-origin`;脚本无法访问父应用的源或文件读取接口。渲染器通过注入的 Remote 回调,加载直接声明的相对 `.js` 经典脚本和 `.css` 样式表;固定安全上限为单个资源 4 MiB、总计 32 MiB、64 个不同资源。Host 代码解析关联路径,带 `baseFile` 的 `readBytes` 返回原生字节。依赖 Resource 在读取返回后加入,使用返回的 `absolutePath`,失败时则使用字符串类型的 `error.details.path`;没有 Host 路径时,Client 不自行猜测。在渲染器内部,base64 仅用于把 iframe 引导载荷嵌入脚本文本。`<base href>` 将依赖解析交给浏览器,HTTPS 资源也由浏览器处理。本地模块 import、CSS `url()`/`@import` 和动态 `fetch` 不使用 Host 文件访问。读取失败、无效 UTF-8 或超出上限都使预览失败,不发布部分资源包。替换或卸载文档会释放其 Blob URL。
@@ -125,7 +125,7 @@ Office Remote 通过 Connection 的 multipart 二进制传输返回原生 `Uint8
<a id="navigation"></a>
## 导航
`ctx.sidebarRight.openResource(address, { params: { line } })` 通过 `file` 参数携带 1 起算的源码行号。在 `text-pages` 模式下,owner 顺序加载到该行或 EOF。纯文本与代码渲染器提供源码行锚点;Markdown 不提供。所选渲染器没有锚点时,导航保持待处理;用户切换到纯文本或代码后执行。代码导航直接滚动内部源码视口。落定的行显示一条高亮带,停留 0.6 秒后在 1.4 秒内渐隐;切换换行或调整预览尺寸会提前移除它;重新挂载的 tab 恢复滚动位置时不显示高亮带。字节模式渲染器不消费源码行导航。每个完成的导航 revision 只响应一次。不带 `revealIfOpened: false` 打开同一文件时聚焦已有 tab,并送达新 revision。
`ctx.sidebarRight.openResource(address, { params: { line } })` 通过 `file` 参数携带 1 起算的源码行号。在 `text-pages` 模式下,owner 顺序加载到该行或 EOF。纯文本与代码渲染器声明 `sourceLines`;Markdown 不声明。未声明 `sourceLines` 的渲染器直接响应导航、不移动正文,之后切换到纯文本或代码也不会重放。`sourceLines` 渲染器尚未画出该行时,导航保持待处理。代码导航直接滚动内部源码视口。落定的行显示一条高亮带,停留 0.6 秒后在 1.4 秒内渐隐;切换换行或调整预览尺寸会提前移除它;重新挂载的 tab 恢复滚动位置时不显示高亮带。字节模式渲染器不消费源码行导航。每个完成的导航 revision 只响应一次。不带 `revealIfOpened: false` 打开同一文件时聚焦已有 tab,并送达新 revision。
文档路径标签提供文件[右键菜单](../ui-context-menu/README.zh.md),保留选中行号与当前窗格。
@@ -147,7 +147,7 @@ Office Remote 通过 Connection 的 multipart 二进制传输返回原生 `Uint8
- **PDF 栅格分配有上限。** 每页位图最多为 16,777,216 像素;超大页面或高像素密度屏幕上的高比例缩放仍可能低于设备分辨率。
- **字节视图不恢复滚动位置。** PDF、HTML 与图片的渲染器重新挂载或重新载入时可能回到顶部;固定 PDF 与图片缩放可产生横向滚动,HTML iframe 的滚动属于其不透明浏览上下文。
- **本地 HTML 依赖集合有限。** 只打包直接引用的经典 `.js` 脚本和 `.css` 样式表。浏览器解析的资源仍受浏览器源与网络规则限制;iframe 不获得运行时文件读取桥接。
- **滚动写入未节流。** 每次滚动事件都把偏移记进 store;行块已 memo 化,于是由此引发的重渲染交还给 React 的是同一批元素。
- **滚动写入未节流。** 除布局夹紧外,每次滚动事件都把偏移记进 store;行块已 memo 化,于是由此引发的重渲染交还给 React 的是同一批元素。
- **PDF chunk 加载失败后需要刷新页面。** React 会在页面生命周期内缓存被拒绝的 lazy import;已加载正文中的普通 PDF 打开或渲染失败仍可重试。
<a id="dev-note"></a>
@@ -66,6 +66,7 @@
"@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-files": "workspace:*",
"@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:*",
"@deepseek-ai/dsh-client-ui-slots": "workspace:*",
"@deepseek-ai/dsh-office-to-pdf": "workspace:*",
@@ -0,0 +1,82 @@
/**
* The text preview's layout: an optional banner, the path header, and the
* content row, with the workspace navigator Factories in fixed positions.
*
* Every preview state renders this one Component, so the navigator, its
* header toggle, and the file handoff keep their DOM nodes, focus, and scroll position while the
* preview moves between unsupported, loading, and text. The two Factory
* positions are memoized Components whose props are only the tab hook and
* `renderFactorySlot`; a preview-only update, such as a recorded scroll,
* does not re-render them.
*/
import { memo, useCallback } from 'react'
import type { ReactNode } from 'react'
import type { PropsRenderFactories, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type {} from '@deepseek-ai/dsh-client-ui-sidebar-files/client'
import type { ContextMenuTriggerProps } from '@deepseek-ai/dsh-client-ui-context-menu/client'
import { PathLabel } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './TextPreview.module.css'
/** The inputs both navigator Factory positions render from. */
type NavigatorSeatProps = Pick<PropsRuntime<'sidebar.right.pane.tab'>, 'useTabInfo'> & PropsRenderFactories
/** The workspace navigator after the preview's content. */
const NavigatorSeat = memo(function NavigatorSeat({ useTabInfo, renderFactorySlot }: NavigatorSeatProps): ReactNode {
const { tab } = useTabInfo()
const onOpen = useCallback((address: string): void => {
tab.actions.openResource(address, { replaceTab: true })
}, [tab.actions])
return renderFactorySlot('sidebar.files.navigator', { useTabInfo, page: false, onOpen })
})
/** The navigator's toggle at the header's end. */
const NavigatorToggleSeat = memo(function NavigatorToggleSeat({ useTabInfo, renderFactorySlot }: NavigatorSeatProps): ReactNode {
return renderFactorySlot('sidebar.files.navigator.toggle', { useTabInfo })
})
/**
* Props of {@link PreviewFrame}. `banner`, `tools`, `handoff`, and `children` are
* ReactNode values passed between Components of this package; no slot owner
* data or injected member carries them.
*/
export type PreviewFrameProps = NavigatorSeatProps & {
/** The path the header shows. */
readonly path: string
/** Context menu handlers bound to the header path. */
readonly pathTrigger: ContextMenuTriggerProps
/** A notice above the header. */
readonly banner?: ReactNode
/** Controls between the path and the file handoff. */
readonly tools?: ReactNode
/** The contributions that hand the file to the Host, in the same position in every state, before the navigator toggle. */
readonly handoff?: ReactNode
/** The content before the navigator. */
readonly children: ReactNode
/** Data attributes forwarded to the root element. */
readonly [attribute: `data-${string}`]: string | undefined
}
/**
* The text preview's layout for every preview state.
* @param props - the header and content parts, the navigator inputs, and the root's data attributes.
* @returns the preview root.
*/
export function PreviewFrame({
path, pathTrigger, banner, tools, handoff, children, useTabInfo, renderFactorySlot, ...data
}: PreviewFrameProps): ReactNode {
return (
<div className={css.preview} {...data}>
{banner}
<div className={css.header}>
<PathLabel path={path} className={css.path} data-textpreview-path {...pathTrigger} />
{tools}
{handoff}
<NavigatorToggleSeat useTabInfo={useTabInfo} renderFactorySlot={renderFactorySlot} />
</div>
<div className={css.content} data-textpreview-content>
{children}
<NavigatorSeat useTabInfo={useTabInfo} renderFactorySlot={renderFactorySlot} />
</div>
</div>
)
}
@@ -5,10 +5,32 @@
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-width: 0;
height: 100%;
min-height: 0;
}
/* Everything below the header: the body, then the navigator another plugin
contributes in a docked or floating pane. The navigator either narrows the
body or positions an overlay against this row, so the header always spans
the pane. */
.content {
position: relative;
/* Child layers stay below the floating pane's resize handle. */
isolation: isolate;
display: flex;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
}
/* Renderers' sticky toolbars stack inside the body, below an overlaying navigator. */
.content > :first-child {
isolation: isolate;
flex: 1 1 auto;
min-width: 0;
}
/* One 38px row flush under the pane's strip: the path, then the type's
controls at its end. With the strip's 38px above, its rule lies where the
conversation header's does (ui-conversation ConversationRoot `.header`,
@@ -54,6 +76,8 @@
margin: 0;
padding: 0;
overflow: auto;
/* The remembered offset is a pixel distance; browser anchoring would move it when content above it reflows. */
overflow-anchor: none;
/* The notice and retry surfaces in this sheet are elevated, so the file body's
scroller rebinds the thumb indirection in a complete pair. */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
@@ -216,12 +240,14 @@
empty states below instead of sitting in the corner. */
.status {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 8px;
align-items: center;
justify-content: center;
box-sizing: border-box;
height: 100%;
min-height: 0;
padding: 12px 10px;
}
@@ -8,20 +8,27 @@
* workspace unknown — takes the same bar's place over the pages already loaded,
* with the same reload. The type's controls, viewer choice, wrap and reload, sit at the end of
* the path row; the Sidebar's strip carries none of them.
*
* Every state renders inside `PreviewFrame`, which places the workspace
* navigator and its header toggle from the files Factories, in docked and
* floating panes. Both render empty when unregistered and stay mounted across
* loading states. A document layer handling Escape calls `preventDefault()` to
* keep the navigator from also closing.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import clsx from 'clsx'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import type { InjectFace, PropsLocale, PropsRenderFactories, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import {
FileTypeIcon, IconNowrapFillRegular, IconPauseOutlineRegular, IconPlayOutlineRegular,
IconRefreshOutlineRegular, IconWrapFillRegular, Menu, PathLabel, Tooltip, classifyFileType,
IconRefreshOutlineRegular, IconWrapFillRegular, Menu, Tooltip, classifyFileType,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path'
import type { TextInjected } from './face.ts'
import { emptyFailureRecourse, failureLine } from './failure-line.ts'
import { LoadingIndicator } from './LoadingIndicator.tsx'
import { PreviewFrame } from './PreviewFrame.tsx'
import { hostFileOf } from './rpc.ts'
import type { TextStore } from './store.ts'
import type { DocumentContent } from './document/contract.ts'
@@ -43,7 +50,11 @@ export interface TextPreviewInjected extends TextInjected {
/** The body's composed props: the tab, its navigation, the shared store and face, and copy. */
export type TextPreviewProps =
& PropsRuntime<'sidebar.right.pane.tab'>
& PropsRenderSlots<'sidebar.right.tab.document' | 'sidebar.right.tab.document.action' | 'sidebar.right.tab.document.actions' | 'sidebar.right.tab.document.unpreviewable'>
& PropsRenderSlots<
| 'sidebar.right.tab.document' | 'sidebar.right.tab.document.action' | 'sidebar.right.tab.document.actions'
| 'sidebar.right.tab.document.unpreviewable'
>
& PropsRenderFactories
& PropsStore<TextStore>
& InjectFace<TextPreviewInjected>
& PropsLocale<'sidebarDocumentPreview'>
@@ -55,7 +66,7 @@ export type TextPreviewProps =
*/
export function TextPreview({
useTabInfo, useResource, useStore, actions, loadPage, reloadPages,
loadAll, reloadAll, prepareRenderer, useDocumentPreviews, renderSlot, t,
loadAll, reloadAll, prepareRenderer, useDocumentPreviews, renderSlot, renderFactorySlot, t,
addResource, setResources, useContextMenu, sessionId,
}: TextPreviewProps): ReactNode {
const { tab, panel } = useTabInfo()
@@ -125,21 +136,31 @@ export function TextPreview({
}, [started, tab.id, file, signal, loadPage, loadAll, prepareRenderer, canRead, mode, selected, meta.value?.version])
// Come back where the reader was once there is content to scroll: on a remount,
// after a reload rebuilt the content, or after the selected renderer changed.
// Scroll writes preserve both identities, so they never re-land.
// after a reload rebuilt the content, after the selected renderer changed, or
// after another text page arrived. The offset only moves down to the remembered
// one, and the next page is read while the remembered offset lies past the
// loaded pages. A line navigation not answered yet positions the body itself.
// Scroll writes preserve every identity here, so they never re-land.
useEffect(() => {
const body = scrollportRef.current
if (hasContent && body !== null && state !== undefined) body.scrollTop = state.scrollTop
}, [hasContent, selected?.id])
if (!hasContent || body === null || state === undefined) return
if (mode === 'text-pages' && line !== undefined && current?.revision !== navigation.revision) return
body.scrollTop = Math.max(body.scrollTop, state.scrollTop)
if (mode === 'text-pages' && body.scrollTop + 1 < state.scrollTop && current !== undefined
&& !current.eof && !current.loading && current.failure === undefined && canRead) {
loadPage(tab.id, file, loadedThrough + 1, signal, meta.value?.version)
}
}, [hasContent, selected?.id, pages, current?.revision])
// Answer a navigation once: a line the pages do not reach yet loads the next
// page (again, until the pages cover it or the file ends); a line they hold
// is scrolled to and marked with a fading band. The store remembers the
// answer, so a remount restores the reader's place instead.
// is scrolled to and marked with a fading band. A renderer without source-line
// anchors answers it without moving. The store remembers the answer, so a
// remount restores the reader's place instead.
useEffect(() => {
const body = scrollportRef.current
if (current === undefined || body === null || current.revision === navigation.revision) return
if (line === undefined || mode !== 'text-pages') {
if (line === undefined || mode !== 'text-pages' || selected?.sourceLines !== true) {
actions.navigated(tab.id, navigation.revision)
return
}
@@ -195,18 +216,21 @@ export function TextPreview({
return { kind: 'text', pages: loaded, text: loaded.filter(page => page.lines > 0).map(page => page.text).join('\n'), eof: current.eof }
}, [mode, loaded, current?.complete, current?.eof, current?.loadRevision, rendererReload, actions, tab.id])
const frame = {
path: displayPath,
pathTrigger: getTriggerProps({ kind: 'file', value: { sessionId, path: displayPath, paneId: panel.id, ...(line === undefined ? {} : { line }) } }),
useTabInfo,
renderFactorySlot,
}
const handoff = fileOwner !== undefined && renderSlot('sidebar.right.tab.document.actions', fileOwner)
// A known binary suffix with no matching renderer never reads: no plain-text
// fallback, no viewer control, only the path, the unsupported line, and the
// contributions that hand the file to the Host.
if (selected === undefined && unviewable) {
const { name: unsupportedName } = pathPartsOf(displayPath)
return (
<div className={css.preview} data-textpreview-state="unsupported" data-textpreview-url={tab.contentId}>
<div className={css.header}>
<PathLabel path={displayPath} className={css.path} data-textpreview-path
{...getTriggerProps({ kind: 'file', value: { sessionId, path: displayPath, paneId: panel.id, ...(line === undefined ? {} : { line }) } })} />
{fileOwner !== undefined && renderSlot('sidebar.right.tab.document.actions', fileOwner)}
</div>
<PreviewFrame {...frame} handoff={handoff} data-textpreview-state="unsupported" data-textpreview-url={tab.contentId}>
<div className={css.body} data-textpreview-body>
<div className={css.empty} data-textpreview-unsupported>
<FileTypeIcon kind={classifyFileType(unsupportedName)} size={36} className={css.emptyIcon} />
@@ -214,16 +238,18 @@ export function TextPreview({
{fileOwner !== undefined && renderSlot('sidebar.right.tab.document.unpreviewable', fileOwner)}
</div>
</div>
</div>
</PreviewFrame>
)
}
if (state === undefined || selected === undefined) {
return (
<div className={css.status} data-textpreview-state="loading">
{meta.status === 'none'
? <p className={css.statusLine}>{t('resourceUnavailable')}</p>
: <LoadingIndicator label={t('loading')} />}
</div>
<PreviewFrame {...frame} handoff={handoff} data-textpreview-state="loading">
<div className={css.status}>
{meta.status === 'none'
? <p className={css.statusLine}>{t('resourceUnavailable')}</p>
: <LoadingIndicator label={t('loading')} />}
</div>
</PreviewFrame>
)
}
const next = loadedThrough + 1
@@ -233,8 +259,12 @@ export function TextPreview({
loadPage(tab.id, file, next, signal, meta.value?.version)
}
return (
<div className={css.preview} data-textpreview-state="text" data-textpreview-url={tab.contentId} data-document-preview={selected.id}>
{meta.failure !== undefined && hasContent
<PreviewFrame
{...frame}
data-textpreview-state="text"
data-textpreview-url={tab.contentId}
data-document-preview={selected.id}
banner={meta.failure !== undefined && hasContent
? (
// The file's metadata failed — gone, or its workspace unknown — which
// outranks a pending change; the pages already read stay under it.
@@ -265,76 +295,77 @@ export function TextPreview({
</button>
</p>
)}
<div className={css.header}>
<PathLabel path={displayPath} className={css.path} data-textpreview-path
{...getTriggerProps({ kind: 'file', value: { sessionId, path: displayPath, paneId: panel.id, ...(line === undefined ? {} : { line }) } })} />
{candidates.length > 1
&& (
<Menu
open={menuOpen}
anchor={(
<button type="button" className={clsx(css.tool, css.viewerTool)} aria-label={t('openWith')} title={selected.title()} data-document-viewer-menu onClick={() => { setMenuOpen(value => !value) }}>
{selected.title()}
</button>
)}
items={candidates.map(candidate => ({ id: candidate.id, label: candidate.title() }))}
selectedId={selected.id}
onSelect={(id) => { actions.selected(tab.id, id); setMenuOpen(false) }}
onClose={() => { setMenuOpen(false) }}
align="end"
portal
dense
/>
tools={(
<>
{candidates.length > 1
&& (
<Menu
open={menuOpen}
anchor={(
<button type="button" className={clsx(css.tool, css.viewerTool)} aria-label={t('openWith')} title={selected.title()} data-document-viewer-menu onClick={() => { setMenuOpen(value => !value) }}>
{selected.title()}
</button>
)}
items={candidates.map(candidate => ({ id: candidate.id, label: candidate.title() }))}
selectedId={selected.id}
onSelect={(id) => { actions.selected(tab.id, id); setMenuOpen(false) }}
onClose={() => { setMenuOpen(false) }}
align="end"
portal
dense
/>
)}
{selected.wrap === true && (
// The tooltip names the action while the stable aria name and
// `aria-pressed` expose the control and its current state.
<Tooltip label={t(state.wrap ? 'wrap.disable' : 'wrap.enable')} side="bottom" delayMs={500}>
<button
type="button"
className={css.tool}
aria-pressed={state.wrap}
aria-label={t('wrap.aria')}
data-textpreview-tool="wrap"
onClick={() => {
actions.toggledWrap(tab.id)
// Wrapping reflows the lines under a line mark without resizing its scrollport.
const body = scrollportRef.current
/* v8 ignore next -- callback refs bind the scrollport during commit, before user input. */
if (body !== null) clearLineMark(body)
}}
>
{state.wrap ? <IconNowrapFillRegular /> : <IconWrapFillRegular />}
</button>
</Tooltip>
)}
{selected.wrap === true && (
// The tooltip names the action while the stable aria name and
// `aria-pressed` expose the control and its current state.
<Tooltip label={t(state.wrap ? 'wrap.disable' : 'wrap.enable')} side="bottom" delayMs={500}>
{content !== undefined && renderSlot('sidebar.right.tab.document.action', { content }, { entryKey: selected.id, hookContext: useTabInfo })}
<span hidden>
<Tooltip label={t(state.autoRefresh ? 'autoRefresh.disable' : 'autoRefresh.enable')} side="bottom" delayMs={500}>
<button type="button" className={css.tool} aria-label={t('autoRefresh')}
aria-pressed={state.autoRefresh} data-textpreview-tool="auto-refresh"
onClick={() => { actions.toggledAutoRefresh(tab.id) }}>
{state.autoRefresh ? <IconPauseOutlineRegular /> : <IconPlayOutlineRegular />}
</button>
</Tooltip>
</span>
{/* Preview and file-tree refresh controls own different reload lifecycles and locale namespaces. */}
{/* jscpd:ignore-start */}
<Tooltip label={t('reload')} shortcutKeys={tab.refreshShortcut?.keys} side="bottom" delayMs={500}>
<button
type="button"
className={css.tool}
aria-pressed={state.wrap}
aria-label={t('wrap.aria')}
data-textpreview-tool="wrap"
onClick={() => {
actions.toggledWrap(tab.id)
// Wrapping reflows the lines under a line mark without resizing its scrollport.
const body = scrollportRef.current
/* v8 ignore next -- callback refs bind the scrollport during commit, before user input. */
if (body !== null) clearLineMark(body)
}}
aria-label={t('reload')}
data-textpreview-tool="reload"
aria-keyshortcuts={tab.refreshShortcut?.aria}
onClick={reload}
>
{state.wrap ? <IconNowrapFillRegular /> : <IconWrapFillRegular />}
<IconRefreshOutlineRegular />
</button>
</Tooltip>
)}
{content !== undefined && renderSlot('sidebar.right.tab.document.action', { content }, { entryKey: selected.id, hookContext: useTabInfo })}
<span hidden>
<Tooltip label={t(state.autoRefresh ? 'autoRefresh.disable' : 'autoRefresh.enable')} side="bottom" delayMs={500}>
<button type="button" className={css.tool} aria-label={t('autoRefresh')}
aria-pressed={state.autoRefresh} data-textpreview-tool="auto-refresh"
onClick={() => { actions.toggledAutoRefresh(tab.id) }}>
{state.autoRefresh ? <IconPauseOutlineRegular /> : <IconPlayOutlineRegular />}
</button>
</Tooltip>
</span>
{/* Preview and file-tree refresh controls own different reload lifecycles and locale namespaces. */}
{/* jscpd:ignore-start */}
<Tooltip label={t('reload')} shortcutKeys={tab.refreshShortcut?.keys} side="bottom" delayMs={500}>
<button
type="button"
className={css.tool}
aria-label={t('reload')}
data-textpreview-tool="reload"
aria-keyshortcuts={tab.refreshShortcut?.aria}
onClick={reload}
>
<IconRefreshOutlineRegular />
</button>
</Tooltip>
{/* jscpd:ignore-end */}
{fileOwner !== undefined && renderSlot('sidebar.right.tab.document.actions', fileOwner)}
</div>
{/* jscpd:ignore-end */}
</>
)}
handoff={handoff}
>
<div
ref={bindBody}
className={clsx(css.body, state.wrap && css.wrap)}
@@ -345,7 +376,11 @@ export function TextPreview({
/* v8 ignore next -- callback refs bind the scrollport during commit, before user input. */
if (body === null) return
if (event.target !== body) return
actions.scrolled(tab.id, body.scrollTop)
// Shorter or emptied content pins the body to its end below the remembered
// offset; that clamp is layout, not the reader, so the offset survives it.
const clamped = body.scrollTop >= body.scrollHeight - body.clientHeight - 1
&& body.scrollTop < state.scrollTop
if (!clamped) actions.scrolled(tab.id, body.scrollTop)
if (mode === 'text-pages' && current?.failure === undefined && body.clientHeight > 0
&& body.scrollTop + body.clientHeight >= body.scrollHeight - 1) loadNext()
}}
@@ -410,6 +445,6 @@ export function TextPreview({
</button>
)}
</div>
</div>
</PreviewFrame>
)
}
@@ -19,6 +19,7 @@ export function apply(ctx: Context): void {
title: () => t('title'),
loading: 'text-pages',
wrap: true,
sourceLines: true,
}))
ctx.effect(() => ctx.slots.inject('sidebar.right.tab.document', () => ctx.slots.register(
{ name: 'sidebar.right.tab.document', key: ID, locale: NS }, CodeBody,
@@ -25,6 +25,11 @@ export interface DocumentPreviewDefinition {
readonly loading: DocumentLoadMode
/** Whether the implementation consumes the document's wrap preference. */
readonly wrap?: boolean
/**
* Whether a `text-pages` body exposes source-line anchors for line navigation;
* without it, a navigation to a line is answered without moving the body.
*/
readonly sourceLines?: boolean
}
/**
@@ -13,7 +13,7 @@ export const PLAIN_BODY_ID = '@deepseek-ai/dsh-client-ui-sidebar-documentpreview
* @returns plain-text registration metadata.
*/
export function textBodyDefinition(title: () => string): DocumentPreviewDefinition {
return { id: PLAIN_BODY_ID, extensions: [], priority: 'builtin', title, loading: 'text-pages', wrap: true }
return { id: PLAIN_BODY_ID, extensions: [], priority: 'builtin', title, loading: 'text-pages', wrap: true, sourceLines: true }
}
/** @param ctx - owning plugin context. Register the fallback metadata and keyed body. */
@@ -43,6 +43,7 @@ interface Recorded {
store?: unknown
inject?: unknown
component: unknown
children?: Record<string, unknown>
}
async function boot() {
@@ -104,6 +105,10 @@ describe('ui-sidebar-documentpreview apply', () => {
['sidebar.right.tab.document', '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/excel', 'sidebarExcel', LazyExcelBody],
['sidebar.right.tab.document', '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/code', 'sidebarCodePreview', CodeBody],
])
expect(Object.keys(registered[0]!.children!)).toEqual([
'sidebar.right.tab.document', 'sidebar.right.tab.document.actions',
'sidebar.right.tab.document.unpreviewable', 'sidebar.right.tab.document.action',
])
expect(registered[0]?.store).toBeDefined()
expect(typeof registered[0]?.inject).toBe('function')
})
@@ -141,8 +141,7 @@ export function harness(script: Record<number, RemoteResult<WorkspaceFileText>>
onTestFinished(() => { controller.abort() })
const tabActions = { bindCommands: vi.fn(() => vi.fn()), openResource: vi.fn(), openTab: vi.fn(), close: vi.fn(), replace: vi.fn() }
const definitions = [textBodyDefinition(() => t('viewer.text'))]
// The document seat renders the plain body; the file-handoff seats render a
// marker carrying what the owner handed them.
// The document slot renders the plain body; other positions mark their input.
const renderSlot: TextPreviewProps['renderSlot'] = (key: string, owner: unknown, opts?: { hookContext?: unknown }) =>
key === 'sidebar.right.tab.document'
? createElement(TextBody, {
@@ -155,7 +154,7 @@ export function harness(script: Record<number, RemoteResult<WorkspaceFileText>>
const props = (navigation: { params?: unknown; revision: number } = { revision: 1 }) => ({
useTabInfo: () => ({
sidebar: { expanded: true, fullscreen: false },
panel: { id: 'pane-1' },
panel: { id: 'pane-1', isFloating: false },
tab: {
id: tabId, kind: 'text', contentId: ADDRESS, title: 'notes.md', visible: true,
navigation: { address: ADDRESS, params: navigation.params, revision: navigation.revision },
@@ -175,6 +174,7 @@ export function harness(script: Record<number, RemoteResult<WorkspaceFileText>>
addResource: face.addResource, setResources: face.setResources,
useDocumentPreviews: () => definitions,
renderSlot,
renderFactorySlot: (key: string) => createElement('div', { 'data-navigator-factory': key }),
t,
}) as TextPreviewProps
return {

Some files were not shown because too many files have changed in this diff Show More