Merge remote-tracking branch 'origin/master' into fix/multiline-display-math
This commit is contained in:
commit
3c37cc91ef
249 files changed
+6762
-2153
No files matched your search
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.md
|
||||
2026-09-20-sidebar-retained-tab-layout.md: 9579a0ba47c56f1d533f066fddd37e7ddf443346
|
||||
2026-09-20-sidebar-retained-tab-layout.zh.md: 365ad5682c8610cab5dc855984e58b4303bfae49
|
||||
@@ -0,0 +1,82 @@
|
||||
# Agent Note: Stable Sidebar tab mounting and Session retention
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-20-sidebar-retained-tab-layout.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Restoring Session-specific tab records and URLs does not preserve a live browsing context. Unmounting a body or changing its DOM ancestors during tab, pane or Session transitions disconnects its page. Electron 44's `WebViewElement.disconnectedCallback` detaches and resets its guest, so preserving a React key or caching an element reference cannot compensate for disconnected ancestors. Content size, clipping and overlap must also follow the actual parent layout.
|
||||
|
||||
## Decision
|
||||
|
||||
Sidebar owns live-body retention, independently of Browser navigation and Workspace storage. A retained page's DOM ancestors remain connected and unchanged during Session switches, tab selection, pane moves, collapse, floating and docking back. Browser Presentation attaches directly inside its content container and performs no placeholder measurement or overlay positioning.
|
||||
|
||||
### Layout ownership
|
||||
|
||||
- `DockLayout` renders the Sidebar's one or two horizontal panes with stable tab siblings. The existing recursive `DockSurface` and separate `FloatLayer` remain available for general split-tree rendering; the layout engine and serialized format are unchanged.
|
||||
- Every tab has a stable Grid cell, frame, Header container and Body container. Explicit Grid columns determine docking placement; Flex assigns Header and Body height. Floating changes that same frame to `position: fixed` without changing its parent or replacing Body.
|
||||
- A floating frame's zero-sized Grid cell establishes a stacking context at the float layer. CSS `order` expresses float depth within that layer; DOM order stays sorted by tab identity. Raising many floats cannot increment their layer past menus. Selection and strip order never reorder the content-node list.
|
||||
- Common ancestors have no transform, clipping or enclosing stacking context. Docked cells own collapse transforms and visibility; each frame clips its content. Fullscreen changes the same root's width and docked layer. Existing window-drag region pulses remain, but no RAF copies content positions.
|
||||
- Split and float gestures retain their existing measurements and operation state. These update user-selected split fractions and float rectangles, not a second Browser rectangle. Browser resize and content sizing use normal CSS layout.
|
||||
|
||||
### Session and tab lifetime
|
||||
|
||||
`SidebarRightTabDefinition.keepMounted` enables lazy retention for a type. Desktop Browser enables it; other types keep visibility-based body mounting. A retained body is first created on display, then survives hiding until its occurrence or provider lifetime ends. The policy is not persisted and does not select a storage partition.
|
||||
|
||||
`SidebarSessionView` acquires and releases one `SessionReference` using the independent `sidebarView` source, and owns its committed-mount count and retained-Body holds. `SidebarSessionViews` manages selection, retention policy and view indexes; it delegates lifetime operations to the view rather than mutating its counts or releasing its reference. `RightbarRoot` receives `SidebarSessionViewSnapshot` values and the existing injected callbacks, not resource-owner objects. It renders each through its own explicit `SessionProvider`, with a stable Session key; the renderer's general Session-remount semantics remain unchanged.
|
||||
|
||||
Tabs contribute Body-retention holds rather than independent Session references. The View's stable `retainTab` callback reaches its seat through owner props, so replacing Session injection bindings does not release and reacquire those holds. Those holds follow the existing occurrence signal, including a new signal after close-and-undo; the view does not create a second tab lifetime. A retired view stays in the collection's reference index until its own disposal removes it, so plugin shutdown can also reach views whose React mounts have not finished.
|
||||
|
||||
Only the selected Session reports frame occupancy or binds public Sidebar navigation. Background tab actions still address their own adopted store. `tab.visible` includes Session and global-panel visibility; floating does not make a background Session visible. A Session change or global panel hides its whole content tree, while Sidebar collapse hides only docked cells and leaves foreground floats visible. Hidden content is inaccessible to pointer and keyboard input, and hidden body focus is blurred.
|
||||
|
||||
A background Session with no initialized retained body leaves the view list. Its reference releases after its committed React root unmounts, avoiding scope disposal underneath a mounted subtree. Plugin disposal releases all references, including retired views awaiting unmount. The Sidebar plugin depends on `sessions` and `uiSession`, so Cordis unloads it when either dependency ends; `ui-session` owns binding invalidation. Views do not register disposal callbacks on the shared Session Context. Catalog absence alone is not treated as deletion. Unvisited Browser tabs and unrelated Sessions are not eagerly opened.
|
||||
|
||||
### Provider responsibilities
|
||||
|
||||
`BrowserFrame` remains navigation and observable page state. Presentation creates and attaches the carrier DOM, without pane, clipping or stacking knowledge. Electron's provider receives physical attach/detach callbacks: hiding retained content emits neither, while an actual unmount cancels pending attachment and releases the guest. A subsequent physical mount recreates it from its known address. Disposal waits for pending creation and guest releases; an acquisition completing after cancellation is released rather than attached.
|
||||
|
||||
The [Desktop Browser decision](../feature/2026-09-20-desktop-browser-webview.md) retains guest security, shared IPC declarations and Workspace partition ownership. Separate tabs and Sessions never share one page instance merely because they share a storage partition. Web remains disabled by default and retains its iframe carrier when explicitly enabled.
|
||||
|
||||
### Retention versus recovery
|
||||
|
||||
| Operation | Content handling |
|
||||
|---|---|
|
||||
| Switch tabs | Hide the old retained cell; display the target's existing body. |
|
||||
| Split, reorder or move to another pane | Change logical placement and Grid assignment, without moving Body. |
|
||||
| Float, dock back or raise | Change the same frame's CSS mode or cell paint order. |
|
||||
| Collapse Sidebar | Hide docked cells; foreground floats remain visible. |
|
||||
| Session A → B → A | Hide and reshow A's original tree instead of loading its saved URL. |
|
||||
| Close, provider unload or actual generation termination | Release the body and guest; undo creates a new occurrence. |
|
||||
| Application restart or window reload | Show the saved title and URL; load only after an explicit restore or address submission. |
|
||||
| Guest crash | Retry through the provider; page memory and native history are not durable. |
|
||||
|
||||
[Layout and provider recovery](2026-09-14-sidebar-layout-provider-recovery.md) remains independent: persisted tab identity, placement and address support cold starts, not DOM serialization. A hidden page may continue scripts, media and networking.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Hide panes only.** Collapse works, but moving between panes or into another floating portal still changes content ancestors.
|
||||
|
||||
**Retain bodies only in the foreground Session.** Session-slot remounting disconnects the page and loses its live state on Session switches.
|
||||
|
||||
**Give each tab its own Session reference.** A view's tabs share one `SessionProvider`, whose reference must exist before their bodies render. Per-view ownership covers that parent and its children without an additional reference lifecycle for each tab.
|
||||
|
||||
**Cache and reparent DOM.** Ordinary reparenting invokes Electron disconnection. Atomic-move dependencies, private attachment APIs and lifecycle monkey-patches are not used.
|
||||
|
||||
**Position Browser content in a separate overlay.** Measurement or CSS anchors can align an overlay, but its content remains outside the parent Grid/Flex layout that must own its sizing and clipping.
|
||||
|
||||
**Use native popovers.** Top-layer elements cannot be covered by ordinary Portal content through `z-index`. Migrating menus and dialogs into another stacking system expands the work beyond Sidebar; the implementation preserves the existing Portal and layer model.
|
||||
|
||||
**Raise floats by increasing their root z-index.** An unbounded float count can cross menu and modal layers. Equal-layer Grid cells with CSS paint order retain both stacking and DOM identity.
|
||||
|
||||
**Save the URL and rebuild when hidden.** This cannot preserve forms, script state, scroll position or native history. It is recovery after loss, not live retention.
|
||||
|
||||
## Consequences
|
||||
|
||||
Layout correctness belongs to Sidebar/DockKit rather than Browser. The cost is stable content containers and explicit Session-reference ownership: background retention holds Session scopes and subscriptions as well as page memory. There is no implicit LRU or idle timeout; future reclamation requires an explicit suspension/recovery policy. Other tab types do not automatically acquire these costs.
|
||||
|
||||
Sidebar/DockKit own chrome, float stacking, focus and platform styles as well as retained content placement. The [docking infrastructure](../feature/2026-09-04-right-sidebar-docking-infrastructure.md) owns the layout engine, persistence and tab-type navigation; the [Desktop Browser decision](../feature/2026-09-20-desktop-browser-webview.md) owns guest navigation, storage and security.
|
||||
|
||||
## Verification
|
||||
|
||||
Focused tests cover retained bodies across Session-source registration and removal, persisted-layout validation, and existing Sidebar presentation. Host and Client TypeScript compilation passes. The user manually accepted a rebuilt Electron cold start; no GIF was recorded. Dedicated real-Electron evidence for guest identity, form and history retention, multi-float paint order, resizing, clipping, drop hints, focus and platform window controls remains separate from these unit tests.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Agent Note: Sidebar 的稳定 Tab 挂载与会话保活
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-20-sidebar-retained-tab-layout.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
恢复各会话的 Tab 记录和 URL, 并不能保留活的浏览上下文. 在 Tab、Pane 或会话切换期间卸载 Body 或改变其 DOM 祖先, 都会断开页面. Electron 44 的 `WebViewElement.disconnectedCallback` 会 detach 并重置 guest, 因此保持 React key 或缓存元素引用不能抵消祖先断开. 内容尺寸、裁剪与重叠也必须遵循真实父节点布局.
|
||||
|
||||
## Decision
|
||||
|
||||
Sidebar 负责活 Body 的保留,不依赖 Browser 导航和 Workspace 存储。保活页面的 DOM 祖先在切会话、选 Tab、跨 Pane 移动、收起、浮动和回停靠时保持连接与身份不变。Browser Presentation 直接挂载到内容容器内,不做占位元素测量或浮层定位。
|
||||
|
||||
### 布局所有权
|
||||
|
||||
- `DockLayout` 用稳定的 Tab 兄弟节点渲染 Sidebar 的一个或两个水平 Pane。现有递归 `DockSurface` 和独立 `FloatLayer` 仍可用于通用分割树渲染;布局引擎和序列化格式不变。
|
||||
- 每个 Tab 都有稳定的 Grid cell、frame、Header 容器和 Body 容器。显式 Grid 列决定停靠归属,Flex 分配 Header 与 Body 的高度。浮动只把同一个 frame 改为 `position: fixed`,不改父节点或替换 Body。
|
||||
- 浮动 frame 的零尺寸 Grid cell 在浮窗层建立层叠上下文。CSS `order` 表达该层内的浮窗深度,DOM 顺序始终按 Tab 身份排序。大量浮窗置顶不会把层级递增到菜单之上;选中项和 Tab 条排序均不重排内容节点列表。
|
||||
- 共同祖先不设置 transform、裁剪或包住全部内容的层叠上下文。停靠 cell 负责收起 transform 和显隐,各 frame 裁剪自身内容。Fullscreen 修改同一个根的宽度和停靠层级。窗口拖动区域的既有脉冲仍保留,但没有 RAF 复制内容位置。
|
||||
- 分栏与浮窗手势沿用原有测量和操作状态,更新用户选择的分栏比例与浮窗矩形,而非维护第二份 Browser 矩形。窗口缩放和内容尺寸变化由普通 CSS 布局处理。
|
||||
|
||||
### 会话与 Tab 生命周期
|
||||
|
||||
`SidebarRightTabDefinition.keepMounted` 为类型启用懒保活。Desktop Browser 启用它,其他类型继续按可见性挂载 Body。保活 Body 在首次显示时创建,随后隐藏不卸载,直到 occurrence 或提供方生命周期结束。该策略不持久化,也不选择存储分区。
|
||||
|
||||
`SidebarSessionView` 使用独立的 `sidebarView` 来源申请、释放一个 `SessionReference`,并管理自己的已提交挂载计数和 Body 保活持有。`SidebarSessionViews` 管理选中、保留策略和视图索引,将生命周期操作委托给视图,而不修改其计数或释放其引用。`RightbarRoot` 接收 `SidebarSessionViewSnapshot` 和已有注入回调,不接收资源所有者对象;它为各视图分别渲染显式 `SessionProvider`,保持会话 key 稳定,渲染器通用的会话重挂载语义不变。
|
||||
|
||||
Tab 提供 Body 保活持有,而不独立申请会话引用。View 的稳定 `retainTab` 回调通过 owner props 传给席位,因此替换 Session injection binding 不会释放再重新获取这些持有。这些持有跟随已有 occurrence signal,包括关闭后撤销恢复得到的新 signal;视图不创建第二套 Tab 生命周期。退出视图列表的视图仍留在集合的引用索引中,直到自行释放时移除索引,因此插件关闭时也能清理尚未结束 React 挂载的视图。
|
||||
|
||||
只有选中的会话向 frame 报告占位、绑定公共 Sidebar 导航。后台 Tab actions 仍指向自己的已接管存储。`tab.visible` 包含会话和全局面板的可见性,浮动不会让后台会话变成可见。切会话或打开全局面板隐藏整个内容树,收起 Sidebar 则只隐藏停靠 cell,前台浮窗仍可见。隐藏内容不可通过指针或键盘访问,隐藏 Body 内的焦点会被移除。
|
||||
|
||||
后台会话没有已初始化的保活 Body 时,从视图列表移除;等已提交的 React 根卸载后再释放引用,避免仍挂载的子树下方先销毁会话 scope。插件释放会清理全部引用,包括等待卸载的已移除视图。Sidebar 插件依赖 `sessions` 和 `uiSession`,任一依赖结束时由 Cordis 卸载它,绑定失效由 `ui-session` 负责。视图不向共享会话 Context 注册销毁回调。仅在目录列表中缺项不视为删除。未访问的 Browser Tab 和无关会话不会提前打开。
|
||||
|
||||
### 提供方职责
|
||||
|
||||
`BrowserFrame` 仍只负责导航与可观察页面状态。Presentation 创建、挂载载体 DOM,不了解 Pane、裁剪或层叠。Electron 提供方接收物理 attach/detach 回调:隐藏保活内容不触发这些回调,真正卸载则取消未完成的挂载并释放 guest;之后再次物理挂载时从已知地址重建。dispose(资源释放)会等待未完成的创建和 guest 释放,取消后才完成的申请只释放、不挂载。
|
||||
|
||||
[Desktop Browser 决策](../feature/2026-09-20-desktop-browser-webview.zh.md)继续负责 guest 安全、共享 IPC 声明和 Workspace 分区所有权。不同 Tab 和会话不能因为共享存储分区就共享一个页面实例。Web 默认仍关闭,显式启用时继续使用 iframe 载体。
|
||||
|
||||
### 保活与恢复
|
||||
|
||||
| 操作 | 内容处理 |
|
||||
|---|---|
|
||||
| 切换 Tab | 隐藏旧的保活 cell,显示目标已有 Body。 |
|
||||
| 分栏、排序或移到另一个 Pane | 改逻辑归属和 Grid 分配,不搬移 Body。 |
|
||||
| 浮动、回停靠或置顶 | 改同一个 frame 的 CSS 模式或 cell 绘制顺序。 |
|
||||
| 收起 Sidebar | 隐藏停靠 cell,前台浮窗保持可见。 |
|
||||
| 会话 A → B → A | 隐藏并重新显示 A 的原内容树,而非加载已保存 URL。 |
|
||||
| 关闭、提供方卸载或真实 generation 结束 | 释放 Body 和 guest;撤销关闭创建新 occurrence。 |
|
||||
| 应用重启或窗口刷新 | 展示保存的标题和 URL,显式恢复或提交地址后才加载。 |
|
||||
| guest 崩溃 | 通过提供方重试;页面内存和原生 history 不持久化。 |
|
||||
|
||||
[布局与提供方恢复](2026-09-14-sidebar-layout-provider-recovery.zh.md)仍然独立:持久化的 Tab 身份、位置和地址支持冷启动,而不是序列化 DOM。隐藏页面可能继续执行脚本、播放媒体或联网。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**只隐藏 Pane。** 可以覆盖收起,但跨 Pane 移动或进入独立浮动 Portal 仍会改变内容祖先。
|
||||
|
||||
**只保留前台会话的 Body.** 会话 slot 重挂载会断开页面, 导致切会话时丢失页面运行状态.
|
||||
|
||||
**每个 Tab 独立持有会话引用。** 同一视图的 Tab 共用一个 `SessionProvider`,它的引用必须先于这些 Body 渲染而存在。按视图持有即可覆盖父级及其子节点,无须再为每个 Tab 管理一份引用生命周期。
|
||||
|
||||
**缓存并搬移 DOM。** 普通 reparent 会触发 Electron 断开处理;实现不依赖原子搬移、私有挂载 API 或生命周期补丁。
|
||||
|
||||
**把 Browser 内容放在独立浮层中.** 测量或 CSS 锚点可以对齐浮层, 但内容仍处于负责其尺寸与裁剪的父级 Grid/Flex 布局之外.
|
||||
|
||||
**使用原生 popover。** 普通 Portal 内容无法通过 `z-index` 覆盖 top layer 元素。把菜单、对话框迁移到另一套层叠体系会扩大 Sidebar 之外的改动;实现保留既有 Portal 和层级模型。
|
||||
|
||||
**通过递增根 z-index 置顶浮窗。** 不设上限的浮窗数量可能越过菜单和模态层。同层 Grid cell 的 CSS 绘制顺序同时保留层叠与 DOM 身份。
|
||||
|
||||
**隐藏时保存 URL 再重建。** 无法保留表单、脚本状态、滚动位置和原生 history。这是丢失实例后的恢复,不是活页面保留。
|
||||
|
||||
## Consequences
|
||||
|
||||
布局正确性由 Sidebar/DockKit 负责,而不是 Browser。代价是稳定的内容容器与显式会话引用所有权:后台保活不仅持有页面内存,也持有会话 scope 和订阅。没有隐式 LRU 或空闲超时,未来回收需要明确的暂停与恢复策略;其他 Tab 类型不会自动承担这些成本。
|
||||
|
||||
Sidebar/DockKit 除了负责保活内容布局, 还负责控件、浮窗层级、焦点和平台样式. [停靠基础设施](../feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md)负责布局引擎、持久化和 Tab 类型导航; [Desktop Browser 决策](../feature/2026-09-20-desktop-browser-webview.zh.md)负责 guest 导航、存储和安全.
|
||||
|
||||
## Verification
|
||||
|
||||
定向测试覆盖 Session 数据源注册与注销期间的正文保活、存档布局校验和既有 Sidebar 呈现。Host 与 Client TypeScript 编译通过。用户已手动验收重新构建后的 Electron 冷启动;未录制 GIF。guest 身份、表单与 history 保留、多浮窗绘制顺序、缩放、裁剪、放置提示、焦点和平台窗口控件仍需独立于这些单元测试的真实 Electron 证据。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md
|
||||
2026-09-04-right-sidebar-docking-infrastructure.md: 3ebfe4b3a95ab5820682098be2a4c43f172d1a02
|
||||
2026-09-04-right-sidebar-docking-infrastructure.zh.md: dc6516324a368dd86b3e01afb4051a4b20697363
|
||||
2026-09-04-right-sidebar-docking-infrastructure.md: c5bfb75730f2037826723bc7fd0aa2bb01005d1e
|
||||
2026-09-04-right-sidebar-docking-infrastructure.zh.md: 4959032b53f84654205a1973aee781acb356f999
|
||||
+4
-2
@@ -16,6 +16,8 @@ The right column is a per-session docking surface — split panes, tabs, floatin
|
||||
|
||||
### Package topology
|
||||
|
||||
The [stable Sidebar mounting decision](../architecture/2026-09-20-sidebar-retained-tab-layout.md) owns one retained tab-content tree for docking, floating and Session switches. The engine, layout state and gesture ownership in this note remain applicable.
|
||||
|
||||
| Package | Kind | Owns |
|
||||
|---|---|---|
|
||||
| `packages/client/ui-dockkit` | static-linked library, zero DSH dependencies | the layout engine and the React components that render and drive it; consumers compile its sources, and it keeps exactly one stylesheet because a consumer de-duplicates injected sheets by file name |
|
||||
@@ -33,7 +35,7 @@ Components render a snapshot and report settled intents, one per gesture: a drag
|
||||
|
||||
[Responsive Sidebar and tab information](../architecture/2026-09-07-sidebar-responsive-tab-info.md) supersedes this note's no-concession layout, overlay presentation and product pane limit. `ui-layout` still owns three-column geometry and pixel width preferences; the Sidebar occupant reports presentation through `ctx.layout.openRightbar(track, fullscreen)` and `closeRightbar()`, without the frame injecting the Sidebar package. Exact width rules belong to [ui-layout](../../../../packages/client/ui-layout/README.md).
|
||||
|
||||
The right Sidebar uses one mounted content tree in normal and fullscreen modes; hiding preserves tab state, and fullscreen covers the viewport while retaining underlying column reservation. Floats still use viewport coordinates through a portal and remain open when the Sidebar closes. The product limits docking to two horizontal panes and a 20–80% divider; the generic engine keeps its own defaults.
|
||||
The right Sidebar uses one mounted content tree in normal and fullscreen modes; hiding preserves retained tab state, and fullscreen covers the viewport while retaining underlying column reservation. `DockLayout` renders stable Grid cells with fixed-position floating frames in the same tree; foreground floats remain open when the Sidebar closes. The product limits docking to two horizontal panes and a 20–80% divider; the generic engine keeps its own defaults.
|
||||
|
||||
### State
|
||||
|
||||
@@ -86,7 +88,7 @@ The surface renders tabs whose bodies it does not know: each tab carries a `kind
|
||||
- The Detail panel and its duplicate card presentation are gone (a net removal of roughly 1,400 lines); cards are read in place, and `inspect` opens the trajectory view.
|
||||
- The frame has no centre floor: a viewport narrower than the two edge columns squeezes the conversation toward zero instead of closing a column.
|
||||
- The kit is compiled by its consumers, so a kit change requires a shell rebuild and a page reload; there is no HMR for it.
|
||||
- The panel, the float host, and the portalled tab menu use hard-coded z-index values; the client still has no z-index token layer.
|
||||
- Docked cells, floating cells and the portalled tab menu use fixed stacking levels; floating cells use CSS paint order within their level. The client still has no global z-index token layer.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
+4
-2
@@ -16,6 +16,8 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的
|
||||
|
||||
### 包拓扑
|
||||
|
||||
[Sidebar 稳定挂载决策](../architecture/2026-09-20-sidebar-retained-tab-layout.zh.md)负责停靠、浮动与会话切换共用的一棵保活 Tab 内容树。本文的引擎、布局状态与手势所有权仍然适用。
|
||||
|
||||
| 包 | 形态 | 所有物 |
|
||||
|---|---|---|
|
||||
| `packages/client/ui-dockkit` | 静态链接库,零 DSH 依赖 | 布局引擎与渲染/驱动它的 React 组件;消费方编译其源码,且它只保留一张样式表,因为消费方按文件名去重注入的样式表 |
|
||||
@@ -33,7 +35,7 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的
|
||||
|
||||
[响应式 Sidebar 与标签信息](../architecture/2026-09-07-sidebar-responsive-tab-info.zh.md)取代本记录中的无让步布局、覆盖模式与产品窗格上限。`ui-layout` 仍拥有三列几何与像素宽度偏好,Sidebar 占位项通过 `ctx.layout.openRightbar(track, fullscreen)` 和 `closeRightbar()` 报告呈现方式,框架不注入 Sidebar 包。具体宽度规则见 [ui-layout](../../../../packages/client/ui-layout/README.zh.md)。
|
||||
|
||||
右栏在普通与全屏模式下使用同一棵已挂载内容树;隐藏保留标签状态,全屏覆盖视口并保留底层列占位。浮窗仍经 portal 使用视口坐标,不随右栏关闭。产品限制为两个水平窗格与 20–80% 分割比例,通用引擎保留自己的默认值。
|
||||
右栏在普通与全屏模式下使用同一棵已挂载内容树;隐藏保留已保活的标签状态,全屏覆盖视口并保留底层列占位。`DockLayout` 在同一棵树中渲染稳定 Grid cell 与 fixed 定位的浮动 frame,前台浮窗不随右栏关闭。产品限制为两个水平窗格与 20–80% 分割比例,通用引擎保留自己的默认值。
|
||||
|
||||
### 状态
|
||||
|
||||
@@ -86,7 +88,7 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的
|
||||
- Detail 面板及其重复的卡片展示消失(净删约 1,400 行);卡片就地阅读,`inspect` 打开 trajectory 视图。
|
||||
- 框架没有中列下限:视口窄于两侧列之和时会话区被挤向零,而不是关掉某一列。
|
||||
- 库由消费方编译,改库须重建壳并刷新页面;它没有 HMR。
|
||||
- 面板、浮层宿主与 portal 出去的 tab 菜单使用硬编码 z-index;客户端仍没有 z-index token 层。
|
||||
- 停靠 cell、浮动 cell 和 portal 菜单使用固定层级,浮动 cell 在所属层内通过 CSS 绘制顺序排序;客户端仍没有全局 z-index token 层。
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-16-sidebar-browser.md
|
||||
2026-09-16-sidebar-browser.md: 3fa4e1dbb70dee97dbe6052d925271d093f2877c
|
||||
2026-09-16-sidebar-browser.zh.md: 69d78dbf2b959d660c9397c7bf7634547a109a9e
|
||||
2026-09-16-sidebar-browser.md: eff143cc042da7c6455152f684ec83dfa654a1ec
|
||||
2026-09-16-sidebar-browser.zh.md: a037c2fb54be745a3a85f43561c0bd587873c008
|
||||
@@ -18,17 +18,21 @@ A parent page cannot inspect or drive a cross-origin iframe's internal history.
|
||||
|
||||
The address parser accepts `http:` and `https:`, including loopback targets; a host name without a scheme becomes HTTPS. It rejects embedded credentials, the application's own origin, malformed addresses, `file:` URLs, and every other scheme. Document Preview remains the local-file surface.
|
||||
|
||||
The current carrier is an iframe in both Web and Desktop. Its default Web policy is `sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"`; the frame has no direct download or top-navigation flag. Popups escape the sandbox, and a Web popup retains its opener and can use that chain to navigate the top-level application. Same-origin lets the visited origin use its own cookies and Web storage; it does not make a cross-origin target same-origin with DSH. The iframe sends no referrer and adds no package-owned Permissions Policy, so browser defaults and user grants apply. A rightmost toolbar toggle removes the sandbox attribute for that tab occurrence; the mode is not persisted and renders a warning while active. An unsandboxed page can navigate the top-level application under browser activation rules and use downloads, modal dialogs, and input locks. The package performs no Host-side URL probe or proxy.
|
||||
The Web carrier is an iframe; Desktop uses the [retained webview carrier](2026-09-20-desktop-browser-webview.md). Its default Web policy is `sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"`; the frame has no direct download or top-navigation flag. Popups escape the sandbox, and a Web popup retains its opener and can use that chain to navigate the top-level application. Same-origin lets the visited origin use its own cookies and Web storage; it does not make a cross-origin target same-origin with DSH. The iframe sends no referrer and adds no package-owned Permissions Policy, so browser defaults and user grants apply. A rightmost toolbar toggle removes the sandbox attribute for that tab occurrence; the mode is not persisted and renders a warning while active. An unsandboxed page can navigate the top-level application under browser activation rules and use downloads, modal dialogs, and input locks. The package performs no Host-side URL probe or proxy.
|
||||
|
||||
Each tab receives one `BrowserController` class. Its command interface contains only `loadUrl`, `goBack`, `goForward`, and `reload`; it owns address validation and the `BrowserNavigation` state machine. The `BrowserFrame` interface owns transient sandbox and document state plus carrier operations, and `IframeImpl` implements it for the current iframe carrier. Slot injection exposes keyed frame state through `useBrowserFrame` and supplies plain callbacks, so the React body receives neither the controller nor an observable source; it owns only the editable draft and iframe DOM. A future `ElectronWebViewImpl` can implement the same interface without putting URL or carrier state in the component.
|
||||
Each tab receives one `BrowserController` for address validation and carrier-independent commands. `BrowserFrame` exposes navigation, observable address/loading/history/error state, and an optional sandbox control; `BrowserPresentation` owns DOM attachment. `pages.ts` composes these two objects into a `BrowserPage`. `IframeImpl` owns application-known `BrowserNavigation`, while `ElectronWebViewImpl` observes native history. Slot injection exposes keyed controller state through `useBrowserState` and plain callbacks; the React body owns its draft and content container, with no carrier branch, controller instance or observable source.
|
||||
|
||||
`BrowserNavigation` keeps the canonical current URL, controlled-load revision, navigation state, and a bounded sequence with its index. A new address drops the forward branch; Back and Forward move the index; Reload recreates the last application-known URL without adding history. A remounted body reloads the latest application-known URL and consults its optional initial URL only before the first controlled target. The Session-scoped store only persists immutable snapshots from that class for title rendering and application reload. An occurrence abort removes its bucket; `TabDomain` uses that same abort for both tab removal and `ui-sidebar-right` unload, so unloading or hot-reloading the Sidebar clears Browser history even when DockKit later restores the tab record.
|
||||
For the iframe carrier, `BrowserNavigation` keeps the canonical current URL, controlled-load revision, navigation state, and a bounded sequence with its index. A new address drops the forward branch; Back and Forward move the index; Reload recreates the last application-known URL without adding history. Within a live controller, remounting a body reloads its last requested URL. The Session-scoped store persists immutable checkpoints for tab titles and cold-start recovery; it does not serialize a live page.
|
||||
|
||||
A controller created from a saved checkpoint offers its last title and URL with a Restore page button. The provider starts idle: mounting the restored tab neither creates a browsing guest nor requests the saved site. Restore, toolbar Reload or address submission starts navigation; a fresh typed-open URL remains an explicit navigation request and loads directly. Before a page is requested, Back and Forward remain disabled, while Reload restores the saved address through the same controller action as Restore. Changing an idle iframe's sandbox does not restore it. This offer belongs to `BrowserControllerState`, not the live `BrowserFrame` interface or the persisted navigation status.
|
||||
|
||||
Occurrence cancellation releases the controller. It removes the saved checkpoint only when the Sidebar's authoritative `openTabs` inventory no longer contains that tab. The inventory publishes layout removal before `TabDomain` aborts its occurrence; plugin unload also aborts occurrences but leaves saved tab membership intact. Cleanup therefore does not interpret plugin unload as a request to erase recovery data.
|
||||
|
||||
Browser state is presentation state. It does not enter the Session log, model request, resource model, or DockKit layout operations. The existing [right Sidebar infrastructure](2026-09-04-right-sidebar-docking-infrastructure.md), [tab type contract](../architecture/2026-09-05-sidebar-tab-types-and-navigation.md), [resource model](../architecture/2026-09-05-client-resource-model.md), and [Document Preview operations](../architecture/2026-09-08-document-preview-operations.md) retain their existing responsibilities.
|
||||
|
||||
## Web navigation states
|
||||
|
||||
The Web carrier treats only the first iframe `load` for a controlled revision as confirmation of the application-known URL. A later `load` for that revision proves that the document changed but cannot reveal the new cross-origin URL. Events carrying an older revision are ignored.
|
||||
The Web carrier treats only the first iframe `load` for a controlled revision as confirmation of the application-known URL. A later `load` for that revision proves that the document changed but cannot reveal the new cross-origin URL. Events carrying an older revision are ignored. These states describe active navigation; an unrequested saved checkpoint remains a separate restore offer.
|
||||
|
||||
| State | Entry | Address and controls |
|
||||
|---|---|---|
|
||||
@@ -37,22 +41,16 @@ The Web carrier treats only the first iframe `load` for a controlled revision as
|
||||
| `known` | The first iframe `load` arrives for the current revision. | The requested URL remains authoritative even when that first load includes an HTTP redirect. The controls follow the same known-target rules as `loading`. |
|
||||
| `unknown` | A second or later iframe `load` arrives for the current revision. | The last controlled URL is muted and marked `URL changed`. Back and Forward are disabled because iframe exposes no cross-origin `canGoBack` or `canGoForward`; external-open is disabled. Reload starts a new revision at the last controlled URL. |
|
||||
|
||||
Address editing is available in every state. An invalid draft reports an address failure without changing the current navigation state. Focusing the unknown address hides its marker and reveals Go; Enter and Go both start a controlled load. Changing sandbox mode reloads the last controlled Web target under a new revision. An iframe `error` event marks only the current `BrowserFrame` revision with a transient load-failure notice; it does not change URL history, and the next controlled document clears it. Browsers do not reliably emit this event for DNS, TLS, mixed-content, CSP, or `X-Frame-Options` failures. `pushState`, `replaceState`, and fragment changes that emit no iframe `load` remain unobservable.
|
||||
Address editing is available in every state. An invalid draft reports an address failure without changing the current navigation state. Focusing the unknown address hides its marker and reveals Go; Enter and Go both start a controlled load. For an already requested page, changing sandbox mode reloads the last controlled Web target under a new revision. An iframe `error` event marks only the current `BrowserFrame` revision with a transient load-failure notice; it does not change URL history, and the next controlled document clears it. Browsers do not reliably emit this event for DNS, TLS, mixed-content, CSP, or `X-Frame-Options` failures. `pushState`, `replaceState`, and fragment changes that emit no iframe `load` remain unobservable.
|
||||
|
||||
## Deferred Electron carrier
|
||||
## Electron carrier
|
||||
|
||||
Electron `<webview>` support is designed but is not registered or tested. The controller keeps the same four commands, while a separate per-tab view object owns attachment, detachment, state observation, and target identity. This follows Playwright's Android WebView split: `AndroidWebView` is a target and lifecycle handle identified by package, process, and debugging socket, while `page()` returns the regular `Page` that owns Web navigation and DOM commands; device-level input remains on `AndroidDevice`.
|
||||
|
||||
The Desktop design enables `webviewTag` only on the application window. Its isolated preload receives an unguessable per-window capability, and every Browser tab appends a fresh UUID to form a distinct non-persistent partition. The main process accepts only an initial `about:blank` guest carrying that capability, removes any preload, and forces sandbox, context isolation, disabled Node integration in all frames, Web security, secure-content checks, disabled nested webviews, and disabled plugins.
|
||||
|
||||
The main process allows page-initiated main-frame navigation and redirects only to credential-free HTTP(S). Requests may use HTTP(S), WebSocket, data, and Blob URLs; direct file, custom-protocol, extension, and privileged requests are cancelled. Permission checks and requests, display capture, device grants, downloads, popup windows, and drag-and-drop navigation are denied.
|
||||
|
||||
The view object keeps an inactive guest connected in an owned hidden DOM host and moves it back into the visible placeholder without recreation. This retains page and target identity across Sidebar body remounts. Because `<webview>` participates in renderer layout and compositing, ordinary DOM dialogs, menus, tooltips, and drag previews can cover it. `WebContentsView` remains unsuitable because it is a native child surface: CSS cannot cover it, and every overlay or animation would require main-process visibility and bounds synchronization.
|
||||
|
||||
Each guest is a distinct WebContents and CDP target. Development may expose Electron's process-wide remote-debugging port and select the guest target explicitly. Production keeps that endpoint disabled; browser-use or computer-use requires an authenticated broker that binds one authorized tab to its WebContents and uses `webContents.debugger` or an equivalently scoped transport without publishing every application target.
|
||||
The [Desktop webview decision](2026-09-20-desktop-browser-webview.md) owns native page lifetime, Workspace storage sharing, guest policy, and runtime verification gaps. The iframe behavior in this note remains independently useful; its cross-origin limitations do not describe the Desktop carrier.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Automatically reload saved pages when their tabs mount.** This resumes third-party requests and scripts merely by restoring the layout. An explicit restore action preserves the saved address without opening the site.
|
||||
|
||||
**Add a Host embeddability probe and persist the sandbox preference.** Rejected because fetching arbitrary targets on the Host adds an SSRF path, the probe can disagree with later redirects, and a persistent global escape makes later tabs inherit an unsafe choice. Browser instead offers an explicit per-tab, non-persistent sandbox toggle with a visible warning.
|
||||
|
||||
**Treat parent-owned history as the complete Web model.** Rejected because it silently leaves a stale address and enabled actions after an in-frame navigation. The bounded parent-owned history remains useful while the current controlled URL is known; the explicit `unknown` state removes claims that the iframe API cannot support.
|
||||
@@ -67,8 +65,10 @@ Each guest is a distinct WebContents and CDP target. Development may expose Elec
|
||||
|
||||
Unit tests cover protocol parsing, delegated Markdown links, controller commands and lifecycle, deterministic navigation-state transitions, bounded history, best-effort iframe errors, and plugin disposal. Keyless Web scenarios boot the shipped composition and exercise message-link routing, HTTP(S), Back, Forward, sandbox control, unknown navigation, and protocol refusal.
|
||||
|
||||
Manual cold-start restoration and checkpoint retention across plugin unload have no runtime verification yet.
|
||||
|
||||
## Consequences
|
||||
|
||||
The Browser adds no Electron privilege and behaves identically in current Web and Desktop builds. Many sites refuse iframe embedding or require downloads or top-level navigation withheld from the frame by the default sandbox. An HTTPS application can block public HTTP pages as mixed content or restrict private-network requests, and disabling the sandbox does not bypass those browser policies. Disabling the sandbox otherwise trades its protections for compatibility: the frame can navigate the top-level application under browser activation rules and use downloads, modal dialogs, and input locks. A Web popup that escapes the sandbox retains its opener and can navigate the top-level application through that chain. Neither path adds Electron or Node APIs. The URL gate cannot prevent an embedded page from choosing its own destination. A later iframe load exposes that navigation occurred but not its cross-origin URL; History API and fragment changes can remain completely invisible. The deferred Electron carrier requires real packaged-app verification before it can become current behavior.
|
||||
The iframe carrier exposes no Electron or Node APIs. Many sites refuse iframe embedding or require downloads or top-level navigation withheld from the frame by the default sandbox. An HTTPS application can block public HTTP pages as mixed content or restrict private-network requests, and disabling the sandbox does not bypass those browser policies. Disabling the sandbox otherwise trades its protections for compatibility: the frame can navigate the top-level application under browser activation rules and use downloads, modal dialogs, and input locks. A Web popup that escapes the sandbox retains its opener and can navigate the top-level application through that chain. Neither path adds Electron or Node APIs. The URL gate cannot prevent an embedded page from choosing its own destination. A later iframe load exposes that navigation occurred but not its cross-origin URL; History API and fragment changes can remain completely invisible.
|
||||
|
||||
Site-cookie behavior follows the user's browser and is not isolated per Browser tab. Local files are rejected and remain owned by Document Preview. Persisted URLs can contain sensitive query or fragment values, so users must not enter credentials they do not want retained in application-local browser storage.
|
||||
Web site-cookie behavior follows the user's browser and is not isolated per Browser tab. Local files are rejected and remain owned by Document Preview. Persisted URLs can contain sensitive query or fragment values, so users must not enter credentials they do not want retained in application-local browser storage.
|
||||
@@ -18,17 +18,21 @@ Status: implemented
|
||||
|
||||
地址解析器接受 `http:` 与 `https:`,包括 loopback 目标;不带 scheme 的主机名补为 HTTPS。它拒绝内嵌凭据、应用自身 origin、畸形地址、`file:` URL,以及所有其他 scheme。本地文件继续由 Document Preview 负责。
|
||||
|
||||
当前 Web 与 Desktop 都使用 iframe 载体。它的默认 Web 策略是 `sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"`;frame 没有直接的下载或顶层导航 flag。popup 会脱离 sandbox,Web popup 会保留 opener,并可以通过该链导航顶层应用。same-origin 允许被访问的 origin 使用自己的 Cookie 与 Web storage;它不会让跨域目标与 DSH 变成同源。iframe 不发送 referrer,也不添加包自有的 Permissions Policy,因此浏览器默认策略与用户授权生效。最右侧 toolbar 开关会为当前 tab occurrence 移除 sandbox attribute;该模式不持久化,启用期间持续显示警告。未受 sandbox 约束的页面可以按浏览器 activation 规则导航顶层应用,并使用下载、模态对话框和输入锁定。本包不执行 Host 侧 URL probe 或代理。
|
||||
Web 使用 iframe 载体;Desktop 使用[保持页面实例的 webview 载体](2026-09-20-desktop-browser-webview.zh.md)。它的默认 Web 策略是 `sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"`;frame 没有直接的下载或顶层导航 flag。popup 会脱离 sandbox,Web popup 会保留 opener,并可以通过该链导航顶层应用。same-origin 允许被访问的 origin 使用自己的 Cookie 与 Web storage;它不会让跨域目标与 DSH 变成同源。iframe 不发送 referrer,也不添加包自有的 Permissions Policy,因此浏览器默认策略与用户授权生效。最右侧 toolbar 开关会为当前 tab occurrence 移除 sandbox attribute;该模式不持久化,启用期间持续显示警告。未受 sandbox 约束的页面可以按浏览器 activation 规则导航顶层应用,并使用下载、模态对话框和输入锁定。本包不执行 Host 侧 URL probe 或代理。
|
||||
|
||||
每个 tab 获得一个 `BrowserController` class。它的命令接口只有 `loadUrl`、`goBack`、`goForward` 与 `reload`;它负责地址校验和 `BrowserNavigation` 状态机。`BrowserFrame` 接口负责临时 sandbox 与 document 状态以及载体操作,`IframeImpl` 为当前 iframe 载体实现该接口。Slot injection 通过 `useBrowserFrame` 提供按 key 索引的 frame 状态,并提供普通 callback,因此 React body 不接收 controller 或 observable source;它只负责可编辑草稿与 iframe DOM。未来的 `ElectronWebViewImpl` 可以实现相同接口,而不把 URL 或载体状态放进组件。
|
||||
每个 tab 获得一个 `BrowserController`,负责地址校验与载体无关的命令。`BrowserFrame` 提供导航、可观察的地址/加载/history/错误状态和可选 sandbox 控制,`BrowserPresentation` 负责 DOM 挂载。`pages.ts` 把两个对象组装成 `BrowserPage`。`IframeImpl` 持有应用已知的 `BrowserNavigation`,`ElectronWebViewImpl` 则观察原生 history。Slot injection 通过 `useBrowserState` 提供按 key 索引的控制器状态和普通回调;React body 只持有草稿与内容容器,没有载体分支、控制器实例或可观察源。
|
||||
|
||||
`BrowserNavigation` 保存 canonical 当前 URL、受控加载 revision、导航状态,以及有上限的序列与当前位置。新地址丢弃 forward 分支;后退和前进移动 index;刷新重建应用最后已知的 URL 且不增加 history。body 重挂载时会重新加载应用最后已知的 URL,并且仅在尚无受控目标时使用可选初始 URL。Session-scoped store 只持久化该 class 的 immutable snapshot,供标题渲染与应用刷新恢复。occurrence abort 会删除其 bucket;`TabDomain` 对 tab 删除与 `ui-sidebar-right` 卸载使用同一个 abort,因此卸载或热重载 Sidebar 会清空 Browser history,即使 DockKit 随后恢复 tab record。
|
||||
对于 iframe 载体,`BrowserNavigation` 保存 canonical 当前 URL、受控加载 revision、导航状态,以及有上限的序列与当前位置。新地址丢弃 forward 分支;后退和前进移动 index;刷新重建应用最后已知的 URL 且不增加 history。在仍存活的控制器内,body 重挂载会重新加载最后请求的 URL。Session-scoped store 持久化不可变检查点,供 Tab 标题与冷启动恢复使用,不序列化活页面。
|
||||
|
||||
从已保存检查点创建的控制器展示上次标题、URL 和“恢复页面”按钮。提供方初始为空闲状态:挂载恢复的 Tab 既不创建浏览 guest,也不请求保存的站点。恢复、工具栏刷新或提交地址才启动导航;新打开 Tab 时显式传入的 URL 仍是主动导航请求,会直接加载。页面尚未请求时,后退和前进仍禁用,刷新则通过与“恢复页面”相同的控制器操作加载保存的地址。切换空闲 iframe 的沙箱也不会恢复页面。恢复提示属于 `BrowserControllerState`,不进入活页面的 `BrowserFrame` 接口或持久化导航状态。
|
||||
|
||||
occurrence 取消会释放控制器。只有 Sidebar 的权威 `openTabs` 清单已不再包含该 Tab 时,才删除保存的检查点。清单先发布布局中的 Tab 移除,再由 `TabDomain` abort 对应 occurrence;插件卸载也会 abort occurrence,但仍保留已保存的 Tab 成员信息。因此清理不会把插件卸载当成删除恢复数据的请求。
|
||||
|
||||
Browser 状态只属于呈现层,不进入 Session log、模型请求、resource model 或 DockKit layout operation。现有的[右侧 Sidebar 基础设施](2026-09-04-right-sidebar-docking-infrastructure.zh.md)、[tab 类型契约](../architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md)、[resource model](../architecture/2026-09-05-client-resource-model.zh.md)和[文档预览操作](../architecture/2026-09-08-document-preview-operations.zh.md)继续负责各自现有职责。
|
||||
|
||||
## Web 导航状态
|
||||
|
||||
对于一个受控 revision,Web 载体只把 iframe 的第一次 `load` 当作应用已知 URL 的确认。同一 revision 后续发生 `load`,只能证明文档已经变化,无法给出新的跨域 URL。携带旧 revision 的事件会被忽略。
|
||||
对于一个受控 revision,Web 载体只把 iframe 的第一次 `load` 当作应用已知 URL 的确认。同一 revision 后续发生 `load`,只能证明文档已经变化,无法给出新的跨域 URL。携带旧 revision 的事件会被忽略。这些状态描述活动导航;尚未请求的已保存检查点仍是独立的恢复提示。
|
||||
|
||||
| 状态 | 进入条件 | 地址与控件 |
|
||||
|---|---|---|
|
||||
@@ -37,22 +41,16 @@ Browser 状态只属于呈现层,不进入 Session log、模型请求、resour
|
||||
| `known` | 当前 revision 收到 iframe 的第一次 `load`。 | 即使第一次加载包含 HTTP redirect,请求 URL 仍是权威地址。控件规则与 `loading` 的已知目标规则相同。 |
|
||||
| `unknown` | 当前 revision 收到 iframe 的第二次或后续 `load`。 | 最后一个受控 URL 变灰,并标记 `URL 已变化`。iframe 不提供跨域 `canGoBack` 或 `canGoForward`,因此后退和前进禁用;外部打开禁用。刷新以最后一个受控 URL 启动新的 revision。 |
|
||||
|
||||
每个状态都允许编辑地址。无效草稿只报告地址错误,不改变当前导航状态。聚焦 unknown 地址会隐藏标记并显示前往按钮;回车与前往都会启动受控加载。切换 sandbox 模式会使用新的 revision 重新加载最后一个受控 Web 目标。iframe `error` event 只会为当前 `BrowserFrame` revision 标记临时加载失败 notice;它不改变 URL history,下一个受控 document 会清除它。浏览器不会为 DNS、TLS、mixed-content、CSP 或 `X-Frame-Options` 失败可靠触发该 event。不产生 iframe `load` 的 `pushState`、`replaceState` 与 fragment 变化仍不可观察。
|
||||
每个状态都允许编辑地址。无效草稿只报告地址错误,不改变当前导航状态。聚焦 unknown 地址会隐藏标记并显示前往按钮;回车与前往都会启动受控加载。对于已经请求的页面,切换 sandbox 模式会使用新的 revision 重新加载最后一个受控 Web 目标。iframe `error` event 只会为当前 `BrowserFrame` revision 标记临时加载失败 notice;它不改变 URL history,下一个受控 document 会清除它。浏览器不会为 DNS、TLS、mixed-content、CSP 或 `X-Frame-Options` 失败可靠触发该 event。不产生 iframe `load` 的 `pushState`、`replaceState` 与 fragment 变化仍不可观察。
|
||||
|
||||
## Deferred Electron carrier
|
||||
## Electron carrier
|
||||
|
||||
Electron `<webview>` 支持已经完成设计,但当前不注册也不测试。controller 保持相同的四个命令,独立的 per-tab view 对象负责 attachment、detachment、状态观察与 target identity。这沿用 Playwright 的 Android WebView 划分:`AndroidWebView` 是由 package、process 与调试 socket 标识的 target 和生命周期 handle,`page()` 返回负责 Web 导航与 DOM 命令的普通 `Page`;设备级输入仍属于 `AndroidDevice`。
|
||||
|
||||
Desktop 设计只在应用窗口启用 `webviewTag`。其隔离 preload 获得不可猜的 per-window capability,每个 Browser tab 再追加新的 UUID,形成独立非持久化 partition。主进程只接受携带该 capability 且初始地址为 `about:blank` 的 guest,删除任何 preload,并强制启用 sandbox 与 context isolation、在所有 frame 禁用 Node integration、启用 Web security 与安全内容检查、禁用嵌套 webview 和 plugin。
|
||||
|
||||
主进程只允许页面发起的 main-frame 导航与重定向前往不含凭据的 HTTP(S)。请求可以使用 HTTP(S)、WebSocket、data 与 Blob URL;直接 file、自定义协议、extension 与特权请求都会被取消。permission 检查与请求、显示捕获、设备授权、下载、弹窗和拖放导航全部拒绝。
|
||||
|
||||
view 对象把非活动 guest 保持连接并停放在自有隐藏 DOM host 中,再把它移回可见占位符而不重建。这会在 Sidebar body remount 后保留页面与 target identity。因为 `<webview>` 参与 renderer 布局与合成,普通 DOM dialog、menu、tooltip 与拖拽预览可以覆盖它。`WebContentsView` 仍不合适,因为它是原生 child surface:CSS 无法覆盖它,每个 overlay 或动画都需要主进程同步 visibility 与 bounds。
|
||||
|
||||
每个 guest 都是独立 WebContents 和 CDP target。开发环境可以开放 Electron 的进程级 remote-debugging port,并明确选择 guest target。生产环境保持该 endpoint 关闭;browser-use 或 computer-use 需要一个经过认证的 broker,把一个已授权 tab 绑定到其 WebContents,并使用 `webContents.debugger` 或等价的 scoped transport,而不公开全部应用 target。
|
||||
[Desktop webview 决策](2026-09-20-desktop-browser-webview.zh.md)负责原生页面生命周期、Workspace 存储共享、guest 策略与运行时验证缺口。本文的 iframe 行为仍具有独立价值;其跨域限制不描述 Desktop 载体。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Tab 挂载时自动重新加载已保存页面。** 这会仅因恢复布局就恢复第三方请求与脚本执行。显式恢复操作保留已保存地址,但不会自行打开站点。
|
||||
|
||||
**增加 Host embeddability probe 并持久化 sandbox 偏好。** 不采用,因为由 Host 请求任意目标会新增 SSRF 路径,probe 可能与后续重定向结果不一致,而持久化的全局逃生开关会让后续 tab 继承不安全选择。Browser 改为提供显式、按 tab、非持久化的 sandbox 开关,并持续显示警告。
|
||||
|
||||
**把 parent-owned history 当成完整 Web 模型。** 不采用,因为 iframe 内导航后,地址会悄然陈旧,而相关操作仍显示可用。当前受控 URL 已知时,有上限的 parent history 仍然有用;显式 `unknown` 状态会移除 iframe API 无法支持的能力声明。
|
||||
@@ -67,8 +65,10 @@ view 对象把非活动 guest 保持连接并停放在自有隐藏 DOM host 中
|
||||
|
||||
单元测试覆盖协议解析、Markdown 链接委托、controller 命令与生命周期、确定性导航状态转换、有界 history、best-effort iframe error 和插件 disposal。Keyless Web 场景启动随附 composition,并覆盖消息链接路由、HTTP(S)、后退、前进、sandbox 控制、unknown 导航和协议拒绝。
|
||||
|
||||
手动冷启动恢复和跨插件卸载保留检查点尚无运行时验证。
|
||||
|
||||
## Consequences
|
||||
|
||||
Browser 不增加 Electron 权限,并在当前 Web 与 Desktop 构建中保持相同行为。很多站点拒绝 iframe 嵌入,或者依赖默认 sandbox 不向 frame 提供的下载或顶层导航。HTTPS 应用可能按 mixed-content 策略阻止公共 HTTP 页面,或限制 private-network 请求;关闭 sandbox 也无法绕过这些浏览器策略。关闭 sandbox 在其他方面会用自身保护换取兼容性:frame 可以按浏览器 activation 规则导航顶层应用,并使用下载、模态对话框和输入锁定。逃逸出 sandbox 的 Web popup 会保留 opener,并可以通过该链导航顶层应用。这两条路径都不会增加 Electron 或 Node API。URL 检查无法阻止 iframe 内页面自行选择目标。后续 iframe load 能表明已经发生导航,但无法给出跨域 URL;History API 与 fragment 变化可能完全不可见。延期的 Electron 载体必须通过真实打包应用验证,才能成为当前行为。
|
||||
iframe 载体不暴露 Electron 或 Node API。很多站点拒绝 iframe 嵌入,或者依赖默认 sandbox 不向 frame 提供的下载或顶层导航。HTTPS 应用可能按 mixed-content 策略阻止公共 HTTP 页面,或限制 private-network 请求;关闭 sandbox 也无法绕过这些浏览器策略。关闭 sandbox 在其他方面会用自身保护换取兼容性:frame 可以按浏览器 activation 规则导航顶层应用,并使用下载、模态对话框和输入锁定。逃逸出 sandbox 的 Web popup 会保留 opener,并可以通过该链导航顶层应用。这两条路径都不会增加 Electron 或 Node API。URL 检查无法阻止 iframe 内页面自行选择目标。后续 iframe load 能表明已经发生导航,但无法给出跨域 URL;History API 与 fragment 变化可能完全不可见。
|
||||
|
||||
站点 Cookie 行为遵循用户浏览器,并不按 Browser tab 隔离。本地文件会被拒绝,并继续由 Document Preview 负责。持久化 URL 可能含敏感 query 或 fragment,因此用户不应在地址栏输入不希望保留在应用本地浏览器存储中的凭据。
|
||||
Web 站点 Cookie 行为遵循用户浏览器,并不按 Browser tab 隔离。本地文件会被拒绝,并继续由 Document Preview 负责。持久化 URL 可能含敏感 query 或 fragment,因此用户不应在地址栏输入不希望保留在应用本地浏览器存储中的凭据。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-20-desktop-browser-webview.md
|
||||
2026-09-20-desktop-browser-webview.md: e69114a90f50d51542e9b6a0d40daef05a93001f
|
||||
2026-09-20-desktop-browser-webview.zh.md: b0ab8b1a7058ae9cbb1258977cb4c4e2d8c40614
|
||||
@@ -0,0 +1,59 @@
|
||||
# Agent Note: Retained Desktop browser webviews
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-20-desktop-browser-webview.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
An iframe cannot expose cross-origin navigation or render sites that refuse embedding. An Electron guest loses forms, scroll position and native history if its tab body unmounts on selection or container changes. Page lifetime must therefore remain independent of visibility and layout changes.
|
||||
|
||||
Independent per-tab storage also prevents related pages in one Workspace from sharing site login state. Page lifetime and storage ownership need different owners.
|
||||
|
||||
## Decision
|
||||
|
||||
Desktop uses `<webview>` through `ElectronWebViewImpl`; Web retains its opt-in iframe carrier. The [Sidebar Browser decision](2026-09-16-sidebar-browser.md) owns shared tab behavior and iframe limitations; this note owns the Desktop carrier and Workspace-keyed partitions.
|
||||
|
||||
The [stable Sidebar mounting decision](../architecture/2026-09-20-sidebar-retained-tab-layout.md) owns retained Session and tab containers in real CSS layout. Workspace storage ownership and baseline guest security remain with this note.
|
||||
|
||||
- `BrowserController` owns address commands and recoverable presentation state. Native history stays in the guest; actual URL and title observations update the persisted address.
|
||||
- Controller registries are keyed by DSH Session, independently of presentation bindings. Rebinding replaces the store writer without recreating the page.
|
||||
- `electron/pages.ts` composes the Electron navigation provider and presentation object. `ElectronWebViewImpl` owns guest leases and native navigation; `ElectronWebviewPresentation` creates the tag and attaches it inside the Sidebar-owned content container.
|
||||
- The Desktop Browser type declares `keepMounted`. Sidebar retains its DOM ancestors across hiding and docking; CSS owns layout and clipping. Docking gestures disable guest pointer input, and body-local drop hints use normal stacking. A physical unmount cancels pending attachment and releases the guest; a later mount recreates it from the known address.
|
||||
- Tab occurrence cancellation, plugin unload and window destruction release guests. Guest crashes leave a retryable failure; Reload creates a new guest. Application restart offers the saved title and URL for explicit restoration; it creates no guest until the user restores or submits an address. Page memory and native history are not restored.
|
||||
|
||||
The standard `./types` subpath exports the shared lease, reservation, open-request and bridge declarations through a declaration-only `types` condition; Desktop consumers use `import type`. There is no runtime default, extra JavaScript artifact or early Host bundling for these declarations. Preload exposes scoped operations and callbacks, not raw IPC or Electron objects.
|
||||
|
||||
Browser has separate Host and Client compiler programs. Desktop and the Host aggregate reference only `tsconfig.host.json`, which compiles the package root and shared bridge declarations; the Client aggregate references `tsconfig.client.json` for the browser implementation. The package root tsconfig is solution-only. This keeps Client consumers of generated `/remote` declarations out of the Host compilation that precedes Typert generation.
|
||||
|
||||
### Storage ownership
|
||||
|
||||
`DesktopBrowserGuests` assigns a random, non-persistent Electron partition to each canonical CWD account. The Client uses the Host-normalized `WorkspaceView.path`, not the Workspace record UUID, so recreating a Workspace at the same directory does not change its storage account key. Browser tabs whose DSH Sessions resolve to that CWD share the account; Sessions without a resolved Workspace remain separately isolated. Workspace membership is resolved after the Client receives its authoritative baseline and is fixed for a guest occurrence. The CWD key controls sharing, not disk persistence.
|
||||
|
||||
Closing a tab releases its guest, not its account's cookies or storage. Cookies, localStorage, IndexedDB, Service Workers and cache remain partition-owned and subject to normal origin rules. DOM, native history and sessionStorage remain page-owned. Account partitions survive window recreation within the Electron process but do not persist across application exit.
|
||||
|
||||
### Initial guest policy
|
||||
|
||||
Only the primary application window enables `webviewTag`. The main process accepts guest reservations only from that window's application top frame and validates a one-use lease, partition and inert initial `about:blank` document before allowing the guest. The main process replaces renderer-supplied preferences: no Node integration, guest preload, nested webviews, plugins, insecure content, dialogs or drag navigation; sandbox, context isolation and Web security stay enabled.
|
||||
|
||||
Guest Sessions do not register the application protocol or inherit the application's authenticated request forwarding. Permission requests and checks, device access, display capture, downloads, native popup windows and HTTP authentication prompts are denied. Approved direct HTTP(S) popup requests without a POST body are routed through their live lease to a new Sidebar tab; scripted blank-window and POST popup flows remain unsupported. Navigation accepts credential-free HTTP(S); request filtering rejects local-file and privileged schemes and the known DSH Host endpoint, including common loopback aliases. This is not a general private-network or DNS-rebinding firewall.
|
||||
|
||||
The Desktop toolbar has no sandbox-disable switch. The implementation adds no remote-debugging endpoint or browser-use integration. A future automation provider requires an authenticated, target-scoped broker rather than access to every application target. The separation of a target/lifetime handle from navigation commands follows the same distinction as Playwright's Android WebView and Page objects; device-level input is a separate responsibility.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Render the guest in a visibility-mounted tab body.** Unmounting disconnects the guest. Direct mounting requires the Sidebar's retained body and stable ancestor mechanism.
|
||||
|
||||
**Move the guest between visible and hidden DOM containers.** Electron 44's `WebViewElement.disconnectedCallback` detaches the guest and resets its internal instance. Retaining the element reference or React key does not preserve that instance; the parent stays fixed instead.
|
||||
|
||||
**Use one partition per tab.** This isolates site accounts between related pages. Workspace ownership provides the requested sharing without sharing the application Session; a tab still owns its own guest.
|
||||
|
||||
**Change the partition of an active webview.** Electron fixes the partition before first navigation. Selecting another isolation scope requires a replacement guest and an explicit policy for existing site data, not a live attribute toggle.
|
||||
|
||||
## Consequences
|
||||
|
||||
Sidebar-owned stable ancestors preserve the page without Browser-owned geometry or occlusion handling. Hidden guests retain page memory and may continue network activity; there is no idle eviction policy. Persistent storage, selectable isolation scopes and permission-grant UI remain separate work. Temporary Workspace partitions are the current policy, not a promise that Workspace isolation always implies temporary storage.
|
||||
|
||||
Focused tests cover native navigation error recovery, preload listener scoping and migrated iframe behavior. They do not establish real Electron attachment timing, overlap, focus, platform styling or storage isolation; those remain runtime verification gaps. No GUI recording accompanies the change. This implementation is not evidence that the complete browser security policy is ready for release.
|
||||
|
||||
The package's Electron provider, presentation, page factory and Workspace storage resolver live in `src/client/electron/`. That directory is temporarily excluded from per-file coverage until a native Electron harness covers guest behavior; its unit tests still run. Shared Browser controllers, restoration UI, iframe code and Sidebar retention remain subject to the existing coverage requirements.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Agent Note: 保持页面实例的 Desktop 浏览器 webview
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-20-desktop-browser-webview.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
iframe 无法提供跨域导航状态, 也无法显示拒绝嵌入的站点. 如果 Tab Body 因选中项或容器变化而卸载, Electron guest 就会丢失表单、滚动位置与原生 history. 页面生命周期因此必须独立于可见性和布局变化.
|
||||
|
||||
独立的 per-tab 存储也会阻止同一 Workspace 内相关页面共享站点登录态。页面生命周期与存储需要不同的所有者。
|
||||
|
||||
## Decision
|
||||
|
||||
Desktop 通过 `ElectronWebViewImpl` 使用 `<webview>`; Web 保留显式启用的 iframe 载体. [Sidebar Browser 决策](2026-09-16-sidebar-browser.zh.md)负责共享 Tab 行为与 iframe 限制; 本文负责 Desktop 载体和按 Workspace 共享的分区.
|
||||
|
||||
[Sidebar 稳定挂载决策](../architecture/2026-09-20-sidebar-retained-tab-layout.zh.md)负责真实 CSS 布局中的保活会话与 Tab 容器。Workspace 存储所有权与 guest 基础安全配置仍由本文负责。
|
||||
|
||||
- `BrowserController` 负责地址命令与可恢复的展示状态。原生 history 留在 guest 内;真实 URL 与标题观察更新持久化地址。
|
||||
- 控制器注册表按 DSH Session 索引,不依赖呈现绑定的存活期。重新绑定只替换存储写入方,不重建页面。
|
||||
- `electron/pages.ts` 组装 Electron 导航提供方和呈现对象. `ElectronWebViewImpl` 负责 guest 租约与原生导航; `ElectronWebviewPresentation` 创建标签并挂载到 Sidebar 持有的内容容器内.
|
||||
- Desktop Browser 类型声明 `keepMounted`。Sidebar 在隐藏和停靠切换时保留其 DOM 祖先,CSS 负责布局与裁剪。停靠手势期间禁用 guest 指针输入,Body 内的放置提示使用普通层叠。物理卸载会取消未完成的挂载并释放 guest,之后重新挂载时从已知地址重建。
|
||||
- tab occurrence 取消、插件卸载与窗口销毁会释放 guest。guest 崩溃留下可重试的失败状态;刷新创建新 guest。应用重启展示保存的标题与 URL,等待显式恢复;用户恢复或提交地址之前不创建 guest。不恢复页面内存或原生 history。
|
||||
|
||||
标准 `./types` 子路径通过仅指向声明文件的 `types` 条件导出共享的租约、申请结果、打开请求与桥接声明,Desktop 消费方使用 `import type`。这些声明没有运行时 default、额外 JavaScript 产物或提前进行的 Host 打包。preload 只暴露限定范围的操作与回调,不暴露原始 IPC 或 Electron 对象。
|
||||
|
||||
Browser 分别拥有 Host 与 Client 编译程序。Desktop 和 Host 聚合配置只引用 `tsconfig.host.json`,编译包根与共享桥接声明;Client 聚合配置通过 `tsconfig.client.json` 编译浏览器实现。包根 tsconfig 只作为 solution。这样,依赖生成 `/remote` 声明的 Client 消费方不会进入 Typert 生成之前的 Host 编译阶段。
|
||||
|
||||
### Storage ownership
|
||||
|
||||
`DesktopBrowserGuests` 为每个规范化 CWD 存储账号分配随机、非持久化的 Electron partition。Client 使用 Host 规范化后的 `WorkspaceView.path`,不使用 Workspace 记录 UUID,因此在同一目录重建 Workspace 不改变存储账号 key。DSH Session 解析到同一 CWD 的 Browser Tab 共享账号;没有可解析 Workspace 的 Session 仍单独隔离。Workspace 归属在 Client 收到权威基线后解析,并在一次 guest occurrence 内保持不变。CWD key 控制共享关系,不决定是否落盘。
|
||||
|
||||
关闭 tab 只释放它的 guest,不清空账号的 Cookie 或存储。Cookie、localStorage、IndexedDB、Service Worker 与缓存由 partition 持有,并继续遵循普通 origin 规则。DOM、原生 history 与 sessionStorage 仍由页面持有。账号 partition 在同一 Electron 进程内重建窗口后仍保留,但不跨应用退出持久化。
|
||||
|
||||
### Initial guest policy
|
||||
|
||||
只有主应用窗口启用 `webviewTag`。主进程只接受该窗口应用顶层 frame 发起的 guest 申请,并在放行前校验一次性租约、partition 与无活动内容的初始 `about:blank` 文档。主进程替换 renderer 提供的偏好:不启用 Node integration、guest preload、嵌套 webview、plugin、不安全内容、模态对话框或拖放导航;sandbox、context isolation 与 Web security 保持开启。
|
||||
|
||||
guest Session 不注册应用协议,也不继承应用的认证请求转发。权限请求与检查、设备访问、屏幕捕获、下载、原生弹窗与 HTTP 认证提示全部拒绝。通过检查且不带 POST body 的直接 HTTP(S) 弹窗请求,通过活动租约路由为新 Sidebar Tab;脚本操作空白窗口和 POST 弹窗流程仍不支持。导航接受不带内嵌凭据的 HTTP(S);请求过滤拒绝本地文件、特权协议与已知 DSH Host 地址,包括常见 loopback 别名。这不是通用的私有网络或 DNS rebinding 防火墙。
|
||||
|
||||
Desktop toolbar 没有关闭 sandbox 的开关。实现不增加远程调试端口或 browser-use 集成。未来的自动化提供方需要经过认证、限定 target 的 broker,而不是访问全部应用 target。把 target/生命周期句柄与导航命令分开的方式,与 Playwright 的 Android WebView 和 Page 对象区分相同;设备级输入属于另一项职责。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**在按可见性挂载的 tab body 内渲染 guest。** 卸载会断开 guest;直接挂载需要 Sidebar 提供 Body 保活与稳定祖先机制。
|
||||
|
||||
**在可见与隐藏 DOM 容器之间搬移 guest。** Electron 44 的 `WebViewElement.disconnectedCallback` 会 detach guest 并重置内部实例。保留元素引用或 React key 不能保留该实例,因此父节点保持固定。
|
||||
|
||||
**每个 tab 使用独立 partition。** 这会隔离相关页面的站点账号。Workspace 所有权提供所需的共享,同时不共享应用 Session;每个 tab 仍拥有独立 guest。
|
||||
|
||||
**修改活动 webview 的 partition。** Electron 在首次导航前确定 partition。选择其他隔离粒度需要替换 guest,并明确已有站点数据的处置策略,而不是实时切换属性。
|
||||
|
||||
## Consequences
|
||||
|
||||
Sidebar 持有的稳定祖先保留页面,无需 Browser 自行处理几何或遮挡。隐藏 guest 保留页面内存,也可能继续联网;当前没有空闲回收策略。持久化存储、可选择的隔离级别与权限授权 UI 留作独立工作。临时 Workspace partition 是当前策略,并不意味着 Workspace 隔离必然要求临时存储。
|
||||
|
||||
定向测试覆盖原生导航错误恢复、preload 监听范围和已迁移的 iframe 行为。这些测试不能确认真实 Electron 的挂载时序、遮挡、焦点、平台样式或存储隔离;这些仍是运行时验证缺口。本次变更不附带 GUI 录像。此实现不构成完整浏览器安全策略已经可以发布的证据。
|
||||
|
||||
包内的 Electron 提供方、呈现适配器、页面工厂与 Workspace 存储解析集中在 `src/client/electron/`. 在原生 Electron 测试环境覆盖 guest 行为之前, 该目录暂时不参与逐文件覆盖率门禁; 其单元测试仍然运行. 共享 Browser 控制器、恢复 UI、iframe 代码和 Sidebar 保活仍遵循现有覆盖率要求.
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/desktop/README.md
|
||||
README.md: 48ad6bbcf515aac5734da1fd406ce70f0f4d6505
|
||||
README.zh.md: 795e62a0e119e40211387a12f9e4fa56fdde3e79
|
||||
README.md: dff2a98591b763d28b0e2d35430f8120d92f0afd
|
||||
README.zh.md: 7e98bb6c2fb390f9984887b64b3a01c0a89530dc
|
||||
@@ -46,7 +46,9 @@ The [thin-wrapper decision](../../.agents/notes/implemented/architecture/2026-09
|
||||
|
||||
Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains packages installed by pnpm; `dsh.profile.bundles` contains the built-in bundles followed by enabled plugins. The signed application supplies dsh, the private Desktop Host, and their production packages from `resources/app.asar/dsh`. Packaged applications select runtime profile resolution without creating package links; development profiles use filesystem links. Both host and plugins execute in the same Electron Node-mode process; Desktop does not enable `--preserve-symlinks`. The CLI cannot boot or mutate this profile.
|
||||
|
||||
The application preload exposes boot readiness, fatal startup reporting, and native directory selection. Product documents use the shared authenticated HTTP APIs and receive the Desktop marker, update presentation, and an action that opens native confirmation; they cannot choose artifacts or authorize installation. Plugin management uses the Web application's authenticated HTTP APIs. Electron serves update-dialog documents and assets locally at `dsh-app://shell/`, independently of Host readiness. Electron exposes no plugin-management IPC or separate management document. No renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments.
|
||||
The application preload exposes boot readiness, fatal startup reporting, native directory selection, and the lease-scoped Browser bridge only to `dsh-app://app` documents. Product documents use the shared authenticated HTTP APIs and receive the Desktop marker, update presentation, and an action that opens native confirmation; they cannot choose artifacts or authorize installation. Plugin management uses the Web application's authenticated HTTP APIs. Electron serves update-dialog documents and assets locally at `dsh-app://shell/`, independently of Host readiness. Electron exposes no plugin-management IPC or separate management document. No renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments.
|
||||
|
||||
Only the main application window enables `<webview>`. Guest attachment must match a main-issued lease and partition; guests keep sandbox, context isolation and Web security without Node integration or guest preload. Browser IPC listeners are created only for the application document. [Sidebar Browser](../../packages/client/ui-sidebar-browser/README.md) describes storage grouping and guest limitations; Host authentication remains required independently of URL filtering.
|
||||
|
||||
The `dsh-app://shell/` origin serves packaged update documents, scripts, and styles without contacting the Host. Static requests retain GET/HEAD, path-containment, and MIME handling; each update document keeps its isolated preload and owned-window IPC checks.
|
||||
|
||||
|
||||
@@ -46,7 +46,9 @@ Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载
|
||||
|
||||
Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 安装的包;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/app.asar/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。打包应用选择 runtime profile 解析,不创建包链接;开发 profile 使用文件系统链接。宿主与插件在同一个 Electron Node 模式进程中执行;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动或修改此 profile。
|
||||
|
||||
应用 preload 暴露启动就绪、致命启动失败上报和原生目录选择。产品页面还获得 Desktop 标记、更新展示数据和打开原生确认的操作,不能选择安装产物或授权安装。插件管理使用 Web 应用经过认证的 HTTP API;Electron 在 `dsh-app://shell/` 本地提供更新弹窗文档和资源,不依赖 Host 就绪。Electron 不提供插件管理 IPC 或独立管理页面。
|
||||
应用 preload 只向 `dsh-app://app` 文档暴露启动就绪、致命启动失败上报、原生目录选择和租约范围内的 Browser 桥接。产品页面还获得 Desktop 标记、更新展示数据和打开原生确认的操作,不能选择安装产物或授权安装。插件管理使用 Web 应用经过认证的 HTTP API;Electron 在 `dsh-app://shell/` 本地提供更新弹窗文档和资源,不依赖 Host 就绪。Electron 不提供插件管理 IPC 或独立管理页面。
|
||||
|
||||
只有主应用窗口启用 `<webview>`。guest 挂载必须匹配主进程签发的租约和分区;guest 保持 sandbox、context isolation 和 Web security,不启用 Node integration 或 guest preload。Browser IPC 监听只为应用文档创建。[Sidebar Browser](../../packages/client/ui-sidebar-browser/README.zh.md) 说明存储分组和 guest 限制;Host 鉴权仍独立于 URL 过滤而必需。
|
||||
|
||||
`dsh-app://shell/` 无需联系 Host 即可提供打包的更新文档、脚本和样式。静态请求保留 GET/HEAD、路径范围和 MIME 处理;每个更新文档继续使用隔离 preload 和所属窗口的 IPC 校验。
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings-general": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar-browser": "workspace:^",
|
||||
"@deepseek-ai/dsh-home-paths": "workspace:^",
|
||||
"@deepseek-ai/node-addon-system": "workspace:^",
|
||||
"@electron/get": "^5.1.0",
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/** Main-process ownership and fixed isolation policy for Sidebar webview guests. */
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { app, session, type BrowserWindow, type Session, type WebContents } from 'electron'
|
||||
import type { DesktopBrowserLeaseId, DesktopBrowserOpenRequest, DesktopBrowserReservation } from '@deepseek-ai/dsh-client-ui-sidebar-browser/types'
|
||||
import { DESKTOP_IPC } from './ipc.ts'
|
||||
|
||||
interface GuestLease {
|
||||
readonly owner: WebContents
|
||||
readonly partition: string
|
||||
attached: boolean
|
||||
guest?: WebContents
|
||||
}
|
||||
|
||||
/** Owns workspace storage partitions independently from individual tab guests. */
|
||||
export class DesktopBrowserGuests {
|
||||
private readonly partitions = new Map<string, string>()
|
||||
private readonly leases = new Map<DesktopBrowserLeaseId, GuestLease>()
|
||||
|
||||
/** @param hostUrl - current authenticated DSH Host, which guests cannot request. */
|
||||
constructor(private readonly hostUrl: () => string | undefined) {}
|
||||
|
||||
/**
|
||||
* Reserve one guest in a workspace's process-lifetime partition.
|
||||
* @param owner - authenticated primary application WebContents.
|
||||
* @param workspace - workspace identity received over IPC.
|
||||
* @returns opaque lease and the partition approved for it.
|
||||
*/
|
||||
acquire(owner: WebContents, workspace: unknown): DesktopBrowserReservation {
|
||||
if (typeof workspace !== 'string' || workspace.length === 0 || workspace.length > 4096) {
|
||||
throw new Error('desktop browser: a workspace storage identity is required')
|
||||
}
|
||||
let partition = this.partitions.get(workspace)
|
||||
if (partition === undefined) {
|
||||
partition = `dsh-sidebar-browser-${randomUUID()}`
|
||||
this.configureSession(session.fromPartition(partition))
|
||||
this.partitions.set(workspace, partition)
|
||||
}
|
||||
const lease = randomUUID() as DesktopBrowserLeaseId
|
||||
this.leases.set(lease, { owner, partition, attached: false })
|
||||
return { lease, partition }
|
||||
}
|
||||
|
||||
/**
|
||||
* Release only a lease issued to this application window; workspace storage survives.
|
||||
* @param owner - authenticated IPC sender.
|
||||
* @param id - lease received over IPC.
|
||||
*/
|
||||
async release(owner: WebContents, id: unknown): Promise<void> {
|
||||
if (typeof id !== 'string') throw new Error('desktop browser: invalid guest lease')
|
||||
const key = id as DesktopBrowserLeaseId
|
||||
const lease = this.leases.get(key)
|
||||
if (lease === undefined) return
|
||||
if (lease.owner !== owner) throw new Error('desktop browser: guest belongs to another window')
|
||||
this.leases.delete(key)
|
||||
const guest = lease.guest
|
||||
if (guest !== undefined && !guest.isDestroyed()) {
|
||||
const destroyed = new Promise<void>((resolve) => { guest.once('destroyed', resolve) })
|
||||
guest.close({ waitForBeforeUnload: false })
|
||||
await destroyed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install attachment checks before the application document can create a webview.
|
||||
* @param window - primary application window.
|
||||
*/
|
||||
bind(window: BrowserWindow): void {
|
||||
const owner = window.webContents
|
||||
owner.on('will-attach-webview', (event, preferences, params) => {
|
||||
const id = typeof params.src === 'string' && params.src.startsWith('about:blank#')
|
||||
? params.src.slice('about:blank#'.length) : ''
|
||||
const lease = this.leases.get(id as DesktopBrowserLeaseId)
|
||||
if (lease === undefined || lease.owner !== owner || lease.attached || params.partition !== lease.partition) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
lease.attached = true
|
||||
// Keep Electron's allowpopups dispatch flag; the guest handler still denies native windows.
|
||||
for (const key of Object.keys(preferences)) {
|
||||
if (key !== 'disablePopups') Reflect.deleteProperty(preferences, key)
|
||||
}
|
||||
Object.assign(preferences, {
|
||||
partition: lease.partition,
|
||||
nodeIntegration: false, nodeIntegrationInWorker: false, nodeIntegrationInSubFrames: false,
|
||||
contextIsolation: true, sandbox: true, webSecurity: true, allowRunningInsecureContent: false,
|
||||
webviewTag: false, plugins: false, navigateOnDragDrop: false, disableDialogs: true,
|
||||
devTools: !app.isPackaged,
|
||||
})
|
||||
params.httpreferrer = ''
|
||||
})
|
||||
owner.on('did-attach-webview', (_event, guest) => {
|
||||
let attachedLease: DesktopBrowserLeaseId | undefined
|
||||
// The first document is an inert about:blank carrying the approved lease.
|
||||
// Bind on the main-process event before the renderer can navigate the ready guest.
|
||||
guest.once('dom-ready', () => {
|
||||
const url = guest.getURL()
|
||||
const id = (url.startsWith('about:blank#') ? url.slice('about:blank#'.length) : '') as DesktopBrowserLeaseId
|
||||
const lease = this.leases.get(id)
|
||||
if (lease === undefined || lease.owner !== owner || lease.guest !== undefined) {
|
||||
guest.close({ waitForBeforeUnload: false })
|
||||
return
|
||||
}
|
||||
lease.guest = guest
|
||||
attachedLease = id
|
||||
guest.once('destroyed', () => { this.leases.delete(id) })
|
||||
})
|
||||
guest.setWindowOpenHandler(({ url, postBody }) => {
|
||||
const lease = attachedLease === undefined ? undefined : this.leases.get(attachedLease)
|
||||
if (attachedLease !== undefined && lease?.guest === guest && lease.owner === owner && !owner.isDestroyed()
|
||||
&& postBody === undefined && this.allowedNavigation(url)) {
|
||||
const request: DesktopBrowserOpenRequest = { lease: attachedLease, url: new URL(url).href }
|
||||
owner.send(DESKTOP_IPC.browserOpenRequested, request)
|
||||
}
|
||||
return { action: 'deny' }
|
||||
})
|
||||
guest.on('will-frame-navigate', (event) => {
|
||||
if (event.isMainFrame && !this.allowedNavigation(event.url)) event.preventDefault()
|
||||
})
|
||||
guest.on('will-redirect', (event, url, _inPlace, mainFrame) => {
|
||||
if (mainFrame && !this.allowedNavigation(url)) event.preventDefault()
|
||||
})
|
||||
guest.on('will-attach-webview', (event) => { event.preventDefault() })
|
||||
guest.on('login', (event, _details, _authInfo, callback) => { event.preventDefault(); callback() })
|
||||
})
|
||||
const releaseAll = (): void => {
|
||||
for (const [id, lease] of this.leases) {
|
||||
if (lease.owner === owner) void this.release(owner, id).catch((error: unknown) => { console.error(error) })
|
||||
}
|
||||
}
|
||||
owner.on('did-start-navigation', (_event, _url, inPlace, mainFrame) => {
|
||||
if (mainFrame && !inPlace) releaseAll()
|
||||
})
|
||||
owner.on('render-process-gone', releaseAll)
|
||||
owner.once('destroyed', releaseAll)
|
||||
}
|
||||
|
||||
private configureSession(browserSession: Session): void {
|
||||
browserSession.setPermissionRequestHandler((_contents, _permission, callback) => { callback(false) })
|
||||
browserSession.setPermissionCheckHandler(() => false)
|
||||
browserSession.setDevicePermissionHandler(() => false)
|
||||
browserSession.setDisplayMediaRequestHandler((_request, callback) => { callback({}) })
|
||||
browserSession.on('will-download', (event) => { event.preventDefault() })
|
||||
browserSession.webRequest.onBeforeRequest((details, callback) => {
|
||||
const url = new URL(details.url)
|
||||
const network = ['http:', 'https:', 'ws:', 'wss:'].includes(url.protocol)
|
||||
callback({ cancel: network
|
||||
? url.username !== '' || url.password !== '' || this.isApplicationHost(url)
|
||||
: !['about:', 'data:', 'blob:'].includes(url.protocol) })
|
||||
})
|
||||
}
|
||||
|
||||
private allowedNavigation(value: string): boolean {
|
||||
if (!URL.canParse(value)) return false
|
||||
const url = new URL(value)
|
||||
return ['http:', 'https:'].includes(url.protocol) && url.username === '' && url.password === ''
|
||||
&& !this.isApplicationHost(url)
|
||||
}
|
||||
|
||||
private isApplicationHost(url: URL): boolean {
|
||||
const value = this.hostUrl()
|
||||
if (value === undefined) return false
|
||||
const host = new URL(value)
|
||||
return url.port === host.port
|
||||
&& (url.hostname === host.hostname || ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
/** Typed preload operations exposed only by the Electron shell. */
|
||||
|
||||
import type { IpcMainInvokeEvent } from 'electron'
|
||||
import type { DesktopBrowserBridge } from '@deepseek-ai/dsh-client-ui-sidebar-browser/types'
|
||||
|
||||
/** IPC channel names kept private to the desktop application bundle. */
|
||||
export const DESKTOP_IPC = {
|
||||
boot: 'dsh-desktop:boot',
|
||||
bootFailed: 'dsh-desktop:boot-failed',
|
||||
browserAcquire: 'dsh-desktop:browser-acquire',
|
||||
browserRelease: 'dsh-desktop:browser-release',
|
||||
browserOpenRequested: 'dsh-desktop:browser-open-requested',
|
||||
directoryPick: 'dsh-desktop:directory-pick',
|
||||
updatesStatus: 'dsh-desktop:updates-status',
|
||||
updatesOpen: 'dsh-desktop:updates-open',
|
||||
@@ -54,6 +58,7 @@ export interface DesktopUpdatePresentation {
|
||||
/** Product documents cannot supply update versions, package URLs, or installation authorization. */
|
||||
export interface DshDesktopProductApi {
|
||||
readonly protocolVersion: 1
|
||||
readonly browser: DesktopBrowserBridge
|
||||
readonly updates: {
|
||||
status(): Promise<DesktopUpdatePresentation>
|
||||
open(): Promise<void>
|
||||
|
||||
@@ -39,6 +39,7 @@ import { DesktopMandatoryUpdateWindow } from './mandatory-update-window.ts'
|
||||
import { DesktopPolicyTestAuth } from './policy-test-auth.ts'
|
||||
import { DesktopUpdateDialog, type UpdateDialogOptions } from './update-dialog.ts'
|
||||
import { readDesktopRuntime } from './runtime-tree.ts'
|
||||
import { DesktopBrowserGuests } from './browser-guests.ts'
|
||||
|
||||
let focusPrimaryWindow = (): void => {}
|
||||
let stopForRecovery = async (): Promise<void> => {}
|
||||
@@ -137,6 +138,7 @@ function createWindow(preload: string, show = false, primary = false): BrowserWi
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
webSecurity: true,
|
||||
webviewTag: primary,
|
||||
},
|
||||
})
|
||||
window.webContents.setWindowOpenHandler(({ url }) => {
|
||||
@@ -234,6 +236,7 @@ async function main(): Promise<void> {
|
||||
const applicationUrl = `${SCHEME}://app/`
|
||||
let hostUrl: string | undefined
|
||||
let hostCookie: string | undefined
|
||||
const browserGuests = new DesktopBrowserGuests(() => hostUrl)
|
||||
let injections: readonly unknown[] = []
|
||||
const assertProductSender = (event: IpcMainInvokeEvent): void => {
|
||||
assertDesktopSender(event, ['app'])
|
||||
@@ -443,6 +446,15 @@ async function main(): Promise<void> {
|
||||
reportFatal(new Error(message))
|
||||
})
|
||||
|
||||
ipcMain.handle(DESKTOP_IPC.browserAcquire, (event, workspace: unknown) => {
|
||||
assertProductSender(event)
|
||||
return browserGuests.acquire(event.sender, workspace)
|
||||
})
|
||||
ipcMain.handle(DESKTOP_IPC.browserRelease, (event, lease: unknown) => {
|
||||
assertProductSender(event)
|
||||
return browserGuests.release(event.sender, lease)
|
||||
})
|
||||
|
||||
session.defaultSession.webRequest.onBeforeSendHeaders({ urls: ['ws://127.0.0.1/*'] }, (details, callback) => {
|
||||
if (hostUrl === undefined || hostCookie === undefined || details.webContentsId !== mainWindow?.webContents.id) {
|
||||
callback({})
|
||||
@@ -684,6 +696,7 @@ async function main(): Promise<void> {
|
||||
const createMainWindow = (): BrowserWindow => {
|
||||
const window = createWindow(appPreload, true, true)
|
||||
mainWindow = window
|
||||
browserGuests.bind(window)
|
||||
window.on('focus', automaticCheck)
|
||||
window.on('closed', () => { if (mainWindow === window) mainWindow = undefined })
|
||||
window.webContents.on('did-fail-load', (_event, code, description, url, isMainFrame) => {
|
||||
|
||||
@@ -6,18 +6,22 @@ import { markDocumentPlatform, syncWindowFullscreen } from './preload-platform.t
|
||||
import { syncNativeTheme } from './preload-theme.ts'
|
||||
import { syncWindowsAppearance } from './preload-windows.ts'
|
||||
import { installMandatoryUpdateOverlay } from './preload-mandatory-overlay.ts'
|
||||
import { createDesktopBrowserBridge } from './preload-browser.ts'
|
||||
|
||||
const product: DshDesktopProductApi = {
|
||||
protocolVersion: 1,
|
||||
updates: {
|
||||
status: () => ipcRenderer.invoke(DESKTOP_IPC.updatesStatus) as Promise<DesktopUpdatePresentation>,
|
||||
open: () => ipcRenderer.invoke(DESKTOP_IPC.updatesOpen) as Promise<void>,
|
||||
subscribe(listener) {
|
||||
const handle = (_event: Electron.IpcRendererEvent, state: DesktopUpdatePresentation): void => { listener(state) }
|
||||
ipcRenderer.on(DESKTOP_IPC.updatesPresentation, handle)
|
||||
return () => { ipcRenderer.off(DESKTOP_IPC.updatesPresentation, handle) }
|
||||
function createProductApi(): DshDesktopProductApi {
|
||||
return {
|
||||
protocolVersion: 1,
|
||||
browser: createDesktopBrowserBridge(),
|
||||
updates: {
|
||||
status: () => ipcRenderer.invoke(DESKTOP_IPC.updatesStatus) as Promise<DesktopUpdatePresentation>,
|
||||
open: () => ipcRenderer.invoke(DESKTOP_IPC.updatesOpen) as Promise<void>,
|
||||
subscribe(listener) {
|
||||
const handle = (_event: Electron.IpcRendererEvent, state: DesktopUpdatePresentation): void => { listener(state) }
|
||||
ipcRenderer.on(DESKTOP_IPC.updatesPresentation, handle)
|
||||
return () => { ipcRenderer.off(DESKTOP_IPC.updatesPresentation, handle) }
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (location.protocol === `${SCHEME}:` && location.hostname === 'app') {
|
||||
@@ -36,4 +40,4 @@ markDocumentPlatform()
|
||||
syncWindowFullscreen()
|
||||
syncNativeTheme()
|
||||
// Main-process IPC also verifies the owning window and top frame.
|
||||
contextBridge.exposeInMainWorld('dshDesktop', location.protocol === `${SCHEME}:` && location.hostname === 'app' ? product : { protocolVersion: 1 })
|
||||
contextBridge.exposeInMainWorld('dshDesktop', location.protocol === `${SCHEME}:` && location.hostname === 'app' ? createProductApi() : { protocolVersion: 1 })
|
||||
@@ -0,0 +1,32 @@
|
||||
/** Lease-scoped browser operations and one main-process event subscription per window. */
|
||||
import { ipcRenderer } from 'electron'
|
||||
import type { DesktopBrowserBridge, DesktopBrowserLeaseId } from '@deepseek-ai/dsh-client-ui-sidebar-browser/types'
|
||||
import { DESKTOP_IPC } from './ipc.ts'
|
||||
|
||||
/** @returns browser operations that expose neither IPC nor Electron objects. */
|
||||
export function createDesktopBrowserBridge(): DesktopBrowserBridge {
|
||||
const listeners = new Map<DesktopBrowserLeaseId, Set<(url: string) => void>>()
|
||||
ipcRenderer.on(DESKTOP_IPC.browserOpenRequested, (_event, request: unknown) => {
|
||||
if (typeof request !== 'object' || request === null || !('lease' in request) || !('url' in request)
|
||||
|| typeof request.lease !== 'string' || typeof request.url !== 'string') return
|
||||
const callbacks = listeners.get(request.lease as DesktopBrowserLeaseId)
|
||||
if (callbacks === undefined) return
|
||||
for (const callback of [...callbacks]) {
|
||||
try { callback(request.url) }
|
||||
catch (error) { console.error('Desktop browser link handler failed', error) }
|
||||
}
|
||||
})
|
||||
return {
|
||||
acquire: workspace => ipcRenderer.invoke(DESKTOP_IPC.browserAcquire, workspace) as ReturnType<DesktopBrowserBridge['acquire']>,
|
||||
release: lease => ipcRenderer.invoke(DESKTOP_IPC.browserRelease, lease) as Promise<void>,
|
||||
onOpenRequested(lease, listener) {
|
||||
let callbacks = listeners.get(lease)
|
||||
if (callbacks === undefined) { callbacks = new Set(); listeners.set(lease, callbacks) }
|
||||
callbacks.add(listener)
|
||||
return () => {
|
||||
callbacks.delete(listener)
|
||||
if (callbacks.size === 0 && listeners.get(lease) === callbacks) listeners.delete(lease)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,8 @@ it('limits product documents to update status and a native confirmation action',
|
||||
expect(api.updates).not.toHaveProperty('install')
|
||||
const listener = vi.fn()
|
||||
const dispose = api.updates.subscribe(listener)
|
||||
const handler = electron.ipcRenderer.on.mock.calls[0]?.[1] as (event: unknown, state: unknown) => void
|
||||
const handler = electron.ipcRenderer.on.mock.calls.find(([channel]) => channel === DESKTOP_IPC.updatesPresentation)?.[1] as
|
||||
(event: unknown, state: unknown) => void
|
||||
handler({}, { visible: false })
|
||||
expect(listener).toHaveBeenCalledWith({ visible: false })
|
||||
dispose()
|
||||
@@ -38,6 +39,7 @@ it.each(['dsh-app://shell/plugin-manager.html', 'dsh-app://other/index.html', 'h
|
||||
vi.stubGlobal('location', new URL(url))
|
||||
await import('../src/preload-app.ts')
|
||||
expect(electron.contextBridge.exposeInMainWorld).toHaveBeenCalledWith('dshDesktop', { protocolVersion: 1 })
|
||||
expect(electron.ipcRenderer.on.mock.calls.some(([channel]) => channel === DESKTOP_IPC.browserOpenRequested)).toBe(false)
|
||||
})
|
||||
|
||||
it('exposes asynchronous boot only to the local application document', async () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect, it } from 'vitest'
|
||||
import { desktopUpdateErrorSummary, presentDesktopUpdate } from '../src/update-presentation.ts'
|
||||
import { en, zh } from '../src/locale.ts'
|
||||
import type { DesktopUpdateState, DshDesktopProductApi } from '../src/ipc.ts'
|
||||
import type { DesktopUpdateBridge } from '../../../packages/client/ui-settings-general/src/client/desktop-update-bridge.ts'
|
||||
import type { DesktopUpdateBridge } from '@deepseek-ai/dsh-client-ui-settings-general/types'
|
||||
|
||||
it('keeps the Electron preload assignable to the product consumer', () => {
|
||||
const assign = (bridge: DshDesktopProductApi['updates']): DesktopUpdateBridge => bridge
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../packages/boot/app-boot" },
|
||||
{ "path": "../../packages/client/ui-sidebar-browser/tsconfig.host.json" },
|
||||
{ "path": "../../packages/util/home-paths" }
|
||||
]
|
||||
}
|
||||
@@ -71,15 +71,22 @@ async function columns(page: Page): Promise<number[]> {
|
||||
getComputedStyle(element).gridTemplateColumns.split(' ').map(value => Math.round(Number.parseFloat(value))))
|
||||
}
|
||||
|
||||
/** Select panes by visual column rather than retained-host DOM order. */
|
||||
function paneAt(column: Locator, index: number): Locator {
|
||||
return column.locator(`[data-dockkit-pane][data-dockkit-column="${index}"]`)
|
||||
}
|
||||
|
||||
/** Tab order and selection inside each docked pane, independent of generated ids. */
|
||||
async function paneSnapshot(page: Page) {
|
||||
return await page.locator('[data-rightbar-col] [data-dockkit-pane]').evaluateAll(panes => panes.map(pane => ({
|
||||
active: pane.hasAttribute('data-dockkit-pane-active'),
|
||||
tabs: [...pane.querySelectorAll('[data-dockkit-tab]')].map(tab => ({
|
||||
title: tab.querySelector('[data-dockkit-tab-title]')?.textContent?.trim(),
|
||||
selected: tab.getAttribute('aria-selected') === 'true',
|
||||
})),
|
||||
})))
|
||||
return await page.locator('[data-rightbar-col] [data-dockkit-pane]').evaluateAll(panes => panes
|
||||
.sort((left, right) => Number(left.getAttribute('data-dockkit-column')) - Number(right.getAttribute('data-dockkit-column')))
|
||||
.map(pane => ({
|
||||
active: pane.hasAttribute('data-dockkit-pane-active'),
|
||||
tabs: [...pane.querySelectorAll('[data-dockkit-tab]')].map(tab => ({
|
||||
title: tab.querySelector('[data-dockkit-tab-title]')?.textContent?.trim(),
|
||||
selected: tab.getAttribute('aria-selected') === 'true',
|
||||
})),
|
||||
})))
|
||||
}
|
||||
|
||||
/** Product-visible geometry, pane state, and expanded Files directories at a settled checkpoint. */
|
||||
@@ -311,16 +318,15 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
|
||||
await Promise.allSettled(frame.getAnimations().map(animation => animation.finished))
|
||||
})
|
||||
await expect.poll(() => detailsTrack(page)).toBe(0)
|
||||
await panel.waitFor({ state: 'hidden' })
|
||||
await panel.locator('[data-dockkit-pane]').first().waitFor({ state: 'hidden' })
|
||||
}
|
||||
|
||||
await select(original, 'LIGHTHOUSE')
|
||||
await open()
|
||||
await column.locator('[data-sidebar-right-guide-entry="files"]').click()
|
||||
// The content-box panel adds its one rendered border pixel outside the
|
||||
// CSS width assigned by the grid solver.
|
||||
// Pane borders stay inside the grid width; the shared panel owns no border.
|
||||
await expect.poll(() => sidebarSnapshot(page), { timeout: 5_000 })
|
||||
.toMatchObject({ mode: 'push', panelContentWidth: normalWidth, panelOuterWidth: normalWidth + 1, resizeHandleWidth: 8 })
|
||||
.toMatchObject({ mode: 'push', panelContentWidth: normalWidth, panelOuterWidth: normalWidth, resizeHandleWidth: 8 })
|
||||
await expect.poll(async () => ({
|
||||
filesVisible: await column.locator('[data-files-state="tree"]').isVisible(),
|
||||
errors: tripwire.pageErrors,
|
||||
@@ -330,9 +336,9 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
|
||||
await expect.poll(() => split.isDisabled()).toBe(false)
|
||||
await split.click()
|
||||
await expect.poll(() => panes.count()).toBe(2)
|
||||
await panes.last().locator('[data-sidebar-right-guide-entry="files"]').click()
|
||||
await panes.first().locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).click()
|
||||
await expect.poll(() => panes.first().locator('[data-files-state="tree"]').count()).toBe(1)
|
||||
await paneAt(column, 1).locator('[data-sidebar-right-guide-entry="files"]').click()
|
||||
await paneAt(column, 0).locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).click()
|
||||
await expect.poll(() => paneAt(column, 0).locator('[data-files-state="tree"]').count()).toBe(1)
|
||||
const retainedA = await paneSnapshot(page)
|
||||
expect(retainedA.map(pane => pane.tabs.map(tab => tab.title))).toEqual([['Files', 'Start'], ['Files']])
|
||||
await checkpoint('A normal: two panes')
|
||||
|
||||
@@ -89,8 +89,8 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
|
||||
let outsideRoot: string | undefined
|
||||
let nativeRoot: string | undefined
|
||||
let openLog = ''
|
||||
const opened = async (): Promise<Array<{ path: string; action: 'open' | 'reveal' }>> =>
|
||||
(await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { path: string; action: 'open' | 'reveal' })
|
||||
const opened = async (): Promise<Array<{ path: string; action: 'open' | 'reveal' | 'application' }>> =>
|
||||
(await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { path: string; action: 'open' | 'reveal' | 'application' })
|
||||
|
||||
beforeAll(async () => {
|
||||
outsideRoot = await mkdtemp(join(tmpdir(), 'dsh-preview-outside-'))
|
||||
@@ -102,10 +102,34 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
|
||||
const command = process.platform === 'darwin' ? 'open' : 'xdg-open'
|
||||
await writeFile(join(nativeRoot, command), `#!/usr/bin/env node
|
||||
const fs = require('node:fs');
|
||||
const path = process.argv[2] === '-R' ? process.argv[3] : process.argv[2];
|
||||
const action = process.argv[2] === '-R' || fs.statSync(path).isDirectory() ? 'reveal' : 'open';
|
||||
const path = process.argv[2] === '-a' ? process.argv[4] : process.argv[2] === '-R' ? process.argv[3] : process.argv[2];
|
||||
const action = process.argv[2] === '-a' ? 'application' : process.argv[2] === '-R' || fs.statSync(path).isDirectory() ? 'reveal' : 'open';
|
||||
fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + '\\n');
|
||||
`, { mode: 0o700 })
|
||||
if (process.platform === 'darwin') {
|
||||
const apps = [
|
||||
{ id: '/Applications/Test Player.app', name: 'Test Player', default: true, icon: `data:image/png;base64,${TINY_PNG.toString('base64')}` },
|
||||
{ id: '/Applications/Other Player.app', name: 'Other Player', default: false, icon: null },
|
||||
]
|
||||
await writeFile(join(nativeRoot, 'osascript'), `#!/usr/bin/env node\nprocess.stdout.write(${JSON.stringify(JSON.stringify(apps))});\n`, { mode: 0o700 })
|
||||
}
|
||||
if (process.platform === 'linux') {
|
||||
const data = join(nativeRoot, 'data')
|
||||
await mkdir(join(data, 'applications'), { recursive: true })
|
||||
const icon = join(nativeRoot, 'icon.png')
|
||||
await writeFile(icon, TINY_PNG)
|
||||
await writeFile(join(data, 'applications', 'test.desktop'), `[Desktop Entry]\nName=Test Player\nIcon=${icon}\n`)
|
||||
await writeFile(join(data, 'applications', 'other.desktop'), '[Desktop Entry]\nName=Other Player\n')
|
||||
await writeFile(join(nativeRoot, 'gio'), `#!/usr/bin/env node
|
||||
const fs = require('node:fs');
|
||||
if (process.argv[2] === 'info') process.stdout.write('standard::content-type: video/mp4');
|
||||
else if (process.argv[2] === 'mime') process.stdout.write('Default application for video/mp4: test.desktop\\nRegistered applications:\\n test.desktop\\n other.desktop\\n');
|
||||
else if (process.argv[2] === 'launch') fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path: process.argv[4], action: 'application' }) + '\\n');
|
||||
else process.exit(1);
|
||||
`, { mode: 0o700 })
|
||||
vi.stubEnv('XDG_DATA_HOME', data)
|
||||
vi.stubEnv('XDG_DATA_DIRS', '')
|
||||
}
|
||||
vi.stubEnv('PATH', `${nativeRoot}${delimiter}${process.env.PATH ?? ''}`)
|
||||
}
|
||||
// The Open In rows carry the default-application controls; the SSH marker
|
||||
@@ -747,13 +771,16 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) +
|
||||
await headerOpen.waitFor({ timeout: 15_000 })
|
||||
const emptyOpen = unsupported.locator('[data-textpreview-unsupported] [data-open-path-unpreviewable]')
|
||||
await emptyOpen.waitFor({ timeout: 15_000 })
|
||||
const prominent = unsupported.locator('[data-open-target="file"][data-size="large"]')
|
||||
expect((await prominent.boundingBox())?.height).toBe(40)
|
||||
expect((await unsupported.locator('[data-open-path]').boundingBox())?.height).toBe(24)
|
||||
await successShot(page, 'unsupported')
|
||||
sections.push([
|
||||
'## Unviewable binary', '',
|
||||
'- State: unsupported',
|
||||
`- Line: ${unsupportedLine.trim()}`,
|
||||
`- Header control: ${await headerOpen.innerText()}`,
|
||||
`- Empty-state control: ${await emptyOpen.innerText()}`,
|
||||
`- Header control has no text: ${String(await headerOpen.innerText() === '')}`,
|
||||
`- Empty-state control has text: ${String((await emptyOpen.innerText()).length > 0)}`,
|
||||
].join('\n'))
|
||||
if (STUB_OPENER) {
|
||||
// Real Host gestures against the stubbed opener: default application from the empty state, reveal from the header menu.
|
||||
@@ -761,19 +788,32 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) +
|
||||
await emptyOpen.click()
|
||||
await expect.poll(async () => (await opened()).length, { timeout: 15_000 }).toBe(1)
|
||||
await unsupported.locator('[data-open-path-more]').click()
|
||||
await page.getByRole('menuitem', { name: 'Show file location', exact: true }).click()
|
||||
await page.getByRole('menuitem', { name: /^Show file location/ }).click()
|
||||
await expect.poll(async () => (await opened()).length, { timeout: 15_000 }).toBe(2)
|
||||
const gestures = await opened()
|
||||
expect(gestures[0]).toEqual({ path: clip, action: 'open' })
|
||||
expect(gestures[0]?.action).toBe('open')
|
||||
expect([clip, cwd]).toContain(gestures[0]?.path)
|
||||
expect(gestures[1]?.action).toBe('reveal')
|
||||
expect([clip, cwd]).toContain(gestures[1]?.path)
|
||||
if (STUB_OPENER) {
|
||||
await expect.poll(() => headerOpen.locator('img').count()).toBe(1)
|
||||
await prominent.getByRole('button', { name: 'More ways to open' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Test Player (default)', exact: true }).waitFor()
|
||||
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'applications.expected.md'), await page.getByRole('menu').ariaSnapshot(), MODE)
|
||||
await page.getByRole('menuitem', { name: 'Other Player', exact: true }).click()
|
||||
await expect.poll(async () => (await opened()).length).toBe(3)
|
||||
expect((await opened())[2]).toEqual({ path: clip, action: 'application' })
|
||||
await headerOpen.click()
|
||||
await expect.poll(async () => (await opened()).length).toBe(4)
|
||||
expect((await opened())[3]).toEqual({ path: clip, action: 'open' })
|
||||
}
|
||||
// Gesture facts stay out of the golden: the stub does not run on Windows.
|
||||
expect(await page.getByRole('alert').count()).toBe(0)
|
||||
}
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await compareOrRefreshGolden(EXPECTED, sections.join('\n\n'), MODE)
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['document.expected.md', 'paging.patch.yml'])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['document.expected.md', 'applications.expected.md', 'paging.patch.yml'])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
- text: Running
|
||||
- 'button "Think Next paragraph: inspect the loaded context and pending tools inspect the loaded context and pending tools inspect the loaded context and pending tools inspect the loaded context and pending tools inspect the loaded context and pending tools inspect the loaded context and pending tools inspect the loaded context and pending tools inspect the loaded context and pending tools"'
|
||||
@@ -60,8 +60,7 @@ describe.skipIf(MODE === 'record')('web e2e: Open In under SSH', () => {
|
||||
])
|
||||
expect(response.status()).toBe(200)
|
||||
expect(await response.json()).toEqual({ apps: [] })
|
||||
expect(await page.getByRole('button', { name: /^Open workspace in / }).count()).toBe(0)
|
||||
expect(await page.getByRole('button', { name: 'Choose an app to open in', exact: true }).count()).toBe(0)
|
||||
expect(await page.locator('[data-open-target="directory"]').count()).toBe(0)
|
||||
expect(await page.evaluate(() => localStorage.getItem('dsh.open-in-app.choice'))).toBe('"vscode"')
|
||||
const snapshot = (await captureStableAria(page, 'role=banner', scaffold.workspaceCwd))
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
|
||||
@@ -128,7 +128,7 @@ describe('web e2e: requested SVG is explicitly delivered', () => {
|
||||
|
||||
it.skipIf(MODE === 'record')('replays the delivered file and Chinese conversation', async () => {
|
||||
await assertFinalWorkspaceSnapshot(DIR, cwd)
|
||||
await expect.poll(() => page.getByRole('button', { name: `${FILE} 的更多文件操作`, exact: true }).isDisabled()).toBe(true)
|
||||
expect(await page.locator('[data-presented-file] [data-open-target]').count()).toBe(0)
|
||||
// Delivery owns the transcript; navigation and composer chrome have separate scenarios.
|
||||
const aria = await captureExpandedTurnProcessAria(page, '[data-chat-flow]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(join(DIR, 'ui.expected.md'), aria, MODE)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { chromium, type Browser, type Page } from 'playwright'
|
||||
import { unzipSync, strFromU8 } from 'fflate'
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { tmpdir, release } from 'node:os'
|
||||
import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-tool-present/types'
|
||||
import {
|
||||
@@ -53,10 +54,18 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con
|
||||
vi.stubEnv('PATH', `${nativeRoot}${delimiter}${process.env.PATH ?? ''}`)
|
||||
await mkdir(DIR, { recursive: true })
|
||||
scaffold = await launchWebScaffold({
|
||||
openInAppEnvironment: createLaunchEnvironmentSnapshot([{ source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }]),
|
||||
extraOverlayPath: fileURLToPath(new URL('./present.overlay.yml', import.meta.url)),
|
||||
agentPresets: { roots: [], default: 'ptc' }, compareReplaySession: true,
|
||||
...(MODE === 'record' ? {} : { replayFixture: FIXTURE }),
|
||||
})
|
||||
// File associations belong to the desktop rather than the recorded Session.
|
||||
const controller = scaffold.ctx.get('sessionController')
|
||||
if (controller === undefined) throw new Error('present requires Session Controller')
|
||||
const nativeQuery: unknown = Reflect.get(controller, 'fileApplications')
|
||||
if (typeof nativeQuery !== 'function') throw new Error('present requires native association discovery')
|
||||
Reflect.set(controller, 'fileApplications', async () => [{ id: 'test-editor', name: 'Test Editor', default: true, icon: null }])
|
||||
scaffold.ctx.effect(() => () => { Reflect.set(controller, 'fileApplications', nativeQuery) }, 'present: native association fixture')
|
||||
disposeApproval = scaffold.ctx.on('approval/request', () => Promise.resolve('allowed-once'), { prepend: true })
|
||||
scaffold.ctx.on('session/event', (_session, event) => { events.push(event) })
|
||||
browser = await chromium.launch()
|
||||
@@ -121,8 +130,20 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con
|
||||
}
|
||||
const row = page.locator('[data-presented-files-row]')
|
||||
await row.waitFor()
|
||||
expect(await row.getByRole('button', { name: /More file actions/ }).count()).toBe(2)
|
||||
await expect.poll(() => row.getByRole('button', { name: 'More ways to open' }).count()).toBe(2)
|
||||
expect(await row.getByText('report.txt', { exact: true }).innerText()).toBe('report.txt')
|
||||
const card = row.locator('[data-presented-file]').filter({ hasText: 'report.txt' })
|
||||
await card.getByRole('button', { name: 'Open in Test Editor', exact: true }).hover()
|
||||
await page.getByRole('tooltip', { name: 'Open in Test Editor', exact: true }).waitFor()
|
||||
expect(await page.getByRole('tooltip').evaluate((tooltip) => {
|
||||
const rect = tooltip.getBoundingClientRect()
|
||||
const previous = tooltip.style.pointerEvents
|
||||
tooltip.style.pointerEvents = 'auto'
|
||||
const visible = document.elementFromPoint(rect.left + rect.width / 2, rect.bottom - 1) === tooltip
|
||||
tooltip.style.pointerEvents = previous
|
||||
return tooltip.parentElement === document.body && visible
|
||||
})).toBe(true)
|
||||
await page.mouse.move(0, 0)
|
||||
const beforePreview = (await opened()).length
|
||||
const column = page.locator('[data-rightbar-col]')
|
||||
for (const [name, content] of [['report.txt', 'EDITED_REPORT'], ['说明.txt', 'EDITED_NOTE']] as const) {
|
||||
@@ -139,19 +160,16 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con
|
||||
expect(downloads).toEqual([])
|
||||
await page.getByRole('button', { name: 'Collapse right sidebar', exact: true }).click()
|
||||
const beforeReveal = (await opened()).length
|
||||
await row.getByRole('button', { name: 'More file actions for report.txt', exact: true }).click()
|
||||
await row.locator('[data-presented-file]').filter({ hasText: 'report.txt' }).getByRole('button', { name: 'More ways to open', exact: true }).click()
|
||||
const revealResponse = page.waitForResponse(response => response.url().includes('action=reveal') && response.request().method() === 'POST')
|
||||
await page.getByRole('menuitem', { name: process.platform === 'darwin' ? /Show in Finder/ : /Open containing folder/ }).click()
|
||||
await page.getByRole('menuitem', { name: 'Show file location', exact: true }).click()
|
||||
expect((await revealResponse).status()).toBe(204)
|
||||
expect(await row.getByRole('button', { name: 'Open report.txt in sidebar', exact: true })
|
||||
.evaluate(button => button === document.activeElement)).toBe(true)
|
||||
await expect.poll(opened).toHaveLength(beforeReveal + 1)
|
||||
expect((await opened()).at(-1)).toEqual({ action: 'reveal', content: null, path: await realpath(process.platform === 'darwin' ? join(cwd, 'report.txt') : cwd) })
|
||||
for (const [name, bytes] of [['report.txt', 'EDITED_REPORT\n'], ['说明.txt', 'EDITED_NOTE\n']] as const) {
|
||||
const count = (await opened()).length
|
||||
const response = page.waitForResponse(response => response.url().includes('/api/present.open?') && response.request().method() === 'POST')
|
||||
await row.getByRole('button', { name: `More file actions for ${name}`, exact: true }).click()
|
||||
await page.getByRole('menuitem', { name: 'Open in default app', exact: true }).click()
|
||||
await row.locator('[data-presented-file]').filter({ hasText: name }).getByRole('button', { name: 'Open in Test Editor', exact: true }).click()
|
||||
expect((await response).status()).toBe(204)
|
||||
await page.waitForFunction(() => document.querySelector('[data-presented-files-row] button:disabled') === null)
|
||||
expect(await opened()).toHaveLength(count + 1)
|
||||
@@ -174,6 +192,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con
|
||||
])
|
||||
expect(exported).not.toContain('EDITED_REPORT')
|
||||
if (MODE !== 'record') {
|
||||
await expect.poll(() => page.locator('[data-presented-file] [role="status"]').count(), { timeout: 10_000 }).toBe(0)
|
||||
const aria = await captureExpandedTurnProcessAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(join(DIR, 'ui.expected.md'), aria, MODE)
|
||||
await page.locator('[data-turn-process]').click()
|
||||
@@ -215,9 +234,9 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con
|
||||
.find(element => element.textContent === 'report.txt'),
|
||||
'report title',
|
||||
)
|
||||
const description = requiredElement(report.querySelector<HTMLElement>('span[role="status"]'), 'report status')
|
||||
const description = requiredElement(report.querySelector<HTMLElement>('[data-presented-description]'), 'report description')
|
||||
const open = requiredElement(
|
||||
report.querySelector<HTMLButtonElement>('button[aria-label="Open report.txt in sidebar"]'),
|
||||
report.querySelector<HTMLButtonElement>('[data-open-target] button'),
|
||||
'report open action',
|
||||
)
|
||||
const icon = requiredElement(report.querySelector<SVGElement>('svg'), 'report icon')
|
||||
@@ -250,7 +269,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con
|
||||
expect(geometry.iconWidth).toBe('20')
|
||||
expect(geometry.titleFontSize).toBe('13px')
|
||||
expect(geometry.descriptionFontSize).toBe('10px')
|
||||
expect(geometry.openFontSize).toBe('12px')
|
||||
expect(geometry.openFontSize).toBe('11px')
|
||||
await page.setViewportSize({ width: 480, height: 900 })
|
||||
const row = page.locator('[data-presented-files-row]')
|
||||
await row.scrollIntoViewIfNeeded()
|
||||
@@ -263,9 +282,9 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con
|
||||
}
|
||||
const beforeDelete = (await opened()).length
|
||||
await unlink(join(cwd, 'report.txt'))
|
||||
const missing = page.waitForResponse(response => response.url().includes('/api/present.open?'))
|
||||
await page.locator('[data-presented-files-row]').getByRole('button', { name: 'More file actions for report.txt', exact: true }).click()
|
||||
await page.getByRole('menuitem', { name: 'Open in default app', exact: true }).click()
|
||||
const missing = page.waitForResponse(response => response.url().includes('/api/present.open?') && response.request().method() === 'POST')
|
||||
await page.locator('[data-presented-file]').filter({ hasText: 'report.txt' })
|
||||
.getByRole('button', { name: 'Open in Test Editor', exact: true }).click()
|
||||
expect((await missing).status()).toBe(404)
|
||||
await page.getByText('Could not open. Click to retry.', { exact: true }).waitFor()
|
||||
expect(await opened()).toHaveLength(beforeDelete)
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/** Streaming reasoning previews through the assembled Chat and a paused model adapter. */
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium } from 'playwright'
|
||||
import { expect, it } from 'vitest'
|
||||
import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-agent-default-model'
|
||||
import {
|
||||
captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, writeComposerDraft } from './support.ts'
|
||||
|
||||
const SUMMARY = `Next paragraph: ${'inspect the loaded context and pending tools '.repeat(8).trim()}`
|
||||
const DELTAS = ['First paragraph', `\nDetails\n\n\n${SUMMARY}`, '\nMore detail']
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./expected/reasoning-preview/running.expected.md', import.meta.url))
|
||||
|
||||
class PausedReasoningAdapter extends LlmAdapter {
|
||||
readonly stages = DELTAS.map(text => ({
|
||||
text,
|
||||
arrived: Promise.withResolvers<undefined>(),
|
||||
proceed: Promise.withResolvers<undefined>(),
|
||||
}))
|
||||
|
||||
release(): void {
|
||||
for (const stage of this.stages) stage.proceed.resolve(undefined)
|
||||
}
|
||||
|
||||
override async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
yield { type: 'block-start', index: 0, blockType: 'reasoning' }
|
||||
for (const stage of this.stages) {
|
||||
yield { type: 'reasoning-delta', index: 0, text: stage.text }
|
||||
stage.arrived.resolve(undefined)
|
||||
await stage.proceed.promise
|
||||
options.signal?.throwIfAborted()
|
||||
}
|
||||
yield { type: 'block-end', index: 0, block: { type: 'reasoning', text: DELTAS.join('') } }
|
||||
yield { type: 'block-start', index: 1, blockType: 'text' }
|
||||
yield { type: 'text-delta', index: 1, text: 'Done' }
|
||||
yield { type: 'block-end', index: 1, block: { type: 'text', text: 'Done' } }
|
||||
yield { type: 'finish', reason: { kind: 'stop' } }
|
||||
}
|
||||
}
|
||||
|
||||
it('shows completed paragraph first lines across blank lines with a right-edge fade', async () => {
|
||||
const scaffold = await launchWebScaffold()
|
||||
const adapter = new PausedReasoningAdapter()
|
||||
try {
|
||||
scaffold.ctx.effect(() => scaffold.ctx.llm.registerAdapter(['reasoning-preview-test'], adapter))
|
||||
await scaffold.ctx.agentDefaultModel.saveSelection({ provider: 'reasoning-preview-test', model: 'paused' })
|
||||
const browser = await chromium.launch()
|
||||
try {
|
||||
const page = await newEnglishPage(browser)
|
||||
const console = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
await page.setViewportSize({ width: 480, height: 1000 })
|
||||
const input = page.locator('[data-composer-input]').first()
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
await writeComposerDraft(page, input, 'Show a streamed reasoning preview.')
|
||||
await input.press('Enter')
|
||||
|
||||
const [first, second, third] = adapter.stages
|
||||
if (first === undefined || second === undefined || third === undefined) throw new Error('preview stages are incomplete')
|
||||
await first.arrived.promise
|
||||
const reasoning = page.locator('[data-variant="think"][data-state="running"]')
|
||||
await reasoning.waitFor()
|
||||
expect(await reasoning.getAttribute('data-preview')).toBeNull()
|
||||
|
||||
first.proceed.resolve(undefined)
|
||||
await second.arrived.promise
|
||||
const preview = reasoning.locator('[data-streaming]')
|
||||
await expect.poll(() => preview.textContent()).toBe('First paragraph')
|
||||
expect(await preview.isVisible()).toBe(true)
|
||||
await preview.evaluate((element) => { element.setAttribute('data-retained-preview', 'true') })
|
||||
|
||||
second.proceed.resolve(undefined)
|
||||
await third.arrived.promise
|
||||
await expect.poll(() => preview.textContent()).toBe(SUMMARY)
|
||||
expect(await preview.getAttribute('data-retained-preview')).toBe('true')
|
||||
expect(await preview.evaluate(element => getComputedStyle(element).maskImage)).toContain('linear-gradient')
|
||||
expect(await preview.evaluate(element => element.scrollWidth > element.clientWidth)).toBe(true)
|
||||
expect(await reasoning.getByRole('button').getAttribute('aria-expanded')).toBe('false')
|
||||
await compareOrRefreshGolden(UI_EXPECTED,
|
||||
await captureStableAria(page, '[data-variant="think"]', scaffold.workspaceCwd), webSnapshotMode())
|
||||
|
||||
third.proceed.resolve(undefined)
|
||||
await settled
|
||||
await page.getByText('Done', { exact: true }).waitFor()
|
||||
expect(console.pageErrors).toEqual([])
|
||||
expect(console.warnings).toEqual([])
|
||||
} finally {
|
||||
adapter.release()
|
||||
await browser.close()
|
||||
}
|
||||
} finally {
|
||||
adapter.release()
|
||||
await scaffold.close()
|
||||
}
|
||||
})
|
||||
@@ -228,6 +228,13 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
],
|
||||
openInAppEnvironment: createLaunchEnvironmentSnapshot([{ source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }]),
|
||||
})
|
||||
// Application registrations belong to the host desktop, not to the recorded Session.
|
||||
const controller = scaffold.ctx.get('sessionController')
|
||||
if (controller === undefined) throw new Error('seeded-history requires Session Controller')
|
||||
const nativeQuery: unknown = Reflect.get(controller, 'fileApplications')
|
||||
if (typeof nativeQuery !== 'function') throw new Error('seeded-history requires the native file-association adapter')
|
||||
Reflect.set(controller, 'fileApplications', async () => [])
|
||||
scaffold.ctx.effect(() => () => { Reflect.set(controller, 'fileApplications', nativeQuery) }, 'seeded-history: native association fixture')
|
||||
// Composer recording uses a child workspace; seedSession owns the scaffold root.
|
||||
const sessionCwd = MODE === 'record' ? join(scaffold.workspaceCwd, 'workspace') : scaffold.workspaceCwd
|
||||
await mkdir(sessionCwd, { recursive: true })
|
||||
@@ -518,7 +525,8 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
await expect.poll(() => path.textContent()).toBe(absolutePath)
|
||||
expect(await path.getAttribute('title')).toBe(absolutePath)
|
||||
await expect.poll(() => column.locator('[data-textpreview-line="1"]').textContent()).toBe('alpha\n')
|
||||
await column.locator('[data-open-path-open]').waitFor({ timeout: 5_000 })
|
||||
await column.getByRole('button', { name: 'Show file location', exact: true }).waitFor({ timeout: 5_000 })
|
||||
await expect.poll(() => column.locator('[data-open-path-open]').isEnabled()).toBe(true)
|
||||
const preview = await captureStableAria(page, '[data-textpreview-state="text"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(FILE_PREVIEW_EXPECTED, preview, MODE)
|
||||
} finally {
|
||||
|
||||
@@ -85,17 +85,24 @@ async function pointIn(locator: Locator, fx: number, fy: number): Promise<{ x: n
|
||||
return { x: box.x + box.width * fx, y: box.y + box.height * fy }
|
||||
}
|
||||
|
||||
/** Pause the panel's next real transform transition after observing its initial frame geometry. */
|
||||
/** Select panes by CSS column; retained tab hosts keep their independent DOM order. */
|
||||
function paneAt(column: Locator, index: number): Locator {
|
||||
return column.locator(`[data-dockkit-pane][data-dockkit-column="${index}"]`)
|
||||
}
|
||||
|
||||
/** Pause a docked host's next real transform transition at its midpoint. */
|
||||
async function holdPanelSlide(panel: Locator) {
|
||||
return await panel.evaluateHandle((node) => {
|
||||
const controller = new AbortController()
|
||||
const state = { animation: null as Animation | null, columnsAtStart: '', dispose: () => { controller.abort() } }
|
||||
node.addEventListener('transitionrun', (event) => {
|
||||
if (event.target !== node || (event as TransitionEvent).propertyName !== 'transform') return
|
||||
const target = event.target
|
||||
if (!(target instanceof Element) || !target.matches('[data-dockkit-host="dock"]')
|
||||
|| (event as TransitionEvent).propertyName !== 'transform') return
|
||||
const frame = node.closest('[style*="grid-template-columns"]')
|
||||
if (frame === null) throw new Error('panel frame is unavailable')
|
||||
state.columnsAtStart = getComputedStyle(frame).gridTemplateColumns
|
||||
const slide = node.getAnimations().find(animation =>
|
||||
const slide = target.getAnimations().find(animation =>
|
||||
'transitionProperty' in animation && animation.transitionProperty === 'transform')
|
||||
if (slide === undefined || slide.effect === null) throw new Error('panel transform transition is unavailable')
|
||||
slide.pause()
|
||||
@@ -494,7 +501,9 @@ describe('web e2e: shipped right Sidebar', () => {
|
||||
await expect.poll(() => panel.boundingBox()).toEqual({ x: 0, y: 0, ...viewport })
|
||||
await column.locator('[data-sidebar-right-toggle]').click()
|
||||
await Promise.all([
|
||||
panel.evaluate(async (node) => { await Promise.allSettled(node.getAnimations().map(animation => animation.finished)) }),
|
||||
panel.evaluate(async (node) => {
|
||||
await Promise.allSettled(node.getAnimations({ subtree: true }).map(animation => animation.finished))
|
||||
}),
|
||||
frame.evaluate(async (node) => { await Promise.allSettled(node.getAnimations().map(animation => animation.finished)) }),
|
||||
])
|
||||
const closedColumns = (await geometry()).columns
|
||||
@@ -507,7 +516,7 @@ describe('web e2e: shipped right Sidebar', () => {
|
||||
try {
|
||||
await expandOf(page).click()
|
||||
await expect.poll(() => held.evaluate(state => state.animation?.playState)).toBe('paused')
|
||||
const entering = await panel.boundingBox()
|
||||
const entering = await paneAt(column, 0).boundingBox()
|
||||
if (entering === null) throw new Error('entering panel is not rendered')
|
||||
expect(entering.x).toBeGreaterThan(0)
|
||||
expect(entering.x).toBeLessThan(viewport.width)
|
||||
@@ -525,7 +534,7 @@ describe('web e2e: shipped right Sidebar', () => {
|
||||
await column.locator('[data-sidebar-right-toggle]').click()
|
||||
await expect.poll(() => exit.evaluate(state => state.animation?.playState)).toBe('paused')
|
||||
expect(await exit.evaluate(state => state.columnsAtStart)).toBe(closedColumns)
|
||||
const leaving = await panel.boundingBox()
|
||||
const leaving = await paneAt(column, 0).boundingBox()
|
||||
if (leaving === null) throw new Error('leaving panel is not rendered')
|
||||
expect(leaving.x).toBeGreaterThan(0)
|
||||
expect(leaving.x).toBeLessThan(viewport.width)
|
||||
@@ -719,7 +728,7 @@ describe('web e2e: shipped right Sidebar', () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-content'))
|
||||
const column = page.locator('[data-rightbar-col]')
|
||||
const panes = column.locator('[data-dockkit-pane]')
|
||||
const floats = page.locator('[data-sidebar-right-float-host] [data-dockkit-float]')
|
||||
const floats = page.locator('[data-sidebar-right-session]:not([hidden]) [data-dockkit-float]')
|
||||
|
||||
// Observation before action: does the read ever leave the browser? The
|
||||
// assertion states the healthy answer so a failure prints the real one.
|
||||
@@ -751,7 +760,7 @@ describe('web e2e: shipped right Sidebar', () => {
|
||||
await expect.poll(async () => await tabTitles(column)).toEqual(['Files', SAMPLE_NAME])
|
||||
|
||||
// Opening the same content again focuses rather than duplicating.
|
||||
await panes.first().locator('[data-dockkit-tab]').first().click()
|
||||
await paneAt(column, 0).locator('[data-dockkit-tab]').first().click()
|
||||
await chip.click()
|
||||
await expect.poll(async () => await tabTitles(column)).toEqual(['Files', SAMPLE_NAME])
|
||||
|
||||
@@ -776,20 +785,20 @@ describe('web e2e: shipped right Sidebar', () => {
|
||||
expect(await folders.count()).toBe(await page.locator('[data-changed-files]').getByRole('button', { name: /folder/i }).count())
|
||||
|
||||
// Split, then dock-drag: the kit's gestures drive the store's actions.
|
||||
await panes.first().locator('[data-dockkit-split-button]').click()
|
||||
await paneAt(column, 0).locator('[data-dockkit-split-button]').click()
|
||||
await expect.poll(async () => await panes.count()).toBe(2)
|
||||
await dragTo(
|
||||
page,
|
||||
column.locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }).first(),
|
||||
await pointIn(panes.nth(1), 0.5, 0.94),
|
||||
await pointIn(paneAt(column, 1), 0.5, 0.94),
|
||||
)
|
||||
await expect.poll(async () => await panes.count()).toBe(2)
|
||||
|
||||
const splitGuide = panes.nth(1).locator('[data-dockkit-tab]').filter({ hasText: 'Start' })
|
||||
const splitGuide = paneAt(column, 1).locator('[data-dockkit-tab]').filter({ hasText: 'Start' })
|
||||
expect(await splitGuide.locator('[data-dockkit-tab-close]').count()).toBe(1)
|
||||
await dragTo(page, splitGuide, await pointIn(panes.first(), 0.5, 0.5))
|
||||
await expect.poll(async () => await tabTitles(panes.nth(1))).toEqual([SAMPLE_NAME])
|
||||
const movedGuide = panes.first().locator('[data-dockkit-tab]').filter({ hasText: 'Start' })
|
||||
await dragTo(page, splitGuide, await pointIn(paneAt(column, 0), 0.5, 0.5))
|
||||
await expect.poll(async () => await tabTitles(paneAt(column, 1))).toEqual([SAMPLE_NAME])
|
||||
const movedGuide = paneAt(column, 0).locator('[data-dockkit-tab]').filter({ hasText: 'Start' })
|
||||
await movedGuide.hover()
|
||||
await movedGuide.locator('[data-dockkit-tab-close]').click()
|
||||
|
||||
@@ -798,12 +807,15 @@ describe('web e2e: shipped right Sidebar', () => {
|
||||
await expect.poll(async () => await filePane.locator('[data-dockkit-add-tab]').count()).toBe(1)
|
||||
expect(await column.locator('[data-dockkit-add-tab]').count()).toBe(2)
|
||||
|
||||
// Floating leaves the column entirely, and survives collapsing it. The
|
||||
// pane the tab was alone in goes with it: an emptied pane never stays.
|
||||
// A float stays in the DOM column but draws outside it and survives collapse.
|
||||
const tab = column.locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }).first()
|
||||
await floatByDrag(page, tab)
|
||||
await expect.poll(async () => await floats.count()).toBe(1)
|
||||
expect(await column.locator('[data-dockkit-float]').count()).toBe(0)
|
||||
expect(await column.locator('[data-dockkit-float]').count()).toBe(1)
|
||||
const floatBox = await floats.first().boundingBox()
|
||||
const columnBox = await column.boundingBox()
|
||||
if (floatBox === null || columnBox === null) throw new Error('float or column is not rendered')
|
||||
expect(floatBox.x).toBeLessThan(columnBox.x)
|
||||
await expect.poll(async () => await panes.count()).toBe(1)
|
||||
await shot(page, '04-split-and-float')
|
||||
|
||||
@@ -891,7 +903,7 @@ describe('web e2e: shipped right Sidebar', () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-gestures'))
|
||||
const column = await resetSidebar(page)
|
||||
const panes = column.locator('[data-dockkit-pane]')
|
||||
const floats = page.locator('[data-sidebar-right-float-host] [data-dockkit-float]')
|
||||
const floats = page.locator('[data-sidebar-right-session]:not([hidden]) [data-dockkit-float]')
|
||||
|
||||
// Chromium cancels pointer capture if a render replaces the pressed
|
||||
// element; jsdom cannot establish that the whole gesture survives.
|
||||
@@ -899,7 +911,7 @@ describe('web e2e: shipped right Sidebar', () => {
|
||||
// 1. Reorder inside one strip: drop the last tab left of its neighbours.
|
||||
// The first pane needs two tabs for this — and for the move below to
|
||||
// leave it standing, since a pane emptied by a move is dropped.
|
||||
const first = panes.first()
|
||||
const first = paneAt(column, 0)
|
||||
const strip = first.locator('[data-dockkit-strip]')
|
||||
await proseChip(page).click()
|
||||
await expect.poll(async () => await tabTitles(first)).toEqual(['Files', SAMPLE_NAME])
|
||||
@@ -918,8 +930,8 @@ describe('web e2e: shipped right Sidebar', () => {
|
||||
}
|
||||
const moving = first.locator('[data-dockkit-tab]').first()
|
||||
const title = await moving.locator('[data-dockkit-tab-title]').innerText()
|
||||
await dragTo(page, moving, await pointIn(panes.nth(1), 0.5, 0.5))
|
||||
await expect.poll(async () => await tabTitles(panes.nth(1))).toContain(title)
|
||||
await dragTo(page, moving, await pointIn(paneAt(column, 1), 0.5, 0.5))
|
||||
await expect.poll(async () => await tabTitles(paneAt(column, 1))).toContain(title)
|
||||
|
||||
const splitButtons = column.locator('[data-dockkit-split-button]')
|
||||
await expect.poll(async () => await splitButtons.count()).toBe(0)
|
||||
@@ -928,14 +940,14 @@ describe('web e2e: shipped right Sidebar', () => {
|
||||
await expect.poll(async () => await splitButtons.count()).toBe(0)
|
||||
|
||||
const outer = column.locator('[data-dockkit-divider]').first()
|
||||
const before = await width(panes.last())
|
||||
const before = await width(paneAt(column, 1))
|
||||
const grip = await centre(outer)
|
||||
await dragElement(page, outer, { x: grip.x - 100, y: grip.y })
|
||||
await expect.poll(async () => await width(panes.last())).toBeGreaterThan(before)
|
||||
await expect.poll(async () => await width(paneAt(column, 1))).toBeGreaterThan(before)
|
||||
await dragElement(page, outer, { x: 0, y: grip.y })
|
||||
const ratio = async (): Promise<number> => {
|
||||
const left = await width(panes.first())
|
||||
const right = await width(panes.last())
|
||||
const left = await width(paneAt(column, 0))
|
||||
const right = await width(paneAt(column, 1))
|
||||
return left / (left + right)
|
||||
}
|
||||
await expect.poll(ratio).toBeCloseTo(0.2, 2)
|
||||
@@ -947,7 +959,7 @@ describe('web e2e: shipped right Sidebar', () => {
|
||||
expect(await splitButtons.count()).toBe(0)
|
||||
|
||||
// 5. The split's guide and the document float while Files stays docked.
|
||||
const floatOne = panes.last().locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME })
|
||||
const floatOne = paneAt(column, 1).locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME })
|
||||
await floatByDrag(page, floatOne)
|
||||
await expect.poll(async () => await floats.count()).toBe(1)
|
||||
const box = await floats.first().boundingBox()
|
||||
@@ -955,8 +967,8 @@ describe('web e2e: shipped right Sidebar', () => {
|
||||
await dragElement(page, floats.first().locator('[data-dockkit-float-grip]'), { x: box.x + 140, y: box.y + 90 })
|
||||
await expect.poll(async () => (await floats.first().boundingBox())?.x ?? box.x).not.toBe(box.x)
|
||||
|
||||
await expect.poll(async () => await tabTitles(panes.last())).toEqual(['Start'])
|
||||
const second = panes.last().locator('[data-dockkit-tab]').filter({ hasText: 'Start' })
|
||||
await expect.poll(async () => await tabTitles(paneAt(column, 1))).toEqual(['Start'])
|
||||
const second = paneAt(column, 1).locator('[data-dockkit-tab]').filter({ hasText: 'Start' })
|
||||
await floatByDrag(page, second)
|
||||
await expect.poll(async () => await floats.count()).toBe(2)
|
||||
|
||||
@@ -976,14 +988,14 @@ describe('web e2e: shipped right Sidebar', () => {
|
||||
const panes = column.locator('[data-dockkit-pane]')
|
||||
expect(await column.locator('[data-dockkit-tab-close]').count()).toBe(1)
|
||||
await proseChip(page).click()
|
||||
await expect.poll(async () => await tabTitles(panes.first())).toEqual(['Files', SAMPLE_NAME])
|
||||
await panes.first().locator('[data-dockkit-split-button]').click()
|
||||
await expect.poll(async () => await tabTitles(paneAt(column, 0))).toEqual(['Files', SAMPLE_NAME])
|
||||
await paneAt(column, 0).locator('[data-dockkit-split-button]').click()
|
||||
await expect.poll(async () => await panes.count()).toBe(2)
|
||||
|
||||
// Closing a pane's last tab drops the pane: there is no separate
|
||||
// "close pane" gesture, and none is needed.
|
||||
await panes.nth(1).locator('[data-dockkit-tab]').first().hover()
|
||||
await panes.nth(1).locator('[data-dockkit-tab-close]').first().click()
|
||||
await paneAt(column, 1).locator('[data-dockkit-tab]').first().hover()
|
||||
await paneAt(column, 1).locator('[data-dockkit-tab-close]').first().click()
|
||||
await expect.poll(async () => await panes.count()).toBe(1)
|
||||
await expect.poll(async () => await tabTitles(column)).toEqual(['Files', SAMPLE_NAME])
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"tests/chat-scroll-fixture.ts",
|
||||
"tests/trajectory-virtualization.e2e.ts",
|
||||
"tests/lifecycle-chrome.e2e.ts",
|
||||
"tests/reasoning-preview.e2e.ts",
|
||||
"tests/details-session-lifecycle.e2e.ts",
|
||||
"tests/sessionless-header.e2e.ts",
|
||||
"tests/document-preview.e2e.ts",
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/config-catalog.md
|
||||
config-catalog.md: 6bd14a5aa194de15463c0d63639c0f5095995f99
|
||||
config-catalog.zh.md: ed0a6cbcc7b28e3875b50ba90e427ca06e90a8dc
|
||||
config-catalog.md: 4b95e9ec6e1e92e5d19be6e4418e121e47b329c4
|
||||
config-catalog.zh.md: 0307bea87f9c00ac31538a39a185451ddfdd595e
|
||||
@@ -215,7 +215,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/api/session-controller/src/index.ts:77`](../packages/api/session-controller/src/index.ts)
|
||||
Source: [`packages/api/session-controller/src/index.ts:78`](../packages/api/session-controller/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-api-settings-controller"></a>
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
来源:[`packages/api/session-controller/src/index.ts:77`](../packages/api/session-controller/src/index.ts)
|
||||
来源:[`packages/api/session-controller/src/index.ts:78`](../packages/api/session-controller/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-api-settings-controller"></a>
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
|
||||
event-producer-consumer.md: 619103ab6f813fc9ea915cc4f6bf5b6ae585f8cb
|
||||
event-producer-consumer.zh.md: 00a13ca2aa785dd253263f154627e6dd8accacc6
|
||||
event-producer-consumer.md: df615a230d8a1858de9dc8fefd5d2447e56f7a88
|
||||
event-producer-consumer.zh.md: a57e1b7e9447939e6a6586c4743dfd39345cc15c
|
||||
@@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
|
||||
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
|
||||
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:614`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:594`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:621`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:600`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:607`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:617`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:624`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:603`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:610`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
|
||||
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
|
||||
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
|
||||
|
||||
@@ -23,11 +23,11 @@
|
||||
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
|
||||
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
|
||||
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:614`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:594`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:621`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:600`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:607`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:617`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:624`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:603`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:610`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
|
||||
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
|
||||
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/persistence-catalog.md
|
||||
persistence-catalog.md: 11cea4328db6e69c1ee08994a6d36fe26e308a7c
|
||||
persistence-catalog.zh.md: 6207dc17023e566f73cb1e0c7cba40533a4a96ff
|
||||
persistence-catalog.md: 32f484b262e1ee2e77fe167245968b5b04dbd393
|
||||
persistence-catalog.zh.md: 4a0d2459667ffe194cf8c802eb8c60a11f1652fa
|
||||
@@ -662,7 +662,7 @@ Source: [`packages/llm/llm-retry/src/types.ts:11`](../packages/llm/llm-retry/src
|
||||
'model/selection': ModelSelection
|
||||
```
|
||||
|
||||
Source: [`packages/api/session-controller/src/types.ts:40`](../packages/api/session-controller/src/types.ts)
|
||||
Source: [`packages/api/session-controller/src/types.ts:41`](../packages/api/session-controller/src/types.ts)
|
||||
|
||||
### `permission/*`
|
||||
|
||||
@@ -4074,7 +4074,7 @@ Sources: [`packages/llm/llm/src/message.ts:24`](../packages/llm/llm/src/message.
|
||||
|
||||
SHA-256: `f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b`
|
||||
|
||||
Sources: [`packages/api/session-controller/src/types.ts:100`](../packages/api/session-controller/src/types.ts)
|
||||
Sources: [`packages/api/session-controller/src/types.ts:101`](../packages/api/session-controller/src/types.ts)
|
||||
|
||||
| Property | Presence | Type |
|
||||
|---|---|---|
|
||||
@@ -5315,7 +5315,7 @@ Array of `number`.
|
||||
|
||||
SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b`
|
||||
|
||||
Sources: [`packages/api/session-controller/src/types.ts:394`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
|
||||
Sources: [`packages/api/session-controller/src/types.ts:397`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
|
||||
|
||||
`string`
|
||||
|
||||
@@ -7248,7 +7248,7 @@ Sources: [`packages/core/tools/src/index.ts:34`](../packages/core/tools/src/inde
|
||||
|
||||
SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7`
|
||||
|
||||
Sources: [`packages/api/session-controller/src/types.ts:399`](../packages/api/session-controller/src/types.ts)
|
||||
Sources: [`packages/api/session-controller/src/types.ts:402`](../packages/api/session-controller/src/types.ts)
|
||||
|
||||
| Property | Presence | Type |
|
||||
|---|---|---|
|
||||
|
||||
@@ -664,7 +664,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
|
||||
'model/selection': ModelSelection
|
||||
```
|
||||
|
||||
来源:[`packages/api/session-controller/src/types.ts:40`](../packages/api/session-controller/src/types.ts)
|
||||
来源:[`packages/api/session-controller/src/types.ts:41`](../packages/api/session-controller/src/types.ts)
|
||||
|
||||
### `permission/*`
|
||||
|
||||
@@ -4076,7 +4076,7 @@ SHA-256: `719c51f67074f97034a6c94297fe1f4b5d3c8d392c57586dab732aa5398c7a91`
|
||||
|
||||
SHA-256: `f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b`
|
||||
|
||||
来源:[`packages/api/session-controller/src/types.ts:100`](../packages/api/session-controller/src/types.ts)
|
||||
来源:[`packages/api/session-controller/src/types.ts:101`](../packages/api/session-controller/src/types.ts)
|
||||
|
||||
| 属性 | 存在性 | 类型 |
|
||||
|---|---|---|
|
||||
@@ -5317,7 +5317,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78`
|
||||
|
||||
SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b`
|
||||
|
||||
来源:[`packages/api/session-controller/src/types.ts:394`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
|
||||
来源:[`packages/api/session-controller/src/types.ts:397`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
|
||||
|
||||
`string`
|
||||
|
||||
@@ -7250,7 +7250,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543`
|
||||
|
||||
SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7`
|
||||
|
||||
来源:[`packages/api/session-controller/src/types.ts:399`](../packages/api/session-controller/src/types.ts)
|
||||
来源:[`packages/api/session-controller/src/types.ts:402`](../packages/api/session-controller/src/types.ts)
|
||||
|
||||
| 属性 | 存在性 | 类型 |
|
||||
|---|---|---|
|
||||
|
||||
Generated
+3
-3
@@ -44608,7 +44608,7 @@
|
||||
"packages/workflow/workflow/src/types.ts#WorkflowRunId"
|
||||
],
|
||||
"sources": [
|
||||
"packages/api/session-controller/src/types.ts:394",
|
||||
"packages/api/session-controller/src/types.ts:397",
|
||||
"packages/attachment/attachment/src/brand.ts:6",
|
||||
"packages/compaction/compaction/src/brand.ts:4",
|
||||
"packages/core/session/src/types.ts:20",
|
||||
@@ -58191,7 +58191,7 @@
|
||||
},
|
||||
"names": [],
|
||||
"sources": [
|
||||
"packages/api/session-controller/src/types.ts:399"
|
||||
"packages/api/session-controller/src/types.ts:402"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -86750,7 +86750,7 @@
|
||||
"packages/api/session-controller/src/types.ts#ModelSelection"
|
||||
],
|
||||
"sources": [
|
||||
"packages/api/session-controller/src/types.ts:100"
|
||||
"packages/api/session-controller/src/types.ts:101"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/session.md
|
||||
session.md: 62f7785d4d1db9c30706a8b967e6f4073b810149
|
||||
session.zh.md: 7cd3c38162df209d245d3f1085ab1edce002531a
|
||||
session.md: be48d4833bacbac31f3765e0b4d9cdcf6da9f4d7
|
||||
session.zh.md: 093fc6d570e93dc5be42d8779fc4d5965085b7e0
|
||||
@@ -758,6 +758,8 @@ The backends that consume this contract are on [persistence.md](persistence.md).
|
||||
|
||||
`SessionOpenWorkspacePathRequest` carries an absolute or workspace-resolved `path`; optional `action: "reveal"` selects file-manager navigation instead of default-application opening. `SessionOpenWorkspacePathValue` confirms that the Host accepted the native handoff. A Session-aware Client resolves relative paths against its current Session cwd when known; the controller hands the path to the opener unchanged and reports invalid requests, cancellation, and opener failures through the Session Remote error vocabulary.
|
||||
|
||||
The optional `application` selects a registered file handler without changing the system default; `workspacePathApplications({ path })` returns current handlers, names, icons, and default selection after Host path verification.
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
<a id="cordis-surface"></a>
|
||||
@@ -845,6 +847,15 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' |
|
||||
*/
|
||||
@Remote('openWorkspacePath') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise<SessionOpenWorkspacePathValue>
|
||||
|
||||
/**
|
||||
* Query current file handlers on the serving desktop without activating an Agent.
|
||||
* @param request - file path in Host filesystem syntax.
|
||||
* @param signal - caller lifetime, propagated to filesystem and desktop queries.
|
||||
* @returns OS application names, icons, and default selection; empty when desktop opening is unavailable.
|
||||
* @throws RemoteError when the path is invalid, the query is cancelled, or native discovery fails.
|
||||
*/
|
||||
@Remote('workspacePathApplications') async workspacePathApplications( request: { readonly path: string }, signal: AbortSignal, ): Promise<readonly SessionWorkspacePathApplication[]>
|
||||
|
||||
/**
|
||||
* Rename one Session after explicitly resuming it.
|
||||
* @param request - Session identity and proposed title.
|
||||
|
||||
@@ -762,6 +762,8 @@ interface TurnEndReasonMap {
|
||||
|
||||
`SessionOpenWorkspacePathRequest` 携带绝对路径或已按 workspace 解析的 `path`。`SessionOpenWorkspacePathValue` 确认 Host 已接受原生交接。Session-aware Client 会在已知当前 Session cwd 时据此解析相对路径;controller 将路径原样交给打开器,并通过 Session Remote 错误词汇表报告无效请求、取消与打开器失败。 可选的 `action: "reveal"` 选择文件管理器导航;省略时使用默认应用打开。
|
||||
|
||||
可选的 `application` 指定文件关联应用,不修改系统默认值;`workspacePathApplications({ path })` 在校验 Host 路径后返回当前关联应用、名称、图标和默认项。
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
<a id="cordis-surface"></a>
|
||||
@@ -849,6 +851,15 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' |
|
||||
*/
|
||||
@Remote('openWorkspacePath') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise<SessionOpenWorkspacePathValue>
|
||||
|
||||
/**
|
||||
* Query current file handlers on the serving desktop without activating an Agent.
|
||||
* @param request - file path in Host filesystem syntax.
|
||||
* @param signal - caller lifetime, propagated to filesystem and desktop queries.
|
||||
* @returns OS application names, icons, and default selection; empty when desktop opening is unavailable.
|
||||
* @throws RemoteError when the path is invalid, the query is cancelled, or native discovery fails.
|
||||
*/
|
||||
@Remote('workspacePathApplications') async workspacePathApplications( request: { readonly path: string }, signal: AbortSignal, ): Promise<readonly SessionWorkspacePathApplication[]>
|
||||
|
||||
/**
|
||||
* Rename one Session after explicitly resuming it.
|
||||
* @param request - Session identity and proposed title.
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/sidebar-right.md
|
||||
sidebar-right.md: 27e5219aec8997cf0d168ac9ea2665baaf943906
|
||||
sidebar-right.zh.md: 63f2c1081009778e191789038ffd1923312fc55b
|
||||
sidebar-right.md: e3a4ffcf4e4b17975c10c0358f6f53dc5eea6ec2
|
||||
sidebar-right.zh.md: a588dc99b92314c54ea1309e4a44d305e23b970f
|
||||
@@ -8,7 +8,7 @@ This page is the reference for the subsystem's contracts: addresses, tab-type re
|
||||
|
||||
## Position and ownership
|
||||
|
||||
One docking surface exists per Session, held in a session-scoped slot store and drawn by `rightbar.session`. The root-scoped `rightbar` controller mounts that seat only while Conversation is selected; a reload returns every session to the collapsed default, and switching sessions keeps each surface where it was ([state](../../packages/client/ui-sidebar-right/README.md#state)). The surface's every change is one recorded history entry computed by the kit's pure planners; a docked pane never stays empty, and an empty root pane receives the default page selected from registered guide entries.
|
||||
One docking surface exists per Session, held in a session-scoped slot store and drawn by `rightbar.session`. The root-scoped `rightbar` controller retains separate seats for the selected Session and background Sessions with initialized `keepMounted` bodies; only the foreground Conversation is visible. Reload restores saved layouts ([state](../../packages/client/ui-sidebar-right/README.md#state)). The surface's every change is one recorded history entry computed by the kit's pure planners; a docked pane never stays empty, and an empty root pane receives the default page selected from registered guide entries.
|
||||
|
||||
A tab type is two registrations that share the definition's `id`: a static definition in `ctx.sidebarRightTabs` saying which addresses its `kind` opens, and a keyed slot registration supplying its body. The framework injects `useTabInfo()` for live Sidebar, pane and tab information; each type keeps its own state in its slot store. Packages import each other's declarations only as types.
|
||||
|
||||
@@ -43,6 +43,7 @@ Tab identity is the pair `(kind, address)`: the registry's claim uses the addres
|
||||
| `priority` | One of three literal bands: `extension` (the default and the highest: a type from outside the product outranks every shipped viewer), `builtin` (types shipped with the product), `fallback` (plain-content viewers anything more specific should beat). |
|
||||
| `canOpen(address)` | Optional synchronous veto of a glob match; it runs on every routing decision. |
|
||||
| `title(address)` | The chip's text, captured into the layout record when the tab opens and never rewritten. |
|
||||
| `keepMounted` | Optional lazy retention of visited bodies across hiding, Session switches and docking; the owning View holds the Session reference. |
|
||||
| `guide` | Optional entry boxes for the guide page: `{ order, title(), description?(), icon? }`. Picking a box opens the contributing type as a page; omit to stay off the page. |
|
||||
|
||||
Routing is a ranked claim. `candidates(address)` ranks the types whose patterns match and whose `canOpen` does not veto: by band, then by the length of the longest matched pattern, then by registration order. `claim(address, kind?)` picks the first candidate, or the named `kind` outright — its globs are skipped, its `canOpen` still applies — and returns `{ kind, contentId: address, title }`. An address no type claims throws: it is a wiring mistake, not a user error.
|
||||
@@ -101,7 +102,7 @@ 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`. Docked bodies are visible only while expanded and active; docked titles need only expansion; floats stay visible. `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 `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.
|
||||
|
||||
`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)).
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
## 定位与归属
|
||||
|
||||
每个会话恰有一个停靠面,保存在会话作用域的 slot store 里、由 `rightbar.session` 绘制。root 作用域的 `rightbar` 控制器仅在选中 Conversation 时挂载该席位;刷新页面后每个会话回到折叠的默认态,切换会话时各自的面保持原状([状态](../../packages/client/ui-sidebar-right/README.zh.md#state))。面的每一次变化都是 kit 纯规划器算出的一条历史记录;停靠的 pane 从不空着,根 pane 为空时会加入根据已注册引导入口选出的默认页。
|
||||
每个会话恰有一个停靠面,保存在会话作用域的 slot store 里、由 `rightbar.session` 绘制。root 作用域的 `rightbar` 控制器为选中 Session 和拥有已初始化 `keepMounted` 正文的后台 Session 保留独立席位;只有前台 Conversation 可见。刷新会恢复已保存的布局([状态](../../packages/client/ui-sidebar-right/README.zh.md#state))。面的每一次变化都是 kit 纯规划器算出的一条历史记录;停靠的 pane 从不空着,根 pane 为空时会加入根据已注册引导入口选出的默认页。
|
||||
|
||||
一个 tab 类型是共用定义 `id` 的两次注册:在 `ctx.sidebarRightTabs` 里的静态定义说明其 `kind` 打开哪些地址,一次 keyed slot 注册提供它的正文。框架注入 `useTabInfo()` 以读取 Sidebar、窗格和标签的实时信息;各类型把自身状态放在 slot store 里。各包之间只以类型形式引用彼此的声明。
|
||||
|
||||
@@ -43,6 +43,7 @@ tab 身份是 `(kind, address)` 二元组:注册表的认领把地址原文用
|
||||
| `priority` | 三档字面量之一:`extension`(缺省且最高:产品之外的类型压过所有内置查看器)、`builtin`(随产品发布的类型)、`fallback`(任何更具体的类型都应压过的纯内容查看器)。 |
|
||||
| `canOpen(address)` | 可选的同步否决,对 glob 命中生效;每次路由决策都会调用。 |
|
||||
| `title(address)` | chip 文本,在 tab 打开时捕获进布局记录,之后不再改写。 |
|
||||
| `keepMounted` | 可选的懒保活:已访问正文跨隐藏、Session 切换与停靠切换保留,由所属 View 持有 Session reference。 |
|
||||
| `guide` | 可选的引导页入口框:`{ order, title(), description?(), icon? }`。点一框即把贡献它的类型作为页面打开;省略即不上引导页。 |
|
||||
|
||||
路由是一次排序认领。`candidates(address)` 对模式命中且未被 `canOpen` 否决的类型排序:先按档,再按最长命中模式的长度,最后按注册顺序。`claim(address, kind?)` 取第一个候选,或直接用点名的 `kind`——跳过它的 glob,但 `canOpen` 仍生效——返回 `{ kind, contentId: address, title }`。没有任何类型认领的地址会抛错:这是接线错误,不是用户错误。
|
||||
@@ -101,7 +102,7 @@ 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`。停靠正文仅在展开且活跃时可见;停靠标题只要求展开;浮窗保持可见。`signal` 在记录消失或插件卸载时中止,不因隐藏或切换 Session 而中止。`tab.actions` 提供绑定到标签所属 Session 的 `openResource`、`openTab` 与 `close`。打开位置缺省为当前所属窗格;`revealIfOpened` 缺省为 `true`,`replaceTab: true` 在同一历史项中替换本记录。菜单项保留普通的 `tab` 与 `dismiss` owner 参数。
|
||||
正文、标题与引导页替换项接收框架注入的 `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 参数。
|
||||
|
||||
`navigation.revision` 在每次导航到该 tab 时递增,`params` 不变也递增,正文可仅凭「又被导航了」行动;按地址打开的 tab 为 `1`,没有人按地址打开的记录——种入的引导、撤销恢复的 tab——为 `0`。Tab 域为每条打开的记录保有一个 occurrence:记录出现即在资源模型里钉住,因此切换 tab 卸载正文也不丢内容;记录消失即中止并丢弃;撤销恢复的记录是新的 occurrence([Tab 域](../../packages/client/ui-sidebar-right/README.zh.md#the-tab-domain))。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/api/session-controller/README.md
|
||||
README.md: 49f38e5af8f0939dd8f1ccafcd7928b10954ca33
|
||||
README.zh.md: 57112fc5ce88567ecfe415ed7ee4dd5ac9d34224
|
||||
README.md: 0516fd93f2887eedede30effde2b79e8c5adde10
|
||||
README.zh.md: ef7db2e48a28671d56d408969c5f627e74cfafec
|
||||
@@ -70,6 +70,8 @@ References keep local Session data, scoped Contexts, and history streams alive,
|
||||
|
||||
`SessionMediaReferences` mounts `GET|HEAD /api/file?path=<absolute path>` on the authenticated `connection.fetch` channel when `connection`, `fs`, and `attachments` are composed. It reads ordinary files through `ctx.fs`, including temporary paths outside registered workspaces and files in remote providers. Neither directory containment nor MIME categories restrict access; `mime-types` supplies the response type, with `application/octet-stream` for unknown extensions. GET reuses `readBytes` for preflight and ongoing byte limits; HEAD reads metadata only. All files use `ctx.attachments.imageLimits.maxImageBytes` (normally 20 MiB); exceeding this limit returns 413. Responses contain the complete file, ignore Range, and carry `private, no-store`, `nosniff`, and a sandbox CSP so directly opened HTML/SVG cannot execute with the API origin. The Client rewrite lives in `ui-chat` (`AssistantMarkdown`); audio/video responses are available, while Markdown audio/video player nodes remain separate work.
|
||||
|
||||
`workspacePathApplications({ path })` returns the serving desktop’s file handlers after the same filesystem mapping check used by `openWorkspacePath`. An optional `application` in the open request selects a currently registered handler without changing the system default. Application names, default selection, icons, and platform coverage come from [native-command](../../util/native-command/README.md). These operations do not activate an Agent or append Session events. Native failures return bounded messages; original command errors remain Host-side causes.
|
||||
|
||||
-----
|
||||
|
||||
<a id="configuration"></a>
|
||||
|
||||
@@ -70,6 +70,8 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在
|
||||
|
||||
当 `connection`、`fs` 与 `attachments` 均被组合时,`SessionMediaReferences` 在鉴权 `connection.fetch` 通道上挂载 `GET|HEAD /api/file?path=<绝对路径>`。它通过 `ctx.fs` 读取普通文件,包括已注册工作区之外的临时路径与远程提供方中的文件。目录包含关系与 MIME 类别均不限制访问;`mime-types` 提供响应类型,未知扩展名使用 `application/octet-stream`。GET 复用 `readBytes` 执行读取前及读取中的字节限制;HEAD 只读取元数据。所有文件均使用 `ctx.attachments.imageLimits.maxImageBytes`(通常为 20 MiB);超过此上限返回 413。响应包含完整文件,忽略 Range,并携带 `private, no-store`、`nosniff` 与沙箱 CSP,使直接打开的 HTML/SVG 无法以 API 源身份执行脚本。客户端重写位于 `ui-chat`(`AssistantMarkdown`);音视频文件响应已可用,Markdown 音视频播放器节点仍是独立工作。
|
||||
|
||||
`workspacePathApplications({ path })` 使用与 `openWorkspacePath` 相同的文件系统映射校验,返回服务端桌面上该文件的关联应用。打开请求中的可选 `application` 指定当前关联的应用,不修改系统默认应用。应用名称、默认项、图标和平台支持范围由 [native-command](../../util/native-command/README.zh.md) 提供。这些操作不激活 Agent,也不追加会话事件。原生操作失败时返回简短消息,原始命令异常作为 Host 端原因保留。
|
||||
|
||||
-----
|
||||
|
||||
<a id="configuration"></a>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-client-file-upload'
|
||||
import { canOpenNativePath, nativeFileManager, openNativeAssociatedPath, revealNativePath } from '@deepseek-ai/dsh-native-command'
|
||||
import { canOpenNativePath, nativeFileManager, nativeFileApplications, openNativeFileApplication, openNativeAssociatedPath, revealNativePath } from '@deepseek-ai/dsh-native-command'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
|
||||
import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
|
||||
@@ -29,6 +29,7 @@ import { SessionSkillCatalog } from './skill-catalog.ts'
|
||||
import { SessionMediaReferences } from './media-references.ts'
|
||||
import type {
|
||||
ModelCatalog,
|
||||
SessionWorkspacePathApplication,
|
||||
SessionAttachmentRequest,
|
||||
SessionAttachmentValue,
|
||||
SessionCancelRequest,
|
||||
@@ -83,6 +84,10 @@ export interface Config {
|
||||
export interface SessionControllerInternals {
|
||||
/** Native default-application handoff. */
|
||||
readonly openPath?: (path: string, signal: AbortSignal) => Promise<void>
|
||||
/** Native file-association query. */
|
||||
readonly fileApplications?: typeof nativeFileApplications
|
||||
/** Explicit registered-application handoff. */
|
||||
readonly openFileApplication?: typeof openNativeFileApplication
|
||||
/** Native file-manager handoff. */
|
||||
readonly revealPath?: (path: string, signal: AbortSignal) => Promise<void>
|
||||
/** Native handoff availability probe. */
|
||||
@@ -115,6 +120,8 @@ export class SessionController extends TypertRemoteService {
|
||||
private readonly history: SessionHistoryController
|
||||
private readonly listState: ApiSessionList
|
||||
private readonly openPath: (path: string, signal: AbortSignal) => Promise<void>
|
||||
private readonly fileApplications: typeof nativeFileApplications
|
||||
private readonly openFileApplication: typeof openNativeFileApplication
|
||||
private readonly revealPath: (path: string, signal: AbortSignal) => Promise<void>
|
||||
private readonly canOpenPath: () => boolean
|
||||
private readonly promotions = new Set<Promise<void>>()
|
||||
@@ -142,6 +149,8 @@ export class SessionController extends TypertRemoteService {
|
||||
}, 'session-controller.promotions')
|
||||
this.history = new SessionHistoryController(ctx, (observation) => { this.promote(observation) })
|
||||
this.listState = new ApiSessionList(ctx)
|
||||
this.fileApplications = internals.fileApplications ?? nativeFileApplications
|
||||
this.openFileApplication = internals.openFileApplication ?? openNativeFileApplication
|
||||
this.openPath = internals.openPath ?? openNativeAssociatedPath
|
||||
this.revealPath = internals.revealPath ?? revealNativePath
|
||||
this.canOpenPath = internals.canOpenPath
|
||||
@@ -309,35 +318,59 @@ export class SessionController extends TypertRemoteService {
|
||||
request: SessionOpenWorkspacePathRequest,
|
||||
signal: AbortSignal,
|
||||
): Promise<SessionOpenWorkspacePathValue> {
|
||||
if (request.path.length === 0) {
|
||||
throw new RemoteError(
|
||||
'gateway/bad-request',
|
||||
'session.openWorkspacePath requires a non-empty path',
|
||||
{},
|
||||
)
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
try {
|
||||
const hostPath = resolve(request.path)
|
||||
const { fs } = this.ctx
|
||||
const mapped = fs.processPathFromHostPath(hostPath)
|
||||
if (mapped === undefined || fs.processPath(await fs.resolve(mapped, { signal })) !== hostPath) {
|
||||
throw new Error('Path has no verified Host path')
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
if (request.action === 'reveal') await this.revealPath(request.path, signal)
|
||||
else await this.openPath(request.path, signal)
|
||||
const path = await this.verifyDesktopPath(request.path, signal)
|
||||
if (request.action === 'reveal') await this.revealPath(path, signal)
|
||||
else if (request.application !== undefined) await this.openFileApplication(path, request.application, signal)
|
||||
else await this.openPath(path, signal)
|
||||
return { opened: true }
|
||||
} catch (error: unknown) {
|
||||
if (signal.aborted) throw new RemoteError('gateway/cancelled', 'path open was aborted', {})
|
||||
if (error instanceof RemoteError) throw error
|
||||
throw new RemoteError(
|
||||
'gateway/internal',
|
||||
`path open failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
'path open failed',
|
||||
{},
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query current file handlers on the serving desktop without activating an Agent.
|
||||
* @param request - file path in Host filesystem syntax.
|
||||
* @param signal - caller lifetime, propagated to filesystem and desktop queries.
|
||||
* @returns OS application names, icons, and default selection; empty when desktop opening is unavailable.
|
||||
* @throws RemoteError when the path is invalid, the query is cancelled, or native discovery fails.
|
||||
*/
|
||||
@Remote('workspacePathApplications')
|
||||
async workspacePathApplications(
|
||||
request: { readonly path: string }, signal: AbortSignal,
|
||||
): Promise<readonly SessionWorkspacePathApplication[]> {
|
||||
if (!this.canOpenPath()) return []
|
||||
try {
|
||||
const path = await this.verifyDesktopPath(request.path, signal)
|
||||
return await this.fileApplications(path, signal)
|
||||
} catch (error: unknown) {
|
||||
if (signal.aborted) throw new RemoteError('gateway/cancelled', 'application query was aborted', {})
|
||||
if (error instanceof RemoteError) throw error
|
||||
throw new RemoteError('gateway/internal', 'file application query failed', {}, { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
private async verifyDesktopPath(path: string, signal: AbortSignal): Promise<string> {
|
||||
if (path.length === 0) throw new RemoteError('gateway/bad-request', 'A non-empty file path is required', {})
|
||||
signal.throwIfAborted()
|
||||
const hostPath = resolve(path)
|
||||
const { fs } = this.ctx
|
||||
const mapped = fs.processPathFromHostPath(hostPath)
|
||||
if (mapped === undefined || fs.processPath(await fs.resolve(mapped, { signal })) !== hostPath) {
|
||||
throw new RemoteError('gateway/bad-request', 'Path has no verified Host path', {})
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
return hostPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename one Session after explicitly resuming it.
|
||||
* @param request - Session identity and proposed title.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Browser-safe request, result, and lifecycle vocabulary for the Session Remote service. */
|
||||
import type { NativeFileApplication } from '@deepseek-ai/dsh-native-command/types'
|
||||
|
||||
import type {
|
||||
AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef, ImageMediaType,
|
||||
@@ -381,6 +382,8 @@ export interface SessionCancelValue {
|
||||
export interface SessionOpenWorkspacePathRequest {
|
||||
/** File-manager navigation when requested; omission uses the default application. */
|
||||
readonly action?: 'reveal'
|
||||
/** Registered application identifier; ignored for reveal. Omission preserves the operating system default. */
|
||||
readonly application?: string
|
||||
/** Path after best-effort Session workspace resolution, in Host filesystem syntax. */
|
||||
readonly path: string
|
||||
}
|
||||
@@ -624,3 +627,6 @@ declare module '@deepseek-ai/cordis' {
|
||||
|
||||
/** JSON-compatible projection value accepted by list consumers. */
|
||||
export type SessionProjectionValue = JsonValue
|
||||
|
||||
/** Application metadata returned by the serving desktop for one file. */
|
||||
export type SessionWorkspacePathApplication = NativeFileApplication
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolve } from 'node:path'
|
||||
import * as nativeCommand from '@deepseek-ai/dsh-native-command'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
@@ -65,11 +66,11 @@ describe('session/openWorkspacePath', () => {
|
||||
|
||||
await expect(remote.openWorkspacePath({ path: '/workspace/project/src/a.ts' }, signal))
|
||||
.resolves.toEqual({ ok: true, value: { opened: true } })
|
||||
expect(openPath).toHaveBeenCalledWith('/workspace/project/src/a.ts', signal)
|
||||
expect(openPath).toHaveBeenCalledWith(resolve('/workspace/project/src/a.ts'), signal)
|
||||
expect(ctx.agents.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves relative and absolute Host-resolvable paths', async () => {
|
||||
it('normalizes relative and absolute Host-resolvable paths', async () => {
|
||||
const ctx = await context()
|
||||
const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve())
|
||||
const remote = createSessionTestRemote(ctx, {
|
||||
@@ -80,7 +81,7 @@ describe('session/openWorkspacePath', () => {
|
||||
|
||||
await remote.openWorkspacePath({ path: '/workspace/result.html' })
|
||||
await remote.openWorkspacePath({ path: 'result.html' })
|
||||
expect(openPath.mock.calls.map(call => call[0])).toEqual(['/workspace/result.html', 'result.html'])
|
||||
expect(openPath.mock.calls.map(call => call[0])).toEqual([resolve('/workspace/result.html'), resolve('result.html')])
|
||||
})
|
||||
|
||||
it('rejects empty paths before opening anything', async () => {
|
||||
@@ -100,7 +101,7 @@ describe('session/openWorkspacePath', () => {
|
||||
it('preserves native opener failure and cancellation results', async () => {
|
||||
const ctx = await context()
|
||||
const openPath = vi.fn((_path: string, _signal: AbortSignal) =>
|
||||
Promise.reject(new Error('desktop unavailable')))
|
||||
Promise.reject(new Error('Command failed: powershell.exe -EncodedCommand private-script-text')))
|
||||
const remote = createSessionTestRemote(ctx, {
|
||||
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
|
||||
cwd: '/default',
|
||||
@@ -110,7 +111,7 @@ describe('session/openWorkspacePath', () => {
|
||||
await expect(remote.openWorkspacePath({ path: 'result.html' }))
|
||||
.resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'gateway/internal', message: 'path open failed: desktop unavailable' },
|
||||
error: { code: 'gateway/internal', message: 'path open failed' },
|
||||
})
|
||||
|
||||
const aborted = new AbortController()
|
||||
@@ -139,7 +140,7 @@ describe('session/openWorkspacePath', () => {
|
||||
await expect(controller.openWorkspacePath({
|
||||
path: 'second.html',
|
||||
}, new AbortController().signal)).rejects.toMatchObject({
|
||||
code: 'gateway/internal', message: 'path open failed: desktop unavailable',
|
||||
code: 'gateway/internal', message: 'path open failed',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -156,7 +157,7 @@ it('reports Host file-manager metadata and dispatches reveal separately from def
|
||||
expect(controller.workspaceDesktop()).toMatchObject({ available: true, name: expect.any(String) as string })
|
||||
const signal = new AbortController().signal
|
||||
await controller.openWorkspacePath({ path: '/workspace/report.txt', action: 'reveal' }, signal)
|
||||
expect(revealPath).toHaveBeenCalledWith('/workspace/report.txt', signal)
|
||||
expect(revealPath).toHaveBeenCalledWith(resolve('/workspace/report.txt'), signal)
|
||||
expect(openPath).not.toHaveBeenCalled()
|
||||
} finally { await ctx.fiber.dispose() }
|
||||
})
|
||||
@@ -186,7 +187,7 @@ it.each(['open', 'reveal'] as const)('rejects an unmapped remote path before nat
|
||||
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, revealPath,
|
||||
})
|
||||
await expect(controller.openWorkspacePath({ path: '/remote/report.html', ...(action === 'reveal' ? { action } : {}) }, new AbortController().signal))
|
||||
.rejects.toMatchObject({ code: 'gateway/internal', message: 'path open failed: Path has no verified Host path' })
|
||||
.rejects.toMatchObject({ code: 'gateway/bad-request', message: 'Path has no verified Host path' })
|
||||
expect(openPath).not.toHaveBeenCalled()
|
||||
expect(revealPath).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -200,7 +201,7 @@ it('rejects a filesystem mapping that resolves to another process path', async (
|
||||
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath,
|
||||
})
|
||||
await expect(controller.openWorkspacePath({ path: '/report.html' }, new AbortController().signal))
|
||||
.rejects.toMatchObject({ code: 'gateway/internal', message: 'path open failed: Path has no verified Host path' })
|
||||
.rejects.toMatchObject({ code: 'gateway/bad-request', message: 'Path has no verified Host path' })
|
||||
expect(openPath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -213,5 +214,61 @@ it('dispatches default-app opening to the association adapter', async () => {
|
||||
})
|
||||
const signal = new AbortController().signal
|
||||
await controller.openWorkspacePath({ path: '/report.html' }, signal)
|
||||
expect(open).toHaveBeenCalledWith('/report.html', signal)
|
||||
expect(open).toHaveBeenCalledWith(resolve('/report.html'), signal)
|
||||
})
|
||||
|
||||
|
||||
it('uses the same path verification for handler queries and explicit application opening', async () => {
|
||||
const ctx = await context()
|
||||
const applications = [{ id: '/Applications/Music.app', name: 'Music', default: true, icon: null }]
|
||||
const fileApplications = vi.fn(async () => applications)
|
||||
const openFileApplication = vi.fn(async () => {})
|
||||
const controller = createSessionTestController(ctx, {
|
||||
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true,
|
||||
fileApplications, openFileApplication,
|
||||
})
|
||||
const signal = new AbortController().signal
|
||||
expect(await controller.workspacePathApplications({ path: '/file.mp3' }, signal)).toEqual(applications)
|
||||
await controller.openWorkspacePath({ path: '/file.mp3', application: applications[0]!.id }, signal)
|
||||
expect(openFileApplication).toHaveBeenCalledWith(expect.stringContaining('file.mp3'), applications[0]!.id, signal)
|
||||
const mapping = vi.spyOn(ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined)
|
||||
onTestFinished(() => { mapping.mockRestore() })
|
||||
await expect(controller.workspacePathApplications({ path: '/remote.mp3' }, signal)).rejects.toThrow('verified Host path')
|
||||
await expect(controller.openWorkspacePath({ path: '/remote.mp3', application: applications[0]!.id }, signal)).rejects.toThrow('verified Host path')
|
||||
expect(fileApplications).toHaveBeenCalledOnce()
|
||||
expect(openFileApplication).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
|
||||
it('avoids desktop queries when unavailable and rejects an empty query path', async () => {
|
||||
const fileApplications = vi.fn(async () => [])
|
||||
const unavailable = createSessionTestController(await context(), {
|
||||
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: false, fileApplications,
|
||||
})
|
||||
expect(await unavailable.workspacePathApplications({ path: '/file.mp3' }, new AbortController().signal)).toEqual([])
|
||||
const available = createSessionTestController(await context(), {
|
||||
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true, fileApplications,
|
||||
})
|
||||
await expect(available.workspacePathApplications({ path: '' }, new AbortController().signal)).rejects.toMatchObject({ code: 'gateway/bad-request' })
|
||||
expect(fileApplications).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
it('returns bounded query failures and classifies cancellation through the Remote', async () => {
|
||||
const lifetime = new AbortController()
|
||||
const fileApplications = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('Command failed: osascript -e private-script-text'))
|
||||
.mockImplementationOnce(async () => {
|
||||
lifetime.abort()
|
||||
throw new Error('native query stopped')
|
||||
})
|
||||
const remote = createSessionTestRemote(await context(), {
|
||||
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true, fileApplications,
|
||||
})
|
||||
await expect(remote.workspacePathApplications({ path: '/file.mp3' })).resolves.toMatchObject({
|
||||
ok: false, error: { code: 'gateway/internal', message: 'file application query failed' },
|
||||
})
|
||||
await expect(remote.workspacePathApplications({ path: '/file.mp3' }, lifetime.signal)).resolves.toMatchObject({
|
||||
ok: false, error: { code: 'gateway/cancelled' },
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Test-only direct Remote face over the Session Controller's internal controllers. */
|
||||
import type { SessionControllerInternals } from '../src/index.ts'
|
||||
|
||||
import { SessionLogOffset } from '@deepseek-ai/dsh-session'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
@@ -48,6 +49,7 @@ import type {
|
||||
SessionListValue,
|
||||
SessionOpenWorkspacePathRequest,
|
||||
SessionOpenWorkspacePathValue,
|
||||
SessionWorkspacePathApplication,
|
||||
SessionPage,
|
||||
SessionPageRequest,
|
||||
SessionPromptRequest,
|
||||
@@ -65,6 +67,9 @@ import type {
|
||||
|
||||
/** Direct test face matching the generated `ctx.remote.session` unary methods. */
|
||||
export interface TestSessionRemote {
|
||||
workspacePathApplications(
|
||||
request: { readonly path: string }, signal?: AbortSignal,
|
||||
): Promise<RemoteResult<readonly SessionWorkspacePathApplication[]>>
|
||||
canOpenWorkspacePath(): Promise<RemoteResult<boolean>>
|
||||
list(request: SessionListRequest, signal?: AbortSignal): Promise<RemoteResult<SessionListValue>>
|
||||
search(request: SessionSearchRequest, signal?: AbortSignal): Promise<RemoteResult<SessionSearchValue>>
|
||||
@@ -94,6 +99,8 @@ export interface TestSessionRemoteDefaults {
|
||||
readonly nativeOpen?: boolean
|
||||
readonly saveDefaultModelSelection?: (selection: AgentModelSelection) => void | Promise<void>
|
||||
readonly openPath?: (path: string, signal: AbortSignal) => Promise<void>
|
||||
readonly fileApplications?: SessionControllerInternals['fileApplications']
|
||||
readonly openFileApplication?: SessionControllerInternals['openFileApplication']
|
||||
readonly revealPath?: (path: string, signal: AbortSignal) => Promise<void>
|
||||
readonly canOpenPath?: () => boolean
|
||||
}
|
||||
@@ -288,6 +295,8 @@ function installControllers(
|
||||
},
|
||||
{
|
||||
...defaults.openPath === undefined ? {} : { openPath: defaults.openPath },
|
||||
...defaults.fileApplications === undefined ? {} : { fileApplications: defaults.fileApplications },
|
||||
...defaults.openFileApplication === undefined ? {} : { openFileApplication: defaults.openFileApplication },
|
||||
...defaults.revealPath === undefined ? {} : { revealPath: defaults.revealPath },
|
||||
...defaults.canOpenPath === undefined ? {} : { canOpenPath: defaults.canOpenPath },
|
||||
},
|
||||
@@ -334,6 +343,9 @@ export function createSessionTestRemote(
|
||||
): TestSessionRemote {
|
||||
const direct = createSessionTestController(ctx, defaults)
|
||||
return {
|
||||
workspacePathApplications: (request, signal = new AbortController().signal) => remoteResult(
|
||||
() => direct.workspacePathApplications(request, signal), signal,
|
||||
),
|
||||
canOpenWorkspacePath: () => remoteResult(() => direct.canOpenWorkspacePath()),
|
||||
list: (request, signal = new AbortController().signal) => remoteResult(
|
||||
() => direct.list(request, signal),
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
{ "path": "../../util/crypto" },
|
||||
{ "path": "../../util/workspace-path" },
|
||||
{ "path": "../../workspace/workspace" },
|
||||
{ "path": "../../typert/protocol" }
|
||||
{ "path": "../../typert/protocol" },
|
||||
{ "path": "../../util/native-command" }
|
||||
]
|
||||
}
|
||||
@@ -61,7 +61,7 @@ function styleInjectionModule(
|
||||
* Everything else under @deepseek-ai/* is either a module-table entry
|
||||
* (external) or a leak the purity gate rejects.
|
||||
*/
|
||||
export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|output-retention|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-host-open-in-app\/shared$|@deepseek-ai\/dsh-plugin-manager\/registry$|@deepseek-ai\/dsh-agent-presets\/display$|@deepseek-ai\/dsh-spill-policy\/notice$)/
|
||||
export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|output-retention|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-native-command\/types$|@deepseek-ai\/dsh-host-open-in-app\/shared$|@deepseek-ai\/dsh-plugin-manager\/registry$|@deepseek-ai\/dsh-agent-presets\/display$|@deepseek-ai\/dsh-spill-policy\/notice$)/
|
||||
|
||||
/**
|
||||
* Vendored framework libraries: rescoped into @deepseek-ai, so the gate below
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md
|
||||
README.md: 3a0e74332a0ee2c5453a024f1ffe9dfbb9ae0163
|
||||
README.zh.md: 89a385a61f08efa86c98d59fd4271f99c3b25210
|
||||
README.md: 2beb4dfadddec0c834c3ef6f4b56826be120baa6
|
||||
README.zh.md: 5d643f4e90b77d2b79ab1bb1c003b399c202e004
|
||||
@@ -8,14 +8,14 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Use this package to render a browser chat from recorded Session conversations, including historical images, localized actions, and restored scroll position. Compact display folds completed-turn process rows while keeping the final answer and independently useful context visible; packed historical Assistant runs remain collapsed. Local transcript and steering submissions appear immediately, remain in their original surface, and disappear atomically when authoritative Session records arrive, while queued submissions stay outside Chat. The package does not assemble or modify model requests.
|
||||
Use this package to render a browser chat from recorded Session conversations, including historical images, localized actions, and restored scroll position. Work-details modes control reasoning previews and fold eligible completed-turn process rows without hiding final answers. Local transcript and steering submissions appear immediately, remain in their original surface, and disappear atomically when authoritative Session records arrive, while queued submissions stay outside Chat. The package does not assemble or modify model requests.
|
||||
|
||||
File-mention providers receive the viewed Session ID with the closing-turn owner, so links into inherited history can address the fork itself.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Reference previews](#reference-previews)
|
||||
- [System prompt row](#system-prompt-row)
|
||||
- [Hidden Chat rows](#system-prompt-row)
|
||||
- [Command and failure rows](#command-and-failure-rows)
|
||||
- [Turn token usage](#turn-token-usage)
|
||||
- [Completed-turn footer](#completed-turn-footer)
|
||||
@@ -35,9 +35,9 @@ Chat supplies file and HTTP(S) navigation through one `MarkdownDelegateProvider`
|
||||
Settings → General → Open chat links in selects the destination for ordinary clicks on Chat HTTP(S) links: Built-in browser (default) opens a new right-Sidebar Browser tab, while New browser tab opens an external tab. The setting is shown only while the built-in browser is available. If the Sidebar Browser is not registered, both choices use the external browser; modified clicks retain native behavior. The `ui-chat.linkOpening` preference persists on loopback browsers and stays process-local when settings cannot persist writes. Sent file references and skills confirmed by the message’s logged invocation also open in the right Sidebar. File paths use the viewed Session; skill names resolve through its current input-trigger source. Both use the prose file-link dotted underline on hover or focus. Sessions, directories, and command labels remain non-navigating references.
|
||||
|
||||
<a id="system-prompt-row"></a>
|
||||
## System prompt row
|
||||
## Hidden Chat rows
|
||||
|
||||
Each nonempty appended `system/message` owns a collapsed prompt row, including a complete prompt at the start of a headerless window; the same-step header does not duplicate it. Chat also shows a collapsed `System prompt` row for a non-empty initial request, explicit message-series start, or `system/message` surface node replacement whose text differs, reading the last nonempty surviving system node in surface order at the `request/header`; a non-initial request whose preceding header is outside the loaded history window also shows one. A resume repeats the row even when its system text is unchanged, including after pagination supplies the preceding header and system node; same-series config-only or tool-only changes, tool steps, and retries create no repetition, and a `system/message` event is never rendered as a transcript message. The row appears before that request's user messages, matching the provider envelope, and expands to the exact model-visible text with its original line breaks. A request whose system node is empty or outside the loaded window creates no row until the page holding the node arrives.
|
||||
Chat omits system-prompt and `permission` command rows in every work-details mode. The filter changes neither recorded Session events nor Trajectory inspection. Ordinary Context injection and other command rows remain in Chat; Context injection can fold with the owning Turn's process content.
|
||||
|
||||
<a id="command-and-failure-rows"></a>
|
||||
## Command and failure rows
|
||||
@@ -65,11 +65,23 @@ The completed-turn action footer starts 20px below the preceding prose or extens
|
||||
<a id="turn-process-folding"></a>
|
||||
## Turn Process Folding
|
||||
|
||||
Each reasoning row starts collapsed, including during streaming and in reasoning-only replies. Clicking the row opens or closes its complete Markdown; incoming answer text, Tool calls, and stream completion preserve that choice. Expanded reasoning uses compact secondary typography: headings add bold weight without changing text size, line height, or color. The collapsed summary stays on one line, follows the latest reasoning line while streaming, and shows the first line after settlement.
|
||||
Each reasoning row starts collapsed, including during streaming and in reasoning-only replies. Clicking the row opens or closes its complete Markdown; incoming answer text, Tool calls, and stream completion preserve that choice. Expanded reasoning uses compact secondary typography: headings add bold weight without changing text size, line height, or color. While streaming, all modes preview the latest paragraph whose first line ends with a newline. Further text in that paragraph does not change the preview; an unfinished single line has no preview. One or more blank lines separate paragraphs. The one-line preview fades at the right edge. After settlement, Compact hides the preview, while Detailed and Expanded show the full text's first line.
|
||||
|
||||
Reasoning rows retain the disclosure's callback, icon, and unchanged content references. Each reasoning row selects its preview visibility from the display policy; mode changes toggle CSS display without unmounting the collapsed summary or updating its parent Assistant renderer. Full Markdown elements are created only while expanded.
|
||||
|
||||
Settings → General exposes a persisted, localized `Normal` / `Compact` conversation-display preference in the `ui-chat` namespace; `Compact` is the default. Normal leaves process rows visible and renders no Turn-process control. In Compact mode, the System prompt remains independently visible before the opening User throughout the Turn. Context injection, reasoning, Assistant material, Tool rows, and Retry rows remain expanded while a Turn is open. At `turn/end`, its latest Step becomes the final-answer boundary only when it contains non-blank text, an image, or an unknown visible block—and no Tool-call block; preceding Context injection, reasoning, earlier Assistant material, Tool rows, and Retry rows then collapse by default. The control reports Turn-wide durable counts for non-subagent Tool calls, reply-bearing Assistant messages before the final answer, and subagent delegation calls; zero-valued segments are omitted, the Tool and subagent figures are mutually exclusive, and neither System prompt nor Context injection contributes a count. When all three counts are zero, the process still folds and the control reads `Thought for a while`. A full-width divider below the summary separates it from the answer or expanded process rows. User and steering messages, System prompt, error, max-token, and turn-tail rows stay outside, and a closed Turn with no final answer keeps all process evidence visible. A newly available process control is inserted without changing the relative order of existing rows: opening human input precedes the control and process rows from their first projection, while System prompt remains above that input. While older history remains available through Load earlier, process controls stay absent and no members are hidden; once history is complete, every eligible closed Turn uses the collapsed default immediately. Stable Chat Node Seats keep every renderer mounted, hidden members add no flow spacing, and a closed control sits 8px above its answer only when no independent input intervenes. Completion collapse does not depend on tail-follow position, so a reader above the tail may see the transcript reflow. An automatic collapse that would hide keyboard focus keeps the group open and leaves focus in place; a manual close focuses the process control before hiding its members. The session-scoped store records only manually expanded Turn-and-answer-Step generations; a different answer generation starts collapsed.
|
||||
Settings → General → Work details stores `ui-chat.transcriptView` as `compact` (default), `detailed`, or `expanded`. A saved legacy `normal` value reads as `detailed` without being written back; it is not offered as an option.
|
||||
|
||||
| Behavior | Compact | Detailed | Expanded |
|
||||
|---|---|---|---|
|
||||
| Settled reasoning preview | Think title only | First line | First line |
|
||||
| Individual reasoning and tool bodies | Manual expansion | Manual expansion | Manual expansion |
|
||||
| Eligible completed-Turn process | Folded by default | Folded by default | Folded by default |
|
||||
|
||||
While a Turn is open, its process rows remain in the transcript; individual reasoning and tool disclosures retain their own expansion state. At `turn/end`, its latest Step becomes the final-answer boundary only when it contains non-blank text, an image, or an unknown visible block—and no Tool-call block. If that Turn's `turn/start` is loaded, preceding Context injection, reasoning, earlier Assistant material, Tool rows, and Retry rows fold by default. Folding eligibility is per Turn: a complete Turn can fold while Load earlier remains available, and a Turn whose start is missing keeps its process content visible until that start arrives. A closed Turn with no final answer also keeps all process evidence visible.
|
||||
|
||||
The control reports Turn-wide durable counts for non-subagent Tool calls, reply-bearing Assistant messages before the final answer, and subagent delegation calls. Zero-valued segments are omitted; the Tool and subagent figures are mutually exclusive, and Context injection contributes no count. When all three counts are zero, the process still folds and the control reads `Thought for a while`. A full-width divider below the summary separates it from the answer or expanded process rows. User and steering messages, error, max-token, and turn-tail rows stay outside. A newly available process control preserves existing row order, with opening human input before the control and process rows.
|
||||
|
||||
Stable Chat Node Seats keep every renderer mounted, hidden members add no flow spacing, and a closed control sits 8px above its answer only when no independent input intervenes. Completion collapse does not depend on tail-follow position, so a reader above the tail may see the transcript reflow. An automatic collapse that would hide keyboard focus keeps the process open and leaves focus in place; a manual close focuses the process control before hiding its members. The session-scoped store records only manually expanded Turn-and-answer-Step generations; a different answer generation starts collapsed. Switching work-details modes preserves manual expansion.
|
||||
|
||||
-----
|
||||
|
||||
@@ -104,6 +116,8 @@ None; Chat presentation does not assemble or mutate provider requests.
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- **Secondary process grouping is not implemented** — Detailed and Expanded currently display the same Chat content. Expanded retains whole-Turn folding and does not automatically open individual reasoning or tool bodies.
|
||||
|
||||
- **Developer messages are not displayed** — presentation is intentionally deferred; encountering `developer/message` throws instead of rendering a fallback row.
|
||||
|
||||
- **The transcript reflects the loaded Session window** — older transcript nodes become available only after Session Controller loads the preceding event page. Turn navigation is wider than the window: the rail merges the loaded Turns with the host `turnOutline` projection, so every started Turn gets a fixed-pitch mark (10px apart; a ladder taller than the frame scrolls inside it with gradient fades), and activating an unloaded mark pages history through the Turn's `turn/start` seq before landing on its row. Without the projection (assemblies not mounting `dsh-session-turn-outline`) the rail falls back to loaded Turns only.
|
||||
|
||||
@@ -8,14 +8,14 @@ kind: "package-reference"
|
||||
|
||||
## 概述
|
||||
|
||||
使用本包可在浏览器中渲染已记录的 Session 对话,包括历史图片、本地化操作和滚动位置恢复。紧凑显示会收起已完成轮次的过程行,同时保持最终答案和独立有用的上下文可见;已打包的历史 Assistant 连续消息保持收起。本地 transcript(文本记录)与 steering(中途引导)提交会立即显示并保留在原区域,在权威会话记录到达时原子地消失,而排队中的提交始终不进入 Chat。本包不组装或修改模型请求。
|
||||
使用本包可在浏览器中渲染已记录的 Session 对话,包括历史图片、本地化操作和滚动位置恢复。工作过程展示模式控制思考预览,并收起符合条件的已完成轮次过程行,不隐藏最终答案。本地 transcript(文本记录)与 steering(中途引导)提交会立即显示并保留在原区域,在权威会话记录到达时原子地消失,而排队中的提交始终不进入 Chat。本包不组装或修改模型请求。
|
||||
|
||||
文件提及提供方同时接收当前查看的会话 ID 与收尾轮次的属主信息,因此继承历史中的链接可以指向 fork 自身。
|
||||
|
||||
## 目录
|
||||
|
||||
- [引用预览](#reference-previews)
|
||||
- [系统提示词行](#system-prompt-row)
|
||||
- [Chat 隐藏的行](#system-prompt-row)
|
||||
- [指令与失败行](#command-and-failure-rows)
|
||||
- [轮次 token 用量](#turn-token-usage)
|
||||
- [已完成轮次的页脚](#completed-turn-footer)
|
||||
@@ -35,9 +35,9 @@ Chat 在节点列表外通过一个 `MarkdownDelegateProvider` 提供文件及 H
|
||||
设置 → 通用设置 → 聊天链接打开方式控制普通点击 Chat HTTP(S) 链接时的目标:「内置浏览器」(默认)打开新的右侧 Sidebar Browser tab,「浏览器新标签页」打开外部标签页。该设置项仅在内置浏览器可用时显示。若 Sidebar Browser 未注册,两种选择均使用外部浏览器;带修饰键的点击保留原生行为。`ui-chat.linkOpening` 偏好在回环地址浏览器中持久化,设置无法持久化写入时仅在当前进程内生效。已发送的文件引用及消息日志确认调用的 skill 也可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。
|
||||
|
||||
<a id="system-prompt-row"></a>
|
||||
## 系统提示词行
|
||||
## Chat 隐藏的行
|
||||
|
||||
每个非空追加的 `system/message` 都拥有一行折叠提示词,包括无 header 窗口起点的完整提示词;同一步骤的 header 不会重复它。Chat 也会为非空的初始请求、显式消息序列起点、文本发生变化的 `system/message` surface 节点替换(文本读取自 `request/header` 处 surface 顺序中最后一个非空存活系统节点),或前序 header 尚未进入已加载历史窗口的非初始请求显示一行默认折叠的 `系统提示词`。即使系统文本未变,恢复也会重复该行,包括分页补齐前序 header 和系统节点后;同一序列内仅配置或仅工具变化、工具步骤与重试不会重复,且 `system/message` 事件绝不会渲染为对话消息。该行位于请求的用户消息之前,与提供方 envelope 顺序一致;展开后显示模型所见的确切文本,并保留其原始换行。系统节点为空或位于已加载窗口之外的请求不创建该行,直到包含该节点的分页到达。
|
||||
Chat 在所有工作过程展示模式下都不显示系统提示词行和 `permission` 命令行。该过滤不改变已记录的 Session 事件或 Trajectory 查看能力。普通上下文注入和其他命令行仍保留在 Chat 中;上下文注入可随所属轮次的过程内容一起收起。
|
||||
|
||||
<a id="command-and-failure-rows"></a>
|
||||
## 指令与失败行
|
||||
@@ -65,11 +65,23 @@ Chat 在节点列表外通过一个 `MarkdownDelegateProvider` 提供文件及 H
|
||||
<a id="turn-process-folding"></a>
|
||||
## 轮次过程折叠
|
||||
|
||||
每个思考行初始都折叠,包括流式输出期间和仅含思考的回复。点击该行可展开或收起完整 Markdown;后续回答文本、工具调用和流结束都保留用户的选择。展开的思考采用紧凑的次级排版:标题只加粗,不改变字号、行高或颜色。折叠摘要保持单行,在流式输出期间跟随最新思考行,结算后显示第一行。
|
||||
每个思考行初始都折叠,包括流式输出期间和仅含思考的回复。点击该行可展开或收起完整 Markdown;后续回答文本、工具调用和流结束都保留用户的选择。展开的思考采用紧凑的次级排版:标题只加粗,不改变字号、行高或颜色。流式输出期间,三档都预览最近一个首行以换行符结束的段落;该段落后续文本不会改变预览,未写完的单行不显示预览。一个或多个空行分隔段落。预览保持单行,右侧渐隐。结算后,「简洁」隐藏预览,「详细」和「完全展开」显示全文首行。
|
||||
|
||||
思考行保留折叠控件的回调、图标和未变化内容的引用。每个思考行从展示策略中选择预览可见性;模式切换只改变 CSS 显示状态,不卸载折叠摘要,也不更新其父级 Assistant 渲染器。完整 Markdown 元素仅在展开时创建。
|
||||
|
||||
「设置 → 通用设置」提供持久化到 `ui-chat` 命名空间的「标准」/「紧凑」对话显示偏好,默认使用「紧凑」。标准模式保持所有过程行可见且不渲染轮次过程控件。紧凑模式下,系统提示词在整个轮次中始终独立显示于开场 User 上方。轮次打开期间,上下文注入、推理、Assistant 内容、工具行与重试行始终展开。到 `turn/end` 时,最后一个步骤只有在包含非空文本、图片或未知可见块且不含工具调用块时才成为最终答案边界;边界之前的上下文注入、推理、较早 Assistant 内容、工具行与重试行随后默认收起。控件展示覆盖整个轮次的非 subagent 工具调用数、最终答案之前带回复内容的 Assistant 消息数和 subagent 委派数;值为 0 的分段省略,工具调用与 subagent 两项互斥,系统提示词与上下文注入都不增加计数。三项全为 0 时过程仍会收起,控件标题显示「已思考」(英文为 `Thought for a while`)。摘要下方的通栏分隔线将其与正文或展开后的过程行隔开。用户与 steering 消息、系统提示词、错误、最大 token 与 turn-tail 行留在过程组外;关闭时没有最终答案的轮次保留全部过程证据。新的过程控件插入时不会改变既有行的相对顺序:开场人工输入从首次投影起便位于控件和过程行之前,系统提示词则始终位于该输入上方。只要仍可通过「加载更早」获取历史,过程控件就不出现,也不会隐藏任何成员;历史加载完整后,每个合格的已关闭轮次立即使用默认收起状态。稳定 Chat Node Seat 会让每个 renderer 保持挂载,隐藏成员不产生消息流间距;只有中间没有独立输入时,收起控件才与正文相隔 8px。完成后的收起不依赖是否跟随尾部,因此正在上方阅读的用户可能看到 transcript 重排。若自动收起会隐藏当前键盘焦点,则过程组保持展开且焦点留在原处;手动收起会先把焦点移到过程控件,再隐藏成员。会话作用域存储只记录用户手动展开的「轮次 + 正文步骤」generation;不同正文 generation 默认收起。
|
||||
「设置 → 通用设置 → 工作过程展示」将 `ui-chat.transcriptView` 保存为 `compact`(默认)、`detailed` 或 `expanded`。已保存的旧值 `normal` 读取为 `detailed`,不自动回写,也不再作为选项提供。
|
||||
|
||||
| 行为 | 简洁 | 详细 | 完全展开 |
|
||||
|---|---|---|---|
|
||||
| 已完成思考的预览 | 只显示「思考」标题 | 首行 | 首行 |
|
||||
| 单个思考与工具正文 | 手动展开 | 手动展开 | 手动展开 |
|
||||
| 符合条件的已完成轮次过程 | 默认收起 | 默认收起 | 默认收起 |
|
||||
|
||||
轮次打开期间,过程行保留在 transcript 中,单个思考与工具控件各自保留开合状态。到 `turn/end` 时,最后一个步骤只有在包含非空文本、图片或未知可见块且不含工具调用块时才成为最终答案边界。若该轮次的 `turn/start` 已加载,边界之前的上下文注入、思考、较早 Assistant 内容、工具行与重试行默认收起。折叠资格逐轮判断:即使仍可「加载更早」,完整轮次也能收起;缺少起点的轮次则保持过程内容可见,直到该起点加载。关闭时没有最终答案的轮次也保留全部过程证据。
|
||||
|
||||
控件展示覆盖整个轮次的非 subagent 工具调用数、最终答案之前带回复内容的 Assistant 消息数和 subagent 委派数。值为 0 的分段省略;工具调用与 subagent 两项互斥,上下文注入不增加计数。三项全为 0 时过程仍会收起,控件标题显示「已思考」(英文为 `Thought for a while`)。摘要下方的通栏分隔线将其与正文或展开后的过程行隔开。用户与 steering 消息、错误、最大 token 与 turn-tail 行留在过程之外。新的过程控件保留既有行的相对顺序,开场人工输入位于控件和过程行之前。
|
||||
|
||||
稳定 Chat Node Seat 会让每个 renderer 保持挂载,隐藏成员不产生消息流间距;只有中间没有独立输入时,收起控件才与正文相隔 8px。完成后的收起不依赖是否跟随尾部,因此正在上方阅读的用户可能看到 transcript 重排。若自动收起会隐藏当前键盘焦点,则过程保持展开且焦点留在原处;手动收起会先把焦点移到过程控件,再隐藏成员。会话作用域存储只记录用户手动展开的「轮次 + 正文步骤」generation;不同正文 generation 默认收起。切换工作过程展示模式会保留手动展开状态。
|
||||
|
||||
-----
|
||||
|
||||
@@ -104,6 +116,8 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- **二级过程分组尚未实现**——「详细」与「完全展开」目前展示相同的 Chat 内容。「完全展开」仍保留整轮折叠,不会自动打开单个思考或工具正文。
|
||||
|
||||
- **不展示 developer 消息** — 展示能力有意留待后续实现;遇到 `developer/message` 时抛出错误,不渲染回退行。
|
||||
|
||||
- **transcript 只反映已加载的 Session 窗口**——只有会话控制器加载前一页事件后,更早的 transcript node 才会出现。轮次导航比窗口更宽:轨道把已加载的轮次与宿主 `turnOutline` 投影合并,每个已开始的轮次都有固定间距刻度(相隔 10px;阶梯高于外框时在框内滚动并以渐变淡出标示可滚方向),激活未加载刻度会先把历史分页拉到该轮次的 `turn/start` seq 再落到它的行上。没有该投影时(未挂载 `dsh-session-turn-outline` 的装配),轨道回退到仅显示已加载轮次。
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface AssistantMarkdownProps {
|
||||
interrupted?: boolean | undefined
|
||||
/** Render consecutive image blocks through the attachment slot. */
|
||||
renderMessageImages: ChatNodeOwnerProps['renderMessageImages']
|
||||
/** Hide reasoning that belongs to the Turn-level process disclosure or a collapsed step group. */
|
||||
/** Hide reasoning that belongs to the Turn-level process disclosure. */
|
||||
reasoningHidden?: boolean | undefined
|
||||
/** Live display policy for reasoning summaries. */
|
||||
usePresentation: UsePresentation
|
||||
|
||||
@@ -16,7 +16,7 @@ function firstLine(text: string): string {
|
||||
function latestCompletedParagraphFirstLine(text: string): string {
|
||||
let summary = ''
|
||||
let paragraphStart = 0
|
||||
const separator = /\r?\n[\t ]*\r?\n/g
|
||||
const separator = /\r?\n(?:[\t ]*\r?\n)+/g
|
||||
while (true) {
|
||||
const nextParagraph = separator.exec(text)
|
||||
const paragraphEnd = nextParagraph === null ? text.length
|
||||
|
||||
@@ -31,7 +31,6 @@ export type { ChatStoreState, ToolCallId, TurnProcessViewEntry } from './contrac
|
||||
export type { TranscriptViewRowInjected, TranscriptViewRowProps } from './settings/TranscriptViewRow.tsx'
|
||||
export type { TranscriptViewMode } from '../chat-settings.ts'
|
||||
export type { ChatPresentationPolicy } from './presentation-policy.ts'
|
||||
export { derivePresentationPolicy, presentationPolicyFor } from './presentation-policy.ts'
|
||||
export type {
|
||||
AssistantActionOwnerProps, ChatFileMentions, ChatNodeOwnerProps, ChatNodeTurnDataInjected,
|
||||
ChatNodeViewProps, ChatScrollPosition, ChatStore, ChatViewInjected, ChatViewSlotProps,
|
||||
|
||||
@@ -13,11 +13,7 @@ export interface ChatPresentationPolicy {
|
||||
readonly mode: TranscriptViewMode
|
||||
/** Whether a normally completed Turn folds its process rows behind the whole-Turn control. */
|
||||
readonly foldCompletedTurns: boolean
|
||||
/**
|
||||
* How consecutive process rows between Assistant replies present: `collapsed`
|
||||
* groups them behind a step-group row that starts closed; `none` renders
|
||||
* every row directly under the whole-Turn control with no group rows.
|
||||
*/
|
||||
/** Reserved secondary-grouping preference; no renderer consumes this field yet. */
|
||||
readonly stepGrouping: 'collapsed' | 'none'
|
||||
/** Whether a settled reasoning row previews its first line beside the Think title. */
|
||||
readonly settledReasoningPreview: boolean
|
||||
|
||||
@@ -88,6 +88,12 @@ function storeOf(runtime: SlotTestRuntime, key: 'conversation.session' | 'conver
|
||||
}
|
||||
|
||||
describe('Chat apply wiring', () => {
|
||||
it('keeps presentation-policy helpers out of the public browser entry', async () => {
|
||||
const entry = await import('../src/client/index.ts')
|
||||
expect(entry).not.toHaveProperty('derivePresentationPolicy')
|
||||
expect(entry).not.toHaveProperty('presentationPolicyFor')
|
||||
})
|
||||
|
||||
it('contributes Chat View, node renderers, and stats', async () => {
|
||||
const b = await bench()
|
||||
const views = b.runtime.slots.entries('conversation.view')
|
||||
|
||||
@@ -26,6 +26,12 @@ describe('ReasoningRow', () => {
|
||||
['First\nSecond\nThird\n', 'First'],
|
||||
['First\n\nNext unfinished', 'First'],
|
||||
['First\n\nNext complete\nDetails', 'Next complete'],
|
||||
['First\n\n\nNext complete\n', 'Next complete'],
|
||||
['First\n \n\t\nNext complete\nDetails', 'Next complete'],
|
||||
['First\r\n\r\n\r\nNext complete\r\n', 'Next complete'],
|
||||
['First\r\n \r\n\t\r\nNext complete\r\nDetails', 'Next complete'],
|
||||
['First\n\n\nNext unfinished', 'First'],
|
||||
['\n\n\nFirst complete\n', 'First complete'],
|
||||
['First\r\n \t\r\nNext complete\r\n', 'Next complete'],
|
||||
['First\n\n\n', 'First'],
|
||||
])('previews the completed paragraph first line for %j', (text, summary) => {
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
"path": "../ui-session"
|
||||
},
|
||||
{
|
||||
"path": "../ui-sidebar-browser"
|
||||
"path": "../ui-sidebar-browser/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../ui-sidebar-right"
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md
|
||||
README.md: 71f3545b8fb3fed3ecd678258e6c50bbfa4b580c
|
||||
README.zh.md: 3e59ed2b7d4ac7e9d64b7d1f2f3c477e3ca1c50e
|
||||
README.md: ce006783310c257957bc8cfddcfd18226972d18f
|
||||
README.zh.md: 82239ba169f4ada188e8a5ba8e1889c61b336f96
|
||||
@@ -30,9 +30,9 @@ Mount this plugin alongside `ui-conversation` and the Host [workspace-changes](.
|
||||
<a id="explicit-deliveries"></a>
|
||||
### Explicit deliveries
|
||||
|
||||
The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../deliverables/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows one delivery as a full-width card and multiple deliveries in a two-column grid with 10px gaps. A list longer than four files starts collapsed and provides a control that reveals or hides the complete list. Each 60px-high card uses 8px vertical and 10px horizontal inset spacing, a 20px shared `FileTypeIcon` in a 40px frame, 13px filename text, 10px secondary text, and a 12px Open action. It shows the basename and description, or the file type when no description exists; a trailing parenthesized suffix in the description is omitted, and hovering the card replaces that line with the Sidebar-preview action. Clicking the card or the left side of its split Open control previews the file in the right Sidebar. The chevron opens the standard menu for the Host default application plus Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. Matching inline-code references preview the same source files in the right Sidebar; native opening requires an explicit card-menu action. Repeated declaration of a path selects its latest description before the closing reply.
|
||||
The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../deliverables/tool-present/README.md) owns file-count limits and Session declarations. A single delivery fills the row; multiple deliveries form a two-column grid, collapsed after four files. Cards show the basename, description or file type, and a shared native opening control. Clicking the card previews the file in the right Sidebar. The control opens the default application and lists associated applications, with file reveal last. Matching inline-code references also open Sidebar previews. Duplicate declarations use the last description before the closing reply.
|
||||
|
||||
The `present` tool row retains the delivery-document glyph while showing running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. The collapsible card grid retains every delivered file. Both menu actions share pending state and show progress, acknowledgement, or an action-specific retryable error. Desktop information is read when delivery cards appear and invalidated on connection replacement; responses from a replaced connection cannot publish metadata. Selecting a native menu action returns keyboard focus to the available Sidebar Open button. Pending actions close the menu until another explicit gesture. A missing desktop disables the Open menu; a failed desktop-information read offers Retry. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device.
|
||||
The `present` tool row retains its document icon and recorded result across progress, success, failure, and interruption. Native actions share pending state and publish progress or retryable errors on the card. Successful open or reveal feedback stays visible for five seconds, fades over 200ms, then restores the file description; failures remain visible until another attempt. Desktop metadata is loaded when cards mount and invalidated on connection replacement. Without a Host desktop, cards retain Sidebar preview but omit native controls; failed metadata reads offer Retry. Delivery cards and change review expose `deliverables.file.actions` and `deliverables.review.file.actions`, supplied by [ui-open-in-app](../ui-open-in-app/README.md). Association GET requests and native POST actions both validate the recorded file coordinates and Session filesystem mapping before using the Host desktop.
|
||||
|
||||
### The changed-files card
|
||||
|
||||
|
||||
@@ -30,9 +30,9 @@ kind: "package-reference"
|
||||
<a id="explicit-deliveries"></a>
|
||||
### 显式交付
|
||||
|
||||
Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付会话文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../deliverables/tool-present/README.zh.md)拥有文件数量限制和会话声明。收尾轮次把单个交付显示为横向占满内容区的卡片,把多个交付显示为间距 10px 的双列网格。文件超过四个时,列表默认收起,并提供显示或隐藏完整列表的控件。每张卡片高 60px,上下内边距为 8px、左右为 10px;40px 图标框内使用 20px 的共享 `FileTypeIcon`,文件名为 13px、次要文本为 10px,“打开”操作为 12px。卡片显示 basename 与说明;没有说明时显示文件类型,说明末尾的括号后缀会被省略,悬停卡片时该行切换为侧栏预览提示。点击卡片或分段“打开”控件的左侧会在右侧 Sidebar 中预览文件;右侧箭头打开标准菜单,其中提供 Host 默认应用,以及 macOS 上的“在 Finder 中显示”、Windows 和 WSL 上的“在文件资源管理器中显示”或 Linux 默认文件管理器的“打开所在文件夹”。匹配的行内代码引用在右侧 Sidebar 中预览相同源文件;原生打开需要显式选择卡片菜单中的操作。同一路径重复声明时,选择收尾回复之前最近一次的说明。
|
||||
Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present`,用于声明会话文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建后以 `files: [{ path, description? }]` 调用。[present 工具](../../deliverables/tool-present/README.zh.md)拥有文件数量限制和会话声明。单个交付占满一行,多个交付使用双列网格,超过四个时默认收起。卡片显示文件名、说明或文件类型,以及共享的原生打开控件。点击卡片会在右侧 Sidebar 预览文件。控件打开默认应用,并列出关联应用,文件定位始终放在最后。匹配的行内代码引用也打开 Sidebar 预览。重复声明使用收尾回复之前最近一次的说明。
|
||||
|
||||
`present` 工具行在正在交付、已交付、失败或中断状态下都保留交付文档图标;展开已结束的调用可查看其记录的结果。可折叠卡片网格保留全部交付文件。菜单中的两个操作共享等待状态,并显示进度、成功确认或各自可重试的错误。交付卡片出现时读取桌面信息,连接更换时清除缓存,旧连接的响应不能更新元数据。选择原生菜单操作后,键盘焦点回到仍可用的侧边栏“打开”按钮。等待操作完成时关闭菜单,用户再次点击才会打开。Host 没有桌面时禁用“打开”菜单;桌面信息读取失败时提供“重试”。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。
|
||||
`present` 工具行在执行、成功、失败和中断状态下保留文档图标及记录的结果。原生操作共享等待状态,并在卡片显示进度或可重试错误。打开或定位成功的反馈显示五秒后,在 200ms 内淡出并恢复文件说明;失败信息保留到下次尝试。卡片挂载时读取桌面信息,连接更换时清除缓存。Host 没有桌面时,卡片保留 Sidebar 预览并隐藏原生控件;元数据读取失败时提供重试。交付卡片和变更对比页通过 `deliverables.file.actions` 和 `deliverables.review.file.actions` 使用 [ui-open-in-app](../ui-open-in-app/README.zh.md) 提供的控件。关联应用 GET 查询与原生 POST 操作都会先校验记录中的文件坐标及会话文件系统映射,再调用 Host 桌面。
|
||||
|
||||
### 改动文件卡片
|
||||
|
||||
|
||||
@@ -18,18 +18,14 @@
|
||||
.description { overflow: hidden; color: var(--dsw-alias-label-tertiary); font-size: 10px; font-weight: 400; line-height: 16px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.description[data-error='true'] { color: var(--dsw-alias-state-error-primary); }
|
||||
.previewHint { display: none; }
|
||||
.file:hover .secondaryText { display: none; }
|
||||
.file:hover .previewHint { display: inline; }
|
||||
.split { display: inline-flex; flex: none; align-items: stretch; box-sizing: border-box; height: 28px; overflow: hidden; pointer-events: auto; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 10px; background: var(--dsw-alias-button-floating-fill); }
|
||||
.menuAnchor { align-self: stretch; }
|
||||
.open, .chevron { display: inline-flex; align-items: center; justify-content: center; border: 0; background: none; color: var(--dsw-alias-label-primary); cursor: pointer; font-family: var(--dsw-font-family); }
|
||||
.open { padding: 4px 8px; font-size: 12px; line-height: 18px; }
|
||||
.chevron { padding: 4px 5px; border-left: 0.5px solid var(--dsw-alias-border-l3); color: var(--dsw-alias-label-secondary); }
|
||||
.open:hover, .open:focus-visible, .chevron:hover:not(:disabled), .chevron:focus-visible { background: var(--dsw-alias-interactive-bg-hover); }
|
||||
.chevron:disabled { color: var(--dsw-alias-label-dimmed); cursor: not-allowed; }
|
||||
.menuActionIcon { display: block; width: 16px; height: 16px; }
|
||||
.file:hover .description:not([role='status']) .secondaryText { display: none; }
|
||||
.file:hover .description:not([role='status']) .previewHint { display: inline; }
|
||||
.toggle { align-self: center; display: inline-flex; align-items: center; gap: 4px; min-width: 0; padding: 1px 11px; border: 0; border-radius: 8px; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; font: inherit; font-size: 12px; line-height: 18px; }
|
||||
.toggle:hover { background: var(--dsw-alias-interactive-bg-hover); }
|
||||
.toggle svg { flex: none; width: 14px; height: 14px; }
|
||||
@container (max-width: 620px) { .presented { grid-template-columns: minmax(0, 1fr); } }
|
||||
@media (pointer: coarse) { .split { min-height: 44px; } .open, .chevron { min-width: 44px; } }
|
||||
.actions { display: inline-flex; flex: none; pointer-events: auto; }
|
||||
|
||||
.secondaryText[data-success] { animation-name: success-fade; animation-timing-function: ease-out; animation-fill-mode: forwards; }
|
||||
@keyframes success-fade { to { opacity: 0; } }
|
||||
@media (prefers-reduced-motion: reduce) { .secondaryText[data-success] { animation-name: none; } }
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import { Button, IconChevronDownOutlineRegular, IconChevronUpOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { GlobalStandardProps, InjectFace, PropsLocale, PropsRuntime, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { GlobalStandardProps, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import type { PresentedOpenController } from './present-open.ts'
|
||||
import type { ChangesSummaryStore } from './changes-summary.ts'
|
||||
@@ -50,24 +50,24 @@ export function selectDeliverables(owner: TurnTailOwnerProps): DeliverablesMatch
|
||||
* @param props - closing Turn, file actions, and localized copy.
|
||||
* @returns file rows, or null when the Turn declares none.
|
||||
*/
|
||||
export function DeliverablesTail(props: PropsRuntime<'conversation.chat.turnTail'> & PropsLocale<typeof NS> & InjectFace<DeliverablesInjected>) {
|
||||
export function DeliverablesTail(props: PropsRuntime<'conversation.chat.turnTail'> & PropsLocale<typeof NS> & InjectFace<DeliverablesInjected> & PropsRenderSlots<'deliverables.file.actions'>) {
|
||||
const matched = selectDeliverables(props)
|
||||
return matched === null ? null : <Deliverables {...props} matched={matched} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the changed-files card, once the Host has served the announced
|
||||
* summary and it lists a file, and default-application buttons for declared
|
||||
* summary and it lists a file, and shared native opening controls for declared
|
||||
* files. A summary the Host no longer serves leaves no card.
|
||||
* @param props - matched announcement and files, workspace opener, and localized copy.
|
||||
* @returns the closing turn's file rows.
|
||||
*/
|
||||
export function Deliverables({
|
||||
matched, openFile, t, sessionId, useSessions, openPresented, openChangesReview, usePresentedOpen, usePresentedHost,
|
||||
useChangesSummary, reloadPresentedHost, loadChangesSummary, useShowCodeDiff,
|
||||
useChangesSummary, reloadPresentedHost, loadChangesSummary, useShowCodeDiff, renderSlot,
|
||||
}: Pick<TurnTailOwnerProps, 'openFile'> & {
|
||||
matched: DeliverablesMatch
|
||||
} & PropsLocale<typeof NS> & Pick<SessionStandardProps, 'sessionId'> & Pick<GlobalStandardProps, 'useSessions'> & InjectFace<DeliverablesInjected>) {
|
||||
} & PropsLocale<typeof NS> & Pick<SessionStandardProps, 'sessionId'> & Pick<GlobalStandardProps, 'useSessions'> & InjectFace<DeliverablesInjected> & PropsRenderSlots<'deliverables.file.actions'>) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const showCodeDiff = useShowCodeDiff(value => value)
|
||||
const cwd = useSessions(state => state.byId[sessionId]?.cwd)
|
||||
@@ -105,7 +105,13 @@ export function Deliverables({
|
||||
phase={states[presentedFileUrl(sessionId, file.seq, file.index)]}
|
||||
host={host === 'error' ? null : host} t={t}
|
||||
onPreview={() => { openFile(file.path) }}
|
||||
onAction={(action) => { void openPresented(sessionId, file.seq, file.index, action) }} />)}
|
||||
actions={renderSlot('deliverables.file.actions', {
|
||||
actionUrl: presentedFileUrl(sessionId, file.seq, file.index),
|
||||
available: host !== null && host !== 'error' && host.available,
|
||||
pending: states[presentedFileUrl(sessionId, file.seq, file.index)] === 'opening'
|
||||
|| states[presentedFileUrl(sessionId, file.seq, file.index)] === 'revealing',
|
||||
onAction: (action, application) => openPresented(sessionId, file.seq, file.index, action, application),
|
||||
})} />)}
|
||||
</div>
|
||||
{collapsible && <button type="button" className={css.toggle}
|
||||
aria-expanded={expanded}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
/** File identity and explicit default-app or file-manager actions for one delivery. */
|
||||
import { useRef, useState } from 'react'
|
||||
/** File identity, Sidebar preview, and contributed native actions for one delivery. */
|
||||
import type { ReactNode } from 'react'
|
||||
import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
import {
|
||||
Menu, FileTypeIcon, fileExtension, IconRightUpOutlineRegular,
|
||||
IconChevronDownOutlineRegular, IconFolderOpenOutlineRegular,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { FileTypeIcon, fileExtension } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { PresentedAction, PresentedHost } from '../presented.ts'
|
||||
import type { PresentedOpenPhase } from './present-open.ts'
|
||||
import type { PresentedHost } from '../presented.ts'
|
||||
import { PRESENTED_SUCCESS_HOLD_MS, PRESENTED_SUCCESS_FADE_MS, type PresentedOpenPhase } from './present-open.ts'
|
||||
import { basename, type PresentedPath } from './turn-deliverables.ts'
|
||||
import type { NS } from './locales.ts'
|
||||
import css from './Deliverables.module.css'
|
||||
@@ -22,25 +19,16 @@ function cardDescription(description: string | undefined, fallback: string): str
|
||||
* @param props - durable file metadata, Sidebar preview, Host capabilities, gesture status, and localized copy.
|
||||
* @returns the file card and its anchored action menu.
|
||||
*/
|
||||
export function PresentedFileCard({ file, cwd, phase, host, onPreview, onAction, t }: {
|
||||
export function PresentedFileCard({ file, cwd, phase, host, onPreview, actions, t }: {
|
||||
file: PresentedPath
|
||||
cwd: string | undefined
|
||||
phase: PresentedOpenPhase | undefined
|
||||
host: PresentedHost | null
|
||||
onPreview: () => void
|
||||
onAction: (action: PresentedAction) => void
|
||||
actions: ReactNode
|
||||
} & PropsLocale<typeof NS>) {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const previewRef = useRef<HTMLButtonElement>(null)
|
||||
const pending = phase === 'opening' || phase === 'revealing'
|
||||
const menuDisabled = pending || host === null || !host.available
|
||||
if (menuDisabled && menuOpen) setMenuOpen(false)
|
||||
const succeeded = phase === 'opened' || phase === 'revealed'
|
||||
const reveal = host?.fileManager ?? 'directory'
|
||||
const act = (action: PresentedAction) => {
|
||||
setMenuOpen(false)
|
||||
previewRef.current?.focus()
|
||||
onAction(action)
|
||||
}
|
||||
const name = basename(file.path)
|
||||
const metadata = fileExtension(name).toUpperCase() || t('presented.file')
|
||||
const status = phase === undefined
|
||||
@@ -55,31 +43,16 @@ export function PresentedFileCard({ file, cwd, phase, host, onPreview, onAction,
|
||||
<div className={css.fileBody}>
|
||||
<div className={css.details}>
|
||||
<span className={css.fileName}>{name}</span>
|
||||
<span className={css.description} role={phase === undefined ? undefined : 'status'}
|
||||
<span className={css.description} data-presented-description role={phase === undefined ? undefined : 'status'}
|
||||
data-error={phase === 'error' || phase === 'revealError' || phase === 'nativeUnavailable' ? true : undefined}>
|
||||
<span className={css.secondaryText}>{status}</span>
|
||||
<span className={css.secondaryText} data-success={succeeded || undefined}
|
||||
style={succeeded ? { animationDelay: `${PRESENTED_SUCCESS_HOLD_MS}ms`, animationDuration: `${PRESENTED_SUCCESS_FADE_MS}ms` } : undefined}>
|
||||
{status}
|
||||
</span>
|
||||
<span className={css.previewHint}>{t('presented.preview')}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={css.split}>
|
||||
<button ref={previewRef} type="button" className={css.open}
|
||||
aria-label={t('presented.previewButton', { name: file.path })}
|
||||
onClick={onPreview}>{t('presented.action')}</button>
|
||||
<Menu className={css.menuAnchor} open={menuOpen && !menuDisabled} autoFocus portal align="end" onClose={() => { setMenuOpen(false) }}
|
||||
anchor={<button type="button" className={css.chevron} disabled={menuDisabled}
|
||||
aria-haspopup="menu" aria-expanded={menuOpen && !menuDisabled}
|
||||
aria-label={t('presented.more', { name: file.path })}
|
||||
onClick={() => { setMenuOpen(value => !value) }}>
|
||||
<IconChevronDownOutlineRegular size={11} />
|
||||
</button>}
|
||||
items={[
|
||||
{ id: 'open', icon: <IconRightUpOutlineRegular size={16} className={css.menuActionIcon} />,
|
||||
label: t('presented.defaultApp') },
|
||||
{ id: 'reveal', icon: <IconFolderOpenOutlineRegular />,
|
||||
label: t(`presented.${reveal}`) },
|
||||
]}
|
||||
onSelect={(id) => { act(id === 'reveal' ? 'reveal' : 'open') }} />
|
||||
</div>
|
||||
<div className={css.actions}>{actions}</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -7,11 +7,11 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ReactNode, UIEvent } from 'react'
|
||||
import {
|
||||
Button, IconChevronDownOutlineRegular, IconCompareSplitOutlineRegular, IconInspectOutlineRegular,
|
||||
IconNowrapFillRegular, IconRightUpOutlineRegular, IconWrapFillRegular, Menu, Tooltip,
|
||||
IconNowrapFillRegular, IconWrapFillRegular, Menu, Tooltip,
|
||||
languageForPath, useCodeHighlighter,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { CodeHighlighter, HighlightSpan } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import { fileAddressFor } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
import type { WorkspaceChangedFile, WorkspaceDiffHunk } from '@deepseek-ai/dsh-workspace-changes/types'
|
||||
@@ -45,7 +45,7 @@ export interface ReviewInjected {
|
||||
|
||||
/** The body's composed props: the tab it draws, its store, its injected face, and its copy. */
|
||||
export type ReviewTabProps = PropsRuntime<'sidebar.right.pane.tab'> & PropsStore<ReturnType<typeof createReviewStore>>
|
||||
& InjectFace<ReviewInjected> & PropsLocale<typeof NS>
|
||||
& InjectFace<ReviewInjected> & PropsLocale<typeof NS> & PropsRenderSlots<'deliverables.review.file.actions'>
|
||||
|
||||
/** One drawn line of a hunk with its line numbers on each side. */
|
||||
export interface DiffRow {
|
||||
@@ -189,7 +189,7 @@ function Counts({ file, t }: { file: WorkspaceChangedFile } & PropsLocale<typeof
|
||||
*/
|
||||
export function ReviewTab({
|
||||
useTabInfo, sessionId, useSessions, useStore, actions, useChangesSummary, useChangesDiff, usePresentedOpen, usePresentedHost,
|
||||
loadChangesSummary, loadChangesDiff, reloadPresentedHost, openChanged, t,
|
||||
loadChangesSummary, loadChangesDiff, reloadPresentedHost, openChanged, t, renderSlot,
|
||||
}: ReviewTabProps): ReactNode {
|
||||
const { tab } = useTabInfo()
|
||||
const { navigation, signal } = tab
|
||||
@@ -266,11 +266,11 @@ export function ReviewTab({
|
||||
<button type="button" className={css.tool} aria-label={t('review.openFileAria', { name: file.display })} data-review-tool="open-file"
|
||||
onClick={() => { tab.actions.openResource(fileAddressFor(sessionId, cwd, file.path)) }}><IconInspectOutlineRegular /></button>
|
||||
</Tooltip>}
|
||||
{file !== undefined && native && <Tooltip label={t(phase === 'error' ? 'diff.openNativeError' : 'diff.openNative')} side="bottom" delayMs={500}>
|
||||
<button type="button" className={css.tool} disabled={phase === 'opening'} data-review-tool="open-native"
|
||||
aria-label={t('diff.openNativeAria', { name: file.display })} data-error={phase === 'error' || undefined}
|
||||
onClick={() => { void openChanged(sessionId, seq, index) }}><IconRightUpOutlineRegular /></button>
|
||||
</Tooltip>}
|
||||
{file !== undefined && renderSlot('deliverables.review.file.actions', {
|
||||
actionUrl: changedFileUrl(sessionId, seq, index), available: native,
|
||||
pending: phase === 'opening' || phase === 'revealing',
|
||||
onAction: (action, application) => openChanged(sessionId, seq, index, action, application),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{summaryState === 'loading' && <p className={css.status} role="status">{t('diff.loading')}</p>}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/** Native file-action contribution shared by delivery cards and change review. */
|
||||
import type { PresentedOpenFailure } from './present-open.ts'
|
||||
import type { PresentedAction } from '../presented.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
/** Open one file through its authorized Session event coordinates. */
|
||||
'deliverables.file.actions': {
|
||||
kind: 'list'
|
||||
scope: 'session'
|
||||
owner: {
|
||||
/** Authenticated document-relative action route carrying Session event coordinates. */
|
||||
readonly actionUrl: string
|
||||
readonly available: boolean
|
||||
readonly pending: boolean
|
||||
/** Execute the selected native action and publish status on the owning file surface. */
|
||||
readonly onAction: (action: PresentedAction, application?: string) => Promise<PresentedOpenFailure>
|
||||
}
|
||||
}
|
||||
/** The same file actions owned by a changed-file review tab. */
|
||||
'deliverables.review.file.actions': {
|
||||
kind: 'list'
|
||||
scope: 'session'
|
||||
owner: SlotMap['deliverables.file.actions']['owner']
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
* cap, and copy — so composing this plugin out of cordis.yml removes every
|
||||
* surface; the owning view renders an empty list and inert prose at zero cost.
|
||||
*/
|
||||
import './file-actions.ts'
|
||||
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-connection/client'
|
||||
@@ -63,13 +64,14 @@ export function apply(ctx: ClientContext): void {
|
||||
name: 'conversation.chat.turnTail',
|
||||
id: '@deepseek-ai/dsh-client-ui-deliverables',
|
||||
locale: NS,
|
||||
children: { 'deliverables.file.actions': { kind: 'list', scope: 'session' } },
|
||||
inject: (): DeliverablesInjected => ({
|
||||
hooks: { presentedOpen: opener.state, presentedHost: opener.host, changesSummary: summaries.state,
|
||||
showCodeDiff: ctx.settingsScope.developerTools.enabled },
|
||||
reloadPresentedHost: () => opener.loadHost(),
|
||||
loadChangesSummary: (sessionId, seq) => summaries.load(sessionId, seq),
|
||||
openPresented: (sessionId, seq, index, action) => opener.open(sessionId, seq, index, action),
|
||||
openChanged: (sessionId, seq, index) => opener.openChanged(sessionId, seq, index),
|
||||
openPresented: (sessionId, seq, index, action, application) => opener.open(sessionId, seq, index, action, application),
|
||||
openChanged: (sessionId, seq, index, action, application) => opener.openChanged(sessionId, seq, index, action, application),
|
||||
openChangesReview: (coordinates, index) => {
|
||||
ctx.sidebarRight.openResource(changesReviewAddress(coordinates), { params: { index } })
|
||||
},
|
||||
@@ -84,12 +86,13 @@ export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register(
|
||||
{
|
||||
name: 'sidebar.right.pane.tab', key: CHANGES_REVIEW_ID, locale: NS, store: createReviewStore(),
|
||||
children: { 'deliverables.review.file.actions': { kind: 'list', scope: 'session' } },
|
||||
inject: (): ReviewInjected => ({
|
||||
hooks: { changesSummary: summaries.state, changesDiff: diffs.state, presentedOpen: opener.state, presentedHost: opener.host },
|
||||
loadChangesSummary: (sessionId, seq) => summaries.load(sessionId, seq),
|
||||
loadChangesDiff: (sessionId, seq, index) => diffs.load(sessionId, seq, index),
|
||||
reloadPresentedHost: () => opener.loadHost(),
|
||||
openChanged: (sessionId, seq, index) => opener.openChanged(sessionId, seq, index),
|
||||
openChanged: (sessionId, seq, index, action, application) => opener.openChanged(sessionId, seq, index, action, application),
|
||||
}),
|
||||
},
|
||||
ReviewTab,
|
||||
|
||||
@@ -15,12 +15,6 @@ export const zh = {
|
||||
'presented.unavailable': '此主机没有可用的桌面,无法使用外部程序打开文件或文件夹;文件仍可在侧边栏预览',
|
||||
'presented.retry': '重试',
|
||||
'presented.hostError': '无法读取主机桌面信息',
|
||||
'presented.directory': '打开所在文件夹',
|
||||
'presented.explorer': '在文件资源管理器中显示',
|
||||
'presented.finder': '在 Finder 中显示',
|
||||
'presented.defaultApp': '用默认应用打开',
|
||||
'presented.more': '{name} 的更多文件操作',
|
||||
'presented.action': '打开',
|
||||
'presented.preview': '在侧边栏预览',
|
||||
'presented.previewButton': '在侧边栏打开 {name}',
|
||||
'presented.previewCard': '在侧边栏预览 {name}',
|
||||
@@ -29,7 +23,7 @@ export const zh = {
|
||||
'presented.collapse': '收起',
|
||||
'presented.collapseAria': '收起交付文件列表',
|
||||
'presented.opening': '正在打开…',
|
||||
'presented.opened': '已在默认程序中打开',
|
||||
'presented.opened': '已请求打开',
|
||||
'presented.error': '打开失败,点击重试',
|
||||
'presented.file': '文件',
|
||||
'row.title': '交付文件',
|
||||
@@ -59,7 +53,6 @@ export const zh = {
|
||||
'review.wrapAria': '自动换行',
|
||||
'review.openFile': '在侧边栏打开整个文件',
|
||||
'review.openFileAria': '在侧边栏打开 {name}',
|
||||
'diff.openNativeAria': '用默认应用打开 {name}',
|
||||
'diff.loading': '正在读取改动…',
|
||||
'diff.missing': '这轮改动的内容已不可用',
|
||||
'diff.error': '无法读取改动',
|
||||
@@ -70,8 +63,6 @@ export const zh = {
|
||||
'diff.unchanged': '两侧内容相同',
|
||||
'diff.coarse': '逐行对比超时,按整个文件替换显示',
|
||||
'diff.truncated': '只显示前 {count} 行',
|
||||
'diff.openNative': '用默认应用打开',
|
||||
'diff.openNativeError': '打开失败,点击重试',
|
||||
}
|
||||
|
||||
/** English dictionary (same key set). */
|
||||
@@ -86,12 +77,6 @@ export const en: Record<DeliverablesKey, string> = {
|
||||
'presented.unavailable': 'This Host has no desktop available to open files or folders in external apps. Files can still be previewed in the sidebar.',
|
||||
'presented.retry': 'Retry',
|
||||
'presented.hostError': 'Could not read the Host desktop information',
|
||||
'presented.directory': 'Open containing folder',
|
||||
'presented.explorer': 'Show in File Explorer',
|
||||
'presented.finder': 'Show in Finder',
|
||||
'presented.defaultApp': 'Open in default app',
|
||||
'presented.more': 'More file actions for {name}',
|
||||
'presented.action': 'Open',
|
||||
'presented.preview': 'Preview in sidebar',
|
||||
'presented.previewButton': 'Open {name} in sidebar',
|
||||
'presented.previewCard': 'Preview {name} in sidebar',
|
||||
@@ -100,7 +85,7 @@ export const en: Record<DeliverablesKey, string> = {
|
||||
'presented.collapse': 'Collapse',
|
||||
'presented.collapseAria': 'Collapse delivered files',
|
||||
'presented.opening': 'Opening…',
|
||||
'presented.opened': 'Opened in default app',
|
||||
'presented.opened': 'Open requested',
|
||||
'presented.error': 'Could not open. Click to retry.',
|
||||
'presented.file': 'File',
|
||||
'row.title': 'Present files',
|
||||
@@ -130,7 +115,6 @@ export const en: Record<DeliverablesKey, string> = {
|
||||
'review.wrapAria': 'Line wrap',
|
||||
'review.openFile': 'Open the whole file in the sidebar',
|
||||
'review.openFileAria': 'Open {name} in sidebar',
|
||||
'diff.openNativeAria': 'Open {name} in default app',
|
||||
'diff.loading': 'Reading changes…',
|
||||
'diff.missing': 'The contents of this turn’s changes are no longer available',
|
||||
'diff.error': 'Could not read the changes',
|
||||
@@ -141,8 +125,6 @@ export const en: Record<DeliverablesKey, string> = {
|
||||
'diff.unchanged': 'Both sides hold the same lines',
|
||||
'diff.coarse': 'Line comparison timed out; shown as a whole-file replacement',
|
||||
'diff.truncated': 'Showing the first {count} lines',
|
||||
'diff.openNative': 'Open in default app',
|
||||
'diff.openNativeError': 'Could not open. Click to retry.',
|
||||
}
|
||||
|
||||
/** Union of this namespace's dictionary keys. */
|
||||
|
||||
@@ -4,7 +4,15 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { changedFileUrl } from '../changes.ts'
|
||||
import { presentedFileUrl, PRESENT_HOST_ROUTE, isPresentedHost, type PresentedAction, type PresentedHost } from '../presented.ts'
|
||||
|
||||
/** State of the latest explicit open gesture for one saved file. */
|
||||
/** Success feedback remains fully visible for five seconds before fading. */
|
||||
export const PRESENTED_SUCCESS_HOLD_MS = 5000
|
||||
/** Fade interval shared by the card animation and status expiry. */
|
||||
export const PRESENTED_SUCCESS_FADE_MS = 200
|
||||
|
||||
/** Failure feedback for the shared native opening control. */
|
||||
export type PresentedOpenFailure = 'openError' | 'revealError' | null
|
||||
|
||||
/** State published on the owning file card. */
|
||||
export type PresentedOpenPhase = 'opening' | 'opened' | 'revealing' | 'revealed' | 'error' | 'revealError' | 'nativeUnavailable'
|
||||
|
||||
/** One browser plugin's file-open requests, cancelled when that plugin is disposed. */
|
||||
@@ -13,10 +21,11 @@ export class PresentedOpenController {
|
||||
readonly state = createSnapshotStore<Record<string, PresentedOpenPhase | undefined>>({})
|
||||
/** Native destination metadata, or a retryable read failure. */
|
||||
readonly host = createSnapshotStore<PresentedHost | 'error' | null>(null)
|
||||
private readonly expiry = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
private loading: Promise<void> | undefined
|
||||
private metadata = new AbortController()
|
||||
private readonly lifetime = new AbortController()
|
||||
private readonly pending = new Set<Promise<void>>()
|
||||
private readonly pending = new Set<Promise<void | PresentedOpenFailure>>()
|
||||
|
||||
/**
|
||||
* Open a declared file once while a request for the same coordinates is pending.
|
||||
@@ -25,10 +34,13 @@ export class PresentedOpenController {
|
||||
* @param seq - durable delivery event sequence.
|
||||
* @param index - original file index within that event.
|
||||
* @param action - default application open or file-manager reveal.
|
||||
* @returns after the Host acknowledges opening or the error state is published.
|
||||
* @param application - registered handler identifier for an explicit application choice.
|
||||
* @returns null after a successful handoff, or the failure to announce after publishing card status.
|
||||
*/
|
||||
open(sessionId: SessionId, seq: number, index: number, action: PresentedAction = 'open'): Promise<void> {
|
||||
return this.openUrl(presentedFileUrl(sessionId, seq, index), action)
|
||||
open(
|
||||
sessionId: SessionId, seq: number, index: number, action: PresentedAction = 'open', application?: string,
|
||||
): Promise<PresentedOpenFailure> {
|
||||
return this.openUrl(presentedFileUrl(sessionId, seq, index), action, application)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,20 +48,25 @@ export class PresentedOpenController {
|
||||
* @param sessionId - viewed Session.
|
||||
* @param seq - durable workspace/changes event sequence.
|
||||
* @param index - original file index within that event.
|
||||
* @returns after the Host acknowledges opening or the error state is published.
|
||||
* @param action - application open or file-manager reveal.
|
||||
* @param application - registered handler identifier for an explicit application choice.
|
||||
* @returns null after a successful handoff, or the failure to announce after publishing card status.
|
||||
*/
|
||||
openChanged(sessionId: SessionId, seq: number, index: number): Promise<void> {
|
||||
return this.openUrl(changedFileUrl(sessionId, seq, index), 'open')
|
||||
openChanged(
|
||||
sessionId: SessionId, seq: number, index: number, action: PresentedAction = 'open', application?: string,
|
||||
): Promise<PresentedOpenFailure> {
|
||||
return this.openUrl(changedFileUrl(sessionId, seq, index), action, application)
|
||||
}
|
||||
|
||||
private async openUrl(url: string, action: PresentedAction): Promise<void> {
|
||||
private async openUrl(url: string, action: PresentedAction, application?: string): Promise<PresentedOpenFailure> {
|
||||
const phase = this.state.getSnapshot()[url]
|
||||
if (this.lifetime.signal.aborted || phase === 'opening' || phase === 'revealing') return
|
||||
if (this.lifetime.signal.aborted || phase === 'opening' || phase === 'revealing') return null
|
||||
this.clearExpiry(url)
|
||||
this.state.update((state) => { state[url] = action === 'open' ? 'opening' : 'revealing' })
|
||||
const task = this.request(url, action)
|
||||
const task = this.request(url, action, application)
|
||||
this.pending.add(task)
|
||||
try {
|
||||
await task
|
||||
return await task
|
||||
} finally {
|
||||
this.pending.delete(task)
|
||||
}
|
||||
@@ -100,19 +117,36 @@ export class PresentedOpenController {
|
||||
/** Cancel outstanding requests and wait until no request can publish state. */
|
||||
async dispose(): Promise<void> {
|
||||
this.lifetime.abort()
|
||||
for (const url of this.expiry.keys()) this.clearExpiry(url)
|
||||
await Promise.all(this.pending)
|
||||
}
|
||||
|
||||
private async request(url: string, action: PresentedAction): Promise<void> {
|
||||
private clearExpiry(url: string): void {
|
||||
clearTimeout(this.expiry.get(url))
|
||||
this.expiry.delete(url)
|
||||
}
|
||||
|
||||
private async request(url: string, action: PresentedAction, application?: string): Promise<PresentedOpenFailure> {
|
||||
const failure = action === 'open' ? 'error' : 'revealError'
|
||||
let phase: PresentedOpenPhase = action === 'open' ? 'opened' : 'revealed'
|
||||
try {
|
||||
const response = await fetch(action === 'open' ? url : `${url}&action=reveal`, { method: 'POST', signal: this.lifetime.signal })
|
||||
const target = action === 'reveal' ? `${url}&action=reveal`
|
||||
: application === undefined ? url : `${url}&application=${encodeURIComponent(application)}`
|
||||
const response = await fetch(target, { method: 'POST', signal: this.lifetime.signal })
|
||||
if (!response.ok) phase = response.status === 422 ? 'nativeUnavailable' : failure
|
||||
} catch {
|
||||
// Transport failures share the retryable card state with Host open failures.
|
||||
phase = failure
|
||||
}
|
||||
if (!this.lifetime.signal.aborted) this.state.update((state) => { state[url] = phase })
|
||||
if (!this.lifetime.signal.aborted) {
|
||||
if (phase === 'opened' || phase === 'revealed') {
|
||||
this.expiry.set(url, setTimeout(() => {
|
||||
this.expiry.delete(url)
|
||||
this.state.update((state) => { Reflect.deleteProperty(state, url) })
|
||||
}, PRESENTED_SUCCESS_HOLD_MS + PRESENTED_SUCCESS_FADE_MS))
|
||||
}
|
||||
this.state.update((state) => { state[url] = phase })
|
||||
}
|
||||
return phase === 'opened' || phase === 'revealed' ? null : action === 'reveal' ? 'revealError' : 'openError'
|
||||
}
|
||||
}
|
||||
@@ -34,12 +34,12 @@ export function registerPresentOpen(ctx: Context): void {
|
||||
await Promise.allSettled(pending)
|
||||
})
|
||||
const routes = [
|
||||
[PRESENT_OPEN_PATH, 'POST', handlePresentOpen], [CHANGES_OPEN_PATH, 'POST', handleChangesOpen], [CHANGES_DIFF_PATH, 'GET', handleChangesDiff],
|
||||
[PRESENT_OPEN_PATH, ['GET', 'POST'], handlePresentOpen], [CHANGES_OPEN_PATH, ['GET', 'POST'], handleChangesOpen], [CHANGES_DIFF_PATH, ['GET'], handleChangesDiff],
|
||||
] as const
|
||||
for (const [path, method, handler] of routes) {
|
||||
for (const [path, methods, handler] of routes) {
|
||||
ctx.connection.fetch.register({
|
||||
path,
|
||||
methods: [method],
|
||||
methods: [...methods],
|
||||
requestBody: 'buffered',
|
||||
fetch: (request) => {
|
||||
const task = handler(ctx, new Request(request, {
|
||||
@@ -91,7 +91,13 @@ async function openVerified(ctx: Context, request: Request, path: string, action
|
||||
return new Response('Path has no verified Host path.', { status: 422 })
|
||||
}
|
||||
request.signal.throwIfAborted()
|
||||
await ctx.sessionController.openWorkspacePath({ path, ...(action === 'reveal' ? { action } : {}) }, request.signal)
|
||||
if (request.method === 'GET') {
|
||||
return Response.json(await ctx.sessionController.workspacePathApplications({ path }, request.signal),
|
||||
{ headers: { 'cache-control': 'no-store' } })
|
||||
}
|
||||
const application = new URL(request.url).searchParams.get('application')
|
||||
await ctx.sessionController.openWorkspacePath({ path, ...(action === 'reveal' ? { action }
|
||||
: application === null ? {} : { application }) }, request.signal)
|
||||
return new Response(null, { status: 204, headers: { 'cache-control': 'no-store' } })
|
||||
}
|
||||
|
||||
@@ -159,6 +165,8 @@ async function handleChangesDiff(ctx: Context, request: Request): Promise<Respon
|
||||
}
|
||||
|
||||
async function handleChangesOpen(ctx: Context, request: Request): Promise<Response> {
|
||||
const action = new URL(request.url).searchParams.get('action') ?? 'open'
|
||||
if (action !== 'open' && action !== 'reveal') return new Response('Invalid file action.', { status: 400 })
|
||||
const coordinates = changedFileCoordinates(request)
|
||||
if (coordinates instanceof Response) return coordinates
|
||||
const { id, seq, index } = coordinates
|
||||
@@ -171,7 +179,7 @@ async function handleChangesOpen(ctx: Context, request: Request): Promise<Respon
|
||||
const file = changes.files[index]
|
||||
if (file === undefined) return new Response('Changed file not found in this summary.', { status: 404 })
|
||||
const { absolutePath: path } = await ctx.workspaceFiles.stat({ sessionId: id, workspaceRoot }, file.path, request.signal)
|
||||
return await openVerified(ctx, request, path, 'open')
|
||||
return await openVerified(ctx, request, path, action)
|
||||
} catch (error: unknown) {
|
||||
request.signal.throwIfAborted()
|
||||
return new Response('Changed file unavailable.', { status: failureStatus(error) })
|
||||
|
||||
@@ -61,7 +61,8 @@ async function fixture() {
|
||||
sessionId === 'owner' && seq === 9 && index === 0 ? comparison : undefined)
|
||||
ctx.provide('workspaceChanges', { summary, diff })
|
||||
const opener = vi.fn(async (_request: { path: string; action?: 'reveal' }, _signal: AbortSignal) => ({ opened: true as const }))
|
||||
ctx.provide('sessionController', { openWorkspacePath: opener, workspaceDesktop: () => ({ name: 'desktop', available: true, fileManager: 'finder' }) } as never)
|
||||
const applications = vi.fn(async () => [{ id: 'player', name: 'Player', default: true, icon: null }])
|
||||
ctx.provide('sessionController', { workspacePathApplications: applications, openWorkspacePath: opener, workspaceDesktop: () => ({ name: 'desktop', available: true, fileManager: 'finder' }) } as never)
|
||||
const connection = new HostConnectionService(ctx, [], {} as BrowserAuth)
|
||||
await ctx.plugin({
|
||||
inject: ['connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs', 'sandboxPolicy', 'workspaceChanges'],
|
||||
@@ -71,7 +72,7 @@ async function fixture() {
|
||||
const open = (query = '?sessionId=owner&seq=9&index=0') => handler.fetch(new Request(`http://localhost${CHANGES_OPEN_PATH}${query}`, { method: 'POST' }))
|
||||
const read = (query = '?sessionId=owner&seq=9') => handler.fetch(new Request(`http://localhost${CHANGED_FILES_PATH}${query}`))
|
||||
const compare = (query = '?sessionId=owner&seq=9&index=0') => handler.fetch(new Request(`http://localhost${CHANGES_DIFF_PATH}${query}`))
|
||||
return { root, cwd, ctx, data, readEvent, open, read, compare, comparison, diff, opener, outside, summary }
|
||||
return { handler, applications, root, cwd, ctx, data, readEvent, open, read, compare, comparison, diff, opener, outside, summary }
|
||||
}
|
||||
|
||||
describe('change summary route', () => {
|
||||
@@ -199,3 +200,26 @@ describe('changed files native open route', () => {
|
||||
expect(isChangesEvent(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
it('queries handlers only after file authorization and forwards explicit application choices', async () => {
|
||||
const { handler, applications, opener, open, ctx } = await fixture()
|
||||
const url = 'http://localhost/api/changes.open?sessionId=owner&seq=9&index=0'
|
||||
const response = await handler.fetch(new Request(url))
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual([{ id: 'player', name: 'Player', default: true, icon: null }])
|
||||
expect(opener).not.toHaveBeenCalled()
|
||||
expect((await open('?sessionId=owner&seq=9&index=0&application=player')).status).toBe(204)
|
||||
expect(opener.mock.lastCall?.[0]).toMatchObject({ application: 'player' })
|
||||
expect((await handler.fetch(new Request(url.replace('index=0', 'index=999')))).status).toBe(404)
|
||||
vi.spyOn(ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined)
|
||||
expect((await handler.fetch(new Request(url))).status).toBe(422)
|
||||
expect(applications).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
|
||||
it('rejects an unsupported changed-file action before invoking the desktop', async () => {
|
||||
const { open, opener } = await fixture()
|
||||
expect((await open('?sessionId=owner&seq=9&index=0&action=remove')).status).toBe(400)
|
||||
expect(opener).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -6,6 +6,7 @@
|
||||
* registrations' fiber-teardown removal (HMR safety) against the real
|
||||
* SlotRegistry.
|
||||
*/
|
||||
import { renderFileActions } from './file-actions.tsx'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { cleanup, fireEvent, render, within } from '@testing-library/react'
|
||||
@@ -41,6 +42,8 @@ function openProps(controller = new PresentedOpenController(), summaries = new C
|
||||
controller.host.set({ name: 'desktop', available: true, fileManager: 'finder' })
|
||||
const sessions: SessionListState = { ids: [], byId: {}, phase: 'ready', projectionsBySession: {}, jobsBySession: {} }
|
||||
return {
|
||||
SessionProvider: ({ children }: { children?: import('react').ReactNode }) => <>{children}</>,
|
||||
renderSlot: renderFileActions,
|
||||
useShowCodeDiff: <T,>(select: (value: boolean) => T): T => select(true),
|
||||
useSessions: <T,>(select: (state: SessionListState) => T): T => select(sessions),
|
||||
reloadPresentedHost: vi.fn(() => controller.loadHost()),
|
||||
@@ -494,7 +497,7 @@ describe('ChangedFiles card', () => {
|
||||
controller = new PresentedOpenController(), locale = en, matched = { changes, presented: [] as never[] }, summaries = servedStore(),
|
||||
) {
|
||||
const props = openProps(controller, summaries)
|
||||
props.openChanged.mockResolvedValue(undefined)
|
||||
props.openChanged.mockResolvedValue(null)
|
||||
const openFile = vi.fn<(path: string) => void>()
|
||||
const view = render(<Deliverables {...props} matched={matched} openFile={openFile} sessionId={SessionId('child-session')} t={makeTranslate(locale)} />)
|
||||
return { props, openFile, view }
|
||||
@@ -845,7 +848,7 @@ describe('presented files', () => {
|
||||
const owner = tailOwner(deliverablesOf(value), 3, preview)
|
||||
const matched = selectDeliverables(owner)!
|
||||
const props = openProps()
|
||||
props.openPresented.mockResolvedValue(undefined)
|
||||
props.openPresented.mockResolvedValue(null)
|
||||
const view = render(<Deliverables {...props} matched={matched} openFile={owner.openFile} sessionId={SessionId('child-session')} t={makeTranslate(en)} />)
|
||||
expect(view.container.querySelectorAll('[data-presented-file]')).toHaveLength(4)
|
||||
const expand = view.getByRole('button', { name: 'Show all 8 delivered files' })
|
||||
@@ -855,12 +858,10 @@ describe('presented files', () => {
|
||||
expect(view.getByRole('button', { name: 'Collapse delivered files' }).getAttribute('aria-expanded')).toBe('true')
|
||||
expect(view.queryByRole('link')).toBeNull()
|
||||
fireEvent.click(view.getByRole('button', { name: 'Preview report-0.docx in sidebar' }))
|
||||
fireEvent.click(view.getByRole('button', { name: 'Open report-0.docx in sidebar' }))
|
||||
expect(preview).toHaveBeenCalledTimes(2)
|
||||
expect(preview).toHaveBeenCalledTimes(1)
|
||||
expect(preview).toHaveBeenLastCalledWith('report-0.docx')
|
||||
fireEvent.click(view.getByRole('button', { name: 'More file actions for report-0.docx' }))
|
||||
fireEvent.click(view.getByRole('menuitem', { name: 'Open in default app' }))
|
||||
expect(props.openPresented).toHaveBeenCalledWith('child-session', 2, 0, 'open')
|
||||
fireEvent.click(view.getAllByRole('button', { name: 'Native file action' })[0]!)
|
||||
expect(props.openPresented).toHaveBeenCalledWith('child-session', 2, 0, 'open', undefined)
|
||||
fireEvent.click(view.getByRole('button', { name: 'Collapse delivered files' }))
|
||||
expect(view.container.querySelectorAll('[data-presented-file]')).toHaveLength(4)
|
||||
expect(view.container.querySelector('[data-changed-files]')).toBeNull()
|
||||
@@ -934,7 +935,7 @@ it.each(['opening', 'opened', 'error'] as const)('shows the %s state and permits
|
||||
{ path: 'report.txt', seq: 2, index: 0 },
|
||||
] }} openFile={() => {}} sessionId={SessionId('session')} t={makeTranslate(en)} />)
|
||||
expect(view.getByText(en[`presented.${phase}`])).toBeTruthy()
|
||||
expect((view.getByRole('button', { name: 'More file actions for report.txt' }) as HTMLButtonElement).disabled).toBe(phase === 'opening')
|
||||
expect((view.getByRole('button', { name: 'Native file action' }) as HTMLButtonElement).disabled).toBe(phase === 'opening')
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Capture delivery action ownership without duplicating the opening-control implementation. */
|
||||
import type { PropsRenderSlots, SlotMap } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {} from '../src/client/index.ts'
|
||||
|
||||
/** Slot renderer exposing the owning route and gesture for component tests. */
|
||||
export const renderFileActions: PropsRenderSlots<'deliverables.file.actions' | 'deliverables.review.file.actions'>['renderSlot'] = (_name: string, input: object) => {
|
||||
const props = input as SlotMap['deliverables.file.actions']['owner']
|
||||
return props.available ? <button type="button" data-action-url={props.actionUrl} disabled={props.pending}
|
||||
onClick={() => { void props.onAction('open') }}>Native file action</button> : null
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { PresentedOpenController } from '../src/client/present-open.ts'
|
||||
|
||||
afterEach(() => { vi.unstubAllGlobals() })
|
||||
afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers() })
|
||||
|
||||
const id = SessionId('fork')
|
||||
const url = 'api/present.open?sessionId=fork&seq=2&index=1'
|
||||
@@ -174,3 +174,68 @@ it.each(['open', 'reveal'] as const)('reports an unavailable Host path for %s wh
|
||||
expect(controller.state.getSnapshot()[url]).toBe('nativeUnavailable')
|
||||
await controller.dispose()
|
||||
})
|
||||
|
||||
|
||||
it('encodes an explicit application identifier without changing the file coordinates', async () => {
|
||||
const fetcher = vi.fn(async () => new Response(null, { status: 204 }))
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
const controller = new PresentedOpenController()
|
||||
expect(await controller.open(id, 2, 1, 'open', '/Apps/A&B.app')).toBeNull()
|
||||
expect(fetcher).toHaveBeenCalledWith(`${url}&application=%2FApps%2FA%26B.app`, { method: 'POST', signal: expect.any(AbortSignal) as AbortSignal })
|
||||
await controller.dispose()
|
||||
})
|
||||
|
||||
|
||||
it.each(['open', 'reveal'] as const)('expires successful %s feedback after five seconds and its fade', async (action) => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 204 })))
|
||||
const controller = new PresentedOpenController()
|
||||
await controller.open(id, 2, 1, action)
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
expect(controller.state.getSnapshot()[url]).toBe(action === 'open' ? 'opened' : 'revealed')
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(controller.state.getSnapshot()[url]).toBeUndefined()
|
||||
await controller.dispose()
|
||||
})
|
||||
|
||||
it('cancels an earlier success expiry when the next action fails', async () => {
|
||||
vi.useFakeTimers()
|
||||
const fetcher = vi.fn().mockResolvedValueOnce(new Response(null, { status: 204 }))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 500 }))
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
const controller = new PresentedOpenController()
|
||||
await controller.open(id, 2, 1)
|
||||
await vi.advanceTimersByTimeAsync(4000)
|
||||
await controller.open(id, 2, 1)
|
||||
await vi.advanceTimersByTimeAsync(6000)
|
||||
expect(controller.state.getSnapshot()[url]).toBe('error')
|
||||
await controller.dispose()
|
||||
})
|
||||
|
||||
it('cancels success expiry when its controller is disposed', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 204 })))
|
||||
const controller = new PresentedOpenController()
|
||||
await controller.open(id, 2, 1)
|
||||
const state = controller.state.getSnapshot()
|
||||
await controller.dispose()
|
||||
await vi.advanceTimersByTimeAsync(6000)
|
||||
expect(controller.state.getSnapshot()).toBe(state)
|
||||
})
|
||||
|
||||
|
||||
it('owns the expiry before notifying subscribers that can dispose the controller', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 204 })))
|
||||
const controller = new PresentedOpenController()
|
||||
let disposal: Promise<void> | undefined
|
||||
const release = controller.state.subscribe(() => {
|
||||
if (controller.state.getSnapshot()[url] === 'opened') disposal = controller.dispose()
|
||||
})
|
||||
await controller.open(id, 2, 1)
|
||||
await disposal
|
||||
const state = controller.state.getSnapshot()
|
||||
await vi.advanceTimersByTimeAsync(6000)
|
||||
expect(controller.state.getSnapshot()).toBe(state)
|
||||
release()
|
||||
})
|
||||
@@ -46,7 +46,8 @@ async function fixture() {
|
||||
})
|
||||
ctx.provide('sessionQuery', { readEvent } as never)
|
||||
const opener = vi.fn(async (_request: { path: string; action?: 'reveal' }, _signal: AbortSignal) => ({ opened: true as const }))
|
||||
ctx.provide('sessionController', { resolveAgent, openWorkspacePath: opener, workspaceDesktop: () => ({ name: 'desktop', available: true, fileManager: 'finder' }) } as never)
|
||||
const applications = vi.fn(async () => [{ id: 'player', name: 'Player', default: true, icon: null }])
|
||||
ctx.provide('sessionController', { workspacePathApplications: applications, resolveAgent, openWorkspacePath: opener, workspaceDesktop: () => ({ name: 'desktop', available: true, fileManager: 'finder' }) } as never)
|
||||
const connection = new HostConnectionService(ctx, [], {} as BrowserAuth)
|
||||
const fiber = ctx.plugin({ inject: ['connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs', 'sandboxPolicy'], apply: registerPresentOpen })
|
||||
await fiber
|
||||
@@ -54,7 +55,7 @@ async function fixture() {
|
||||
const open = (query = '?sessionId=owner&seq=7&index=0', signal?: AbortSignal) => handler.fetch(new Request(
|
||||
`http://localhost${PRESENT_OPEN_PATH}${query}`, { method: 'POST', signal: signal ?? null },
|
||||
))
|
||||
return { root, cwd, ctx, fiber, file, session, readEvent, open, opener, handler, resolveAgent }
|
||||
return { applications, root, cwd, ctx, fiber, file, session, readEvent, open, opener, handler, resolveAgent }
|
||||
}
|
||||
|
||||
describe('Presented workspace file native open route', () => {
|
||||
@@ -62,7 +63,7 @@ describe('Presented workspace file native open route', () => {
|
||||
const { cwd, open, file, fiber, opener, handler, ctx } = await fixture()
|
||||
const source = await realpath(join(cwd, file.path))
|
||||
expect(presentedFileUrl(SessionId('owner'), 7, 0)).toBe('api/present.open?sessionId=owner&seq=7&index=0')
|
||||
expect((await handler.fetch(new Request(`http://localhost${PRESENT_OPEN_PATH}`))).status).toBe(404)
|
||||
expect((await handler.fetch(new Request(`http://localhost${PRESENT_OPEN_PATH}`))).status).toBe(400)
|
||||
expect((await handler.fetch(new Request('http://localhost/api/present.download?sessionId=owner&seq=7&index=0'))).status).toBe(404)
|
||||
for (const contents of ['current source', 'edited source']) {
|
||||
await writeFile(source, contents)
|
||||
@@ -233,3 +234,19 @@ it('uses the deployment workspace root when the viewed Session has no cwd', asyn
|
||||
expect((await open()).status).toBe(204)
|
||||
expect(opener.mock.lastCall?.[0].path).toBe(await realpath(join(cwd, file.path)))
|
||||
})
|
||||
|
||||
|
||||
it('queries handlers only after file authorization and forwards explicit application choices', async () => {
|
||||
const { handler, applications, opener, open, ctx } = await fixture()
|
||||
const url = 'http://localhost/api/present.open?sessionId=owner&seq=7&index=0'
|
||||
const response = await handler.fetch(new Request(url))
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual([{ id: 'player', name: 'Player', default: true, icon: null }])
|
||||
expect(opener).not.toHaveBeenCalled()
|
||||
expect((await open('?sessionId=owner&seq=7&index=0&application=player')).status).toBe(204)
|
||||
expect(opener.mock.lastCall?.[0]).toMatchObject({ application: 'player' })
|
||||
expect((await handler.fetch(new Request(url.replace('index=0', 'index=999')))).status).toBe(404)
|
||||
vi.spyOn(ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined)
|
||||
expect((await handler.fetch(new Request(url))).status).toBe(422)
|
||||
expect(applications).toHaveBeenCalledOnce()
|
||||
})
|
||||
@@ -13,66 +13,9 @@ const props = () => ({
|
||||
host: { name: 'remote-desktop', available: true, fileManager: 'finder' as const },
|
||||
phase: undefined,
|
||||
onPreview: vi.fn(),
|
||||
onAction: vi.fn(),
|
||||
actions: <button type="button">Native file action</button>,
|
||||
t: makeTranslate(en),
|
||||
})
|
||||
|
||||
it.each([
|
||||
['finder', 'Show in Finder'], ['explorer', 'Show in File Explorer'], ['directory', 'Open containing folder'],
|
||||
] as const)('uses the Host %s action and closes the menu after selection', (fileManager, label) => {
|
||||
const p = props()
|
||||
const view = render(<PresentedFileCard {...p} host={{ ...p.host, fileManager }} />)
|
||||
fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' }))
|
||||
fireEvent.click(view.getByRole('menuitem', { name: new RegExp(label) }))
|
||||
expect(p.onAction).toHaveBeenCalledWith('reveal')
|
||||
expect(view.queryByRole('menu')).toBeNull()
|
||||
fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' }))
|
||||
fireEvent.click(view.getByRole('menuitem', { name: /Open in default app/ }))
|
||||
expect(p.onAction).toHaveBeenLastCalledWith('open')
|
||||
expect(p.onAction).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('dismisses the menu with Escape or an outside click without launching anything', () => {
|
||||
const p = props()
|
||||
const view = render(<PresentedFileCard {...p} />)
|
||||
const trigger = view.getByRole('button', { name: 'More file actions for out/report.pdf' })
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(view.queryByRole('menu')).toBeNull()
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.pointerDown(document.body)
|
||||
expect(view.queryByRole('menu')).toBeNull()
|
||||
expect(p.onAction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['opening', 'revealing'] as const)('keeps sidebar previews available while the native action is %s', (phase) => {
|
||||
const p = props()
|
||||
const view = render(<PresentedFileCard {...p} phase={phase} />)
|
||||
expect((view.getByRole('button', { name: 'More file actions for out/report.pdf' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
fireEvent.click(view.getByRole('button', { name: 'Preview out/report.pdf in sidebar' }))
|
||||
fireEvent.click(view.getByRole('button', { name: 'Open out/report.pdf in sidebar' }))
|
||||
expect(p.onPreview).toHaveBeenCalledTimes(2)
|
||||
expect(p.onAction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the native menu disabled until a desktop is available', () => {
|
||||
const p = props()
|
||||
const view = render(<PresentedFileCard {...p} host={null} />)
|
||||
expect((view.getByRole('button', { name: 'More file actions for out/report.pdf' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect((view.getByRole('button', { name: 'Open out/report.pdf in sidebar' }) as HTMLButtonElement).disabled).toBe(false)
|
||||
view.rerender(<PresentedFileCard {...p} host={{ ...p.host, available: false, fileManager: null }} />)
|
||||
expect((view.getByRole('button', { name: 'More file actions for out/report.pdf' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('opens the right sidebar from either the card or its primary button', () => {
|
||||
const p = props()
|
||||
const view = render(<PresentedFileCard {...p} />)
|
||||
fireEvent.click(view.getByRole('button', { name: 'Preview out/report.pdf in sidebar' }))
|
||||
fireEvent.click(view.getByRole('button', { name: 'Open out/report.pdf in sidebar' }))
|
||||
expect(p.onPreview).toHaveBeenCalledTimes(2)
|
||||
expect(p.onAction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('localizes reveal failures and accurately reports a directory-only action', () => {
|
||||
const p = props()
|
||||
const view = render(<PresentedFileCard {...p} phase="revealError" t={makeTranslate(zh)} />)
|
||||
@@ -84,36 +27,12 @@ it('localizes reveal failures and accurately reports a directory-only action', (
|
||||
})
|
||||
|
||||
|
||||
it('supports keyboard selection and returns focus to the trigger on Escape', () => {
|
||||
const view = render(<PresentedFileCard {...props()} />)
|
||||
const trigger = view.getByRole('button', { name: 'More file actions for out/report.pdf' })
|
||||
fireEvent.click(trigger)
|
||||
const items = view.getAllByRole('menuitem')
|
||||
expect(document.activeElement).toBe(items[0])
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' })
|
||||
expect(document.activeElement).toBe(items[1])
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' })
|
||||
expect(document.activeElement).toBe(items[0])
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'End' })
|
||||
expect(document.activeElement).toBe(items[1])
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'ArrowUp' })
|
||||
expect(document.activeElement).toBe(items[0])
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'Home' })
|
||||
expect(document.activeElement).toBe(items[0])
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
|
||||
expect(document.activeElement).toBe(trigger)
|
||||
})
|
||||
|
||||
|
||||
it('shows the basename while retaining the full location for hover and actions', () => {
|
||||
const p = props()
|
||||
const path = '/work/reports/result.pdf'
|
||||
const view = render(<PresentedFileCard {...p} cwd="/work" file={{ ...p.file, path }} />)
|
||||
expect(view.getByTitle(path)).toBeTruthy()
|
||||
expect(view.getByText('result.pdf')).toBeTruthy()
|
||||
fireEvent.click(view.getByRole('button', { name: `More file actions for ${path}` }))
|
||||
fireEvent.click(view.getByRole('menuitem', { name: 'Open in default app' }))
|
||||
expect(p.onAction).toHaveBeenCalledWith('open')
|
||||
view.rerender(<PresentedFileCard {...p} cwd="/work" />)
|
||||
expect(view.getByTitle('/work/out/report.pdf')).toBeTruthy()
|
||||
expect(view.getByText('report.pdf')).toBeTruthy()
|
||||
@@ -130,31 +49,6 @@ it.each([
|
||||
})
|
||||
|
||||
|
||||
it('does not reopen a menu after a shared native request settles', () => {
|
||||
const p = props()
|
||||
const view = render(<PresentedFileCard {...p} />)
|
||||
fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' }))
|
||||
expect(view.getByRole('menu')).toBeTruthy()
|
||||
view.rerender(<PresentedFileCard {...p} phase="opening" />)
|
||||
expect(view.queryByRole('menu')).toBeNull()
|
||||
view.rerender(<PresentedFileCard {...p} phase="opened" />)
|
||||
expect(view.queryByRole('menu')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps focus on the available preview button after selecting a native action', () => {
|
||||
const p = props()
|
||||
const view = render(<PresentedFileCard {...p} />)
|
||||
fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' }))
|
||||
const item = view.getByRole('menuitem', { name: 'Show in Finder' })
|
||||
item.focus()
|
||||
fireEvent.click(item)
|
||||
view.rerender(<PresentedFileCard {...p} phase="revealing" />)
|
||||
const preview = view.getByRole('button', { name: 'Open out/report.pdf in sidebar' })
|
||||
expect(document.activeElement).toBe(preview)
|
||||
view.rerender(<PresentedFileCard {...p} phase="revealed" />)
|
||||
expect(document.activeElement).toBe(preview)
|
||||
})
|
||||
|
||||
it.each([en, zh])('distinguishes directory-only progress and errors in each locale', (dictionary) => {
|
||||
const p = { ...props(), t: makeTranslate(dictionary) }
|
||||
const view = render(<PresentedFileCard {...p} phase="revealing" />)
|
||||
@@ -164,3 +58,15 @@ it.each([en, zh])('distinguishes directory-only progress and errors in each loca
|
||||
view.rerender(<PresentedFileCard {...p} phase="revealError" host={{ ...p.host, fileManager: 'directory' }} />)
|
||||
expect(view.getByText(dictionary['presented.directoryError'])).toBeTruthy()
|
||||
})
|
||||
|
||||
|
||||
it('renders the supplied action independently from the card preview', () => {
|
||||
const p = props()
|
||||
const native = vi.fn()
|
||||
const view = render(<PresentedFileCard {...p} actions={<button type="button" onClick={native}>Native file action</button>} />)
|
||||
fireEvent.click(view.getByRole('button', { name: 'Native file action' }))
|
||||
expect(native).toHaveBeenCalledOnce()
|
||||
expect(p.onPreview).not.toHaveBeenCalled()
|
||||
fireEvent.click(view.getByRole('button', { name: 'Preview out/report.pdf in sidebar' }))
|
||||
expect(p.onPreview).toHaveBeenCalledOnce()
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
/** The review tab type: its addresses, its store, the row pairing of the split view, and the states its body draws. */
|
||||
import { renderFileActions } from './file-actions.tsx'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -158,11 +159,17 @@ describe('ReviewTab', () => {
|
||||
loadChangesSummary: vi.fn<ReviewInjected['loadChangesSummary']>(() => Promise.resolve()),
|
||||
loadChangesDiff: vi.fn<ReviewInjected['loadChangesDiff']>(() => Promise.resolve()),
|
||||
reloadPresentedHost: vi.fn<ReviewInjected['reloadPresentedHost']>(() => Promise.resolve()),
|
||||
openChanged: vi.fn<ReviewInjected['openChanged']>(() => Promise.resolve()),
|
||||
openChanged: vi.fn<ReviewInjected['openChanged']>(() => Promise.resolve(null)),
|
||||
}
|
||||
const sessions: SessionListState = {
|
||||
ids: [SESSION],
|
||||
byId: { [SESSION]: { id: SESSION, displayTitle: 'Workspace', cwd: options.cwd ?? '/work/app',
|
||||
running: false, retainedBy: {}, blank: false, updatedAt: 0 } },
|
||||
phase: 'ready', projectionsBySession: {}, jobsBySession: {},
|
||||
}
|
||||
const sessions = { byId: { [SESSION]: { cwd: options.cwd ?? '/work/app' } } } as unknown as SessionListState
|
||||
const navigation = { address: options.address ?? ADDRESS, params: options.params, revision: options.revision ?? 1 }
|
||||
const runtime = {
|
||||
renderSlot: renderFileActions,
|
||||
useTabInfo: () => ({
|
||||
sidebar: { expanded: true, fullscreen: false }, panel: { id: 'pane-1' },
|
||||
tab: { id: TAB, kind: 'changes-review', contentId: options.address ?? ADDRESS, title: 'Review', visible: true, navigation, signal: aborter.signal, actions: tabActions },
|
||||
@@ -177,7 +184,7 @@ describe('ReviewTab', () => {
|
||||
usePresentedHost: hookOf(controller.host),
|
||||
t: makeTranslate(options.locale ?? en),
|
||||
...injected,
|
||||
} as unknown as ReviewTabProps
|
||||
} as ReviewTabProps
|
||||
const view = render(<ReviewTab {...runtime} />)
|
||||
return {
|
||||
view, injected, tabActions, store, aborter, summaries, diffs, controller,
|
||||
@@ -313,19 +320,18 @@ describe('ReviewTab', () => {
|
||||
expect(view.container.querySelector('[data-review-tool="open-file"] svg')?.getAttribute('width')).toBe('12')
|
||||
fireEvent.click(view.getByRole('button', { name: 'Open ~/out/big.bin in sidebar' }))
|
||||
expect(tabActions.openResource).toHaveBeenCalledWith(fileAddressFor(SESSION, '/work/app', '/tmp/out/big.bin'))
|
||||
fireEvent.click(view.getByRole('button', { name: 'Open ~/out/big.bin in default app' }))
|
||||
expect(injected.openChanged).toHaveBeenCalledWith('viewed', 5, 1)
|
||||
fireEvent.click(view.getByRole('button', { name: 'Native file action' }))
|
||||
expect(injected.openChanged).toHaveBeenCalledWith('viewed', 5, 1, 'open', undefined)
|
||||
act(() => { controller.state.set({ 'api/changes.open?sessionId=viewed&seq=5&index=1': 'opening' }) })
|
||||
expect((view.getByRole('button', { name: 'Open ~/out/big.bin in default app' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect((view.getByRole('button', { name: 'Native file action' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
act(() => { controller.state.set({ 'api/changes.open?sessionId=viewed&seq=5&index=1': 'error' }) })
|
||||
expect(view.getByRole('button', { name: 'Open ~/out/big.bin in default app' }).hasAttribute('data-error')).toBe(true)
|
||||
act(() => { controller.state.set({ 'api/changes.open?sessionId=viewed&seq=5&index=1': 'nativeUnavailable' }) })
|
||||
expect(view.queryByRole('button', { name: 'Open ~/out/big.bin in default app' })).toBeNull()
|
||||
expect(view.queryByRole('button', { name: 'Native file action' })).toBeNull()
|
||||
act(() => {
|
||||
controller.state.set({})
|
||||
controller.host.set({ name: 'server', available: false, fileManager: null })
|
||||
})
|
||||
expect(view.queryByRole('button', { name: 'Open ~/out/big.bin in default app' })).toBeNull()
|
||||
expect(view.queryByRole('button', { name: 'Native file action' })).toBeNull()
|
||||
expect(view.getByText(en['changes.oversized'])).toBeTruthy()
|
||||
})
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ describe('deliverables layout', () => {
|
||||
expect(read('PresentedFileCard.tsx')).toMatch(/<FileTypeIcon path=\{file\.path\} size=\{20\} \/>/)
|
||||
expect(deliveries).toMatch(/\.fileName\s*\{[^}]*font-size:\s*13px;[^}]*line-height:\s*20px/s)
|
||||
expect(deliveries).toMatch(/\.description\s*\{[^}]*font-size:\s*10px;[^}]*line-height:\s*16px/s)
|
||||
expect(deliveries).toMatch(/\.open\s*\{[^}]*padding:\s*4px 8px;[^}]*font-size:\s*12px;[^}]*line-height:\s*18px/s)
|
||||
expect(deliveries).toMatch(/\.presented\s*\{[^}]*gap:\s*10px/s)
|
||||
})
|
||||
|
||||
|
||||
@@ -14,10 +14,10 @@ const desktopIpc = await vi.hoisted(async () => {
|
||||
const path = await import('node:path')
|
||||
// Electron belongs to the Desktop app; resolve its mock from that workspace.
|
||||
const electron = createRequire(path.resolve(import.meta.dirname, '../../../../apps/desktop/package.json')).resolve('electron')
|
||||
return { invoke: vi.fn(), electron }
|
||||
return { invoke: vi.fn(), on: vi.fn(), electron }
|
||||
})
|
||||
vi.mock(desktopIpc.electron, () => ({
|
||||
ipcRenderer: { invoke: desktopIpc.invoke },
|
||||
ipcRenderer: { invoke: desktopIpc.invoke, on: desktopIpc.on },
|
||||
contextBridge: { exposeInMainWorld: (name: string, value: unknown) => { vi.stubGlobal(name, value) } },
|
||||
}))
|
||||
vi.mock('../../../../apps/desktop/src/preload-platform.ts', () => ({ markDocumentPlatform: vi.fn(), syncWindowFullscreen: vi.fn() }))
|
||||
@@ -25,7 +25,7 @@ vi.mock('../../../../apps/desktop/src/preload-theme.ts', () => ({ syncNativeThem
|
||||
vi.mock('../../../../apps/desktop/src/preload-windows.ts', () => ({ syncWindowsAppearance: vi.fn() }))
|
||||
vi.mock('../../../../apps/desktop/src/preload-mandatory-overlay.ts', () => ({ installMandatoryUpdateOverlay: vi.fn() }))
|
||||
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks(); desktopIpc.invoke.mockReset() })
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks(); desktopIpc.invoke.mockReset(); desktopIpc.on.mockReset() })
|
||||
|
||||
const HOLES = ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-dockkit/README.md
|
||||
README.md: f41668ad2eb6c16e13deb5fcc554c8717811a468
|
||||
README.zh.md: 6a4c70a1ddb46b75b614e9e63eaddd89f3641ac3
|
||||
README.md: 613cdbcb105108eb848160f1cb6bc8ee28d39fac
|
||||
README.zh.md: 77b256f01dd46aea5144e9f8da644fd39330f187
|
||||
@@ -50,13 +50,15 @@ Everything host-specific arrives through props:
|
||||
| `TabRenderer` | one tab's body (`renderTab`), drawn flush to the pane's edges and the unbordered strip's bottom edge with the insets it chooses, and optionally what its chip or panel header shows as a title (`renderTabTitle`, falling back to the record's `title`); the embedder dispatches on `tab.kind` |
|
||||
| `DockIntents` | the settled results of every gesture |
|
||||
|
||||
`DockController` satisfies `DockIntents` as written, so the simplest embedding hands the controller straight to `DockSurface`. An embedder that routes through its own store implements the same method names instead. Three props carry control policy rather than gestures: `canSplit` (surface-wide, the pane budget; disables the split control with `splitPaneDisabled`), `canAddTab(paneId)` (per pane, omits the add control; leave it out to draw one in every pane), and `canCloseTab(tabId)` (per tab, withholds the chip's close control and the menu's close item together; leave it out to keep every tab closable). Hiding the add control moves nothing else in the strip, and a withheld close moves nothing in the chip — the close control paints over the title's end rather than beside it. A pane's lone chip whose close is withheld draws quiet — no capsule, no hover fill — since there is nothing to select against and nothing to do to it. The kit adds one policy of its own, the room rule below, which disables a pane's split control with `splitPaneNarrow`; `onRoom(fits)` reports its readings so an embedder splitting programmatically can honour the same rule.
|
||||
`DockController` satisfies `DockIntents` as written, so an embedding can pass it to `DockLayout` for retained Sidebar layouts or `DockSurface` for recursive split trees. An embedder that routes through its own store implements the same method names instead. Three props carry control policy rather than gestures: `canSplit` (surface-wide, the pane budget; disables the split control with `splitPaneDisabled`), `canAddTab(paneId)` (per pane, omits the add control; leave it out to draw one in every pane), and `canCloseTab(tabId)` (per tab, withholds the chip's close control and the menu's close item together; leave it out to keep every tab closable). Hiding the add control moves nothing else in the strip, and a withheld close moves nothing in the chip — the close control paints over the title's end rather than beside it. A pane's lone chip whose close is withheld draws quiet — no capsule, no hover fill — since there is nothing to select against and nothing to do to it. The kit adds one policy of its own, the room rule below, which disables a pane's split control with `splitPaneNarrow`; `onRoom(fits)` reports its readings so an embedder splitting programmatically can honour the same rule.
|
||||
|
||||
`dropZones="horizontal"` offers two half-pane hints; once budget or width forbids another split, the whole body accepts a move. A hint is a dashed card inset 8px inside its region, showing the zone's glyph and `labels.dropZone[zone]`; the preview layer covers all tab-body content, while the card under the pointer takes the accent and its neighbour stays a quiet outline. `minPaneFraction` sets the preview minimum, and `planResizeSplit` accepts the same minimum for the committed operation. The Sidebar uses 0.2 and enforces two panes in its own store. The generic engine retains its tree and other split directions. `hideSplitWhenBlocked` hides a blocked split control — pane budget spent or pane too narrow — instead of rendering it disabled; its default is false.
|
||||
|
||||
A tab's `kind` is an opaque string. Seeded tabs are factories (`DockControllerOptions`), so what a fresh pane contains is the embedder's decision, not this package's. Content identity is the pair (`kind`, `contentId`): `findContentTab(state, contentId, kind?)` finds the tab showing it anywhere and `findPaneContentTab(state, paneId, contentId, kind?)` within one pane, and `planOpenContent` focuses that tab instead of opening another unless told `revealIfOpened: false`; an explicit `index` seats a new tab at a strip slot rather than at the end.
|
||||
|
||||
`DockSurface` is the docked area. Chrome around it — a rail, a collapsed presentation, any history controls — belongs to the embedder, which reads `state.expanded` and decides; the kit ships no undo/redo control of its own. Surface-wide controls the embedder does want on the surface go through the `chrome` prop, which the kit places at the far end of the top-right pane's tab strip (the last child of every row split, the first of every column split), so a surface needs no header row of its own. `FloatLayer` owns its own gestures and positions panels in viewport coordinates, so it may be mounted anywhere, including a portal.
|
||||
`DockLayout`, used by the Sidebar, keeps each tab in a stable DOM cell across selection, pane moves and floating. It accepts one docked pane or two horizontal panes; CSS Grid resolves their widths. `keepMounted(tab)` retains visited bodies and `active` controls Session visibility without reparenting content.
|
||||
|
||||
`DockSurface` is the docked area for the separate recursive renderer. Chrome around it — a rail, a collapsed presentation, any history controls — belongs to the embedder, which reads `state.expanded` and decides; the kit ships no undo/redo control of its own. Surface-wide controls the embedder does want on the surface go through the `chrome` prop, which the kit places at the far end of the top-right pane's tab strip (the last child of every row split, the first of every column split), so a surface needs no header row of its own. `FloatLayer` owns its own gestures and positions panels in viewport coordinates, so it may be mounted anywhere, including a portal.
|
||||
|
||||
<a id="interaction-rules-worth-keeping"></a>
|
||||
## Interaction rules worth keeping
|
||||
|
||||
@@ -50,13 +50,15 @@ kind: "package-reference"
|
||||
| `TabRenderer` | 一个 tab 的正文(`renderTab`),贴着格的边缘和(不带边线的)tab 条底边绘制、自己决定留白,以及可选的 chip 或浮窗头部显示的标题(`renderTabTitle`,回退到记录的 `title`);嵌入方按 `tab.kind` 分发 |
|
||||
| `DockIntents` | 每次手势落定的结果 |
|
||||
|
||||
`DockController` 原样满足 `DockIntents`,所以最简单的嵌入就是把 controller 直接交给 `DockSurface`。经由自己 store 路由的嵌入方则实现同名方法。有三个 props 承载的是控制策略而非手势:`canSplit`(整面有效,即格预算;用 `splitPaneDisabled` 禁用分栏控件)、`canAddTab(paneId)`(按格,省略添加控件;不传则每格都画)与 `canCloseTab(tabId)`(按 tab,把 chip 的关闭控件和菜单的关闭项一并收起;不传则每个 tab 都可关闭)。隐藏添加控件不会移动 tab 条里的其它任何东西,收起关闭也不会移动 chip 里的任何东西——关闭控件压在标题末端之上而非并排。某格仅剩的一个 chip 在关闭被收起时画成安静样式——没有胶囊底色,没有悬停填充——因为既没有别的 tab 可供选择,也没有任何可对它做的事。套件自己再加一条策略,即下文的空间规则,它用 `splitPaneNarrow` 禁用某格的分栏控件;`onRoom(fits)` 上报其读数,让以编程方式分栏的嵌入方能遵守同一规则。
|
||||
`DockController` 原样满足 `DockIntents`,因此嵌入方可将它交给 `DockLayout` 来保活 Sidebar 布局,或交给 `DockSurface` 来渲染递归分割树。经由自己 store 路由的嵌入方则实现同名方法。有三个 props 承载的是控制策略而非手势:`canSplit`(整面有效,即格预算;用 `splitPaneDisabled` 禁用分栏控件)、`canAddTab(paneId)`(按格,省略添加控件;不传则每格都画)与 `canCloseTab(tabId)`(按 tab,把 chip 的关闭控件和菜单的关闭项一并收起;不传则每个 tab 都可关闭)。隐藏添加控件不会移动 tab 条里的其它任何东西,收起关闭也不会移动 chip 里的任何东西——关闭控件压在标题末端之上而非并排。某格仅剩的一个 chip 在关闭被收起时画成安静样式——没有胶囊底色,没有悬停填充——因为既没有别的 tab 可供选择,也没有任何可对它做的事。套件自己再加一条策略,即下文的空间规则,它用 `splitPaneNarrow` 禁用某格的分栏控件;`onRoom(fits)` 上报其读数,让以编程方式分栏的嵌入方能遵守同一规则。
|
||||
|
||||
`dropZones="horizontal"` 提供左右两个半区提示;预算或宽度不允许再拆时,正文整格接收移动。提示是一张内缩 8px 的虚线卡片,显示该落区的图形和 `labels.dropZone[zone]`;预览层覆盖全部 tab 正文,指针所在的卡片取强调色,另一张保持安静的轮廓。`minPaneFraction` 控制预览的最小比例,`planResizeSplit` 接受相同最小值以约束提交;Sidebar使用0.2并在自己的store限制两格。通用引擎仍保留原有树与其它分割方向。 `hideSplitWhenBlocked` 在分栏被阻止时(窗格预算已满或格太窄)直接隐藏分栏控件而不是渲染禁用态,默认值为 false。
|
||||
|
||||
tab 的 `kind` 是不透明字符串。种子 tab 是工厂(`DockControllerOptions`),因此新格里放什么由嵌入方决定,与本包无关。内容身份是二元组(`kind`、`contentId`):`findContentTab(state, contentId, kind?)` 在任意位置找到展示它的 tab,`findPaneContentTab(state, paneId, contentId, kind?)` 在一个格内找;`planOpenContent` 会聚焦该 tab 而非再开一个,除非被告知 `revealIfOpened: false`;显式的 `index` 把新 tab 放到 tab 条的某个位置而非末尾。
|
||||
|
||||
`DockSurface` 是停靠区。它周围的 chrome——轨道、折叠形态、任何历史控件——属于嵌入方,由嵌入方读取 `state.expanded` 后自行决定;套件不自带撤销/重做控件。嵌入方确实想放到面上的整面控件通过 `chrome` prop 传入,套件把它放在右上格 tab 条的最末端(每个横向分裂的最后一个子节点、每个纵向分裂的第一个子节点),因此停靠面不需要自己的标题行。`FloatLayer` 拥有自己的手势并以视口坐标定位浮窗,因此可以挂在任何位置,包括 portal 里。
|
||||
Sidebar 使用的 `DockLayout` 在选中项变化、跨格移动与浮动期间,把每个 tab 保留在稳定的 DOM 容器中。它接受一个停靠格或左右两个格,由 CSS Grid 计算宽度。`keepMounted(tab)` 保留已访问正文,`active` 控制 Session 可见性,不改变内容的父子关系。
|
||||
|
||||
`DockSurface` 是独立递归渲染器的停靠区。它周围的 chrome——轨道、折叠形态、任何历史控件——属于嵌入方,由嵌入方读取 `state.expanded` 后自行决定;套件不自带撤销/重做控件。嵌入方确实想放到面上的整面控件通过 `chrome` prop 传入,套件把它放在右上格 tab 条的最末端(每个横向分裂的最后一个子节点、每个纵向分裂的第一个子节点),因此停靠面不需要自己的标题行。`FloatLayer` 拥有自己的手势并以视口坐标定位浮窗,因此可以挂在任何位置,包括 portal 里。
|
||||
|
||||
<a id="interaction-rules-worth-keeping"></a>
|
||||
## 值得保留的交互规则
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { fitOf, measurePaneFits, paneElements, sameFits } from './measure.ts'
|
||||
import { useGesture } from './pointer.ts'
|
||||
import { PaneTree, type SizePreview } from './PaneTree.tsx'
|
||||
import { TabLayout, type TabRetention } from './TabLayout.tsx'
|
||||
import type { PaneCallbacks, SplitBlock } from './render.ts'
|
||||
import css from './dockkit.module.css'
|
||||
|
||||
@@ -161,10 +162,11 @@ function sameSizes(a: readonly number[], b: readonly number[]): boolean {
|
||||
}
|
||||
|
||||
/** The split tree and the gestures over it. */
|
||||
export function DockSurface({
|
||||
function Surface({
|
||||
state, canSplit, canAddTab, canCloseTab, intents, labels, renderTab, renderTabTitle, renderTabMenuItems, chrome, onRoom,
|
||||
draw,
|
||||
dropZones = 'edges', minPaneFraction = MIN_PANE_FRACTION, hideSplitWhenBlocked = false,
|
||||
}: DockSurfaceProps): ReactNode {
|
||||
}: DockSurfaceProps & { readonly draw: (callbacks: PaneCallbacks, preview: SizePreview | undefined) => ReactNode }): ReactNode {
|
||||
const surface = useRef<HTMLDivElement | null>(null)
|
||||
const [preview, setPreview] = useState<Preview>(NO_PREVIEW)
|
||||
const [fits, setFits] = useState(NO_FITS)
|
||||
@@ -289,7 +291,26 @@ export function DockSurface({
|
||||
|
||||
return (
|
||||
<div className={css.surface} ref={surface} data-dockkit-surface data-dockkit-drop-zones={dropZones}>
|
||||
<PaneTree state={state} nodeId={state.rootId} callbacks={callbacks} preview={preview.sizes} />
|
||||
{draw(callbacks, preview.sizes)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Recursive docked layout; floating content is rendered separately by FloatLayer. */
|
||||
export function DockSurface(props: DockSurfaceProps): ReactNode {
|
||||
return <Surface {...props} draw={(callbacks, preview) =>
|
||||
<PaneTree state={props.state} nodeId={props.state.rootId} callbacks={callbacks} preview={preview} />} />
|
||||
}
|
||||
|
||||
/** Horizontal Sidebar layout with stable tab containers across docking and floating. */
|
||||
export type DockLayoutProps = DockSurfaceProps & TabRetention
|
||||
|
||||
/**
|
||||
* Render one pane or two horizontal panes, and their floats, in one stable content tree.
|
||||
* @param props - layout, gestures and lazy body-retention policy.
|
||||
* @returns the layout; the containing ancestors must not clip or establish a fixed-position containing block.
|
||||
*/
|
||||
export function DockLayout(props: DockLayoutProps): ReactNode {
|
||||
return <Surface {...props} draw={(callbacks, preview) =>
|
||||
<TabLayout {...props} callbacks={callbacks} preview={preview} />} />
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import type { PointerEvent as ReactPointerEvent, ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconCloseOutlineRegular, IconPanelLeftOutlineRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { DockIntents, DockLabels, TabRenderer } from '../contract/adapter.ts'
|
||||
import type { FloatRect, LayoutState, PaneId, TabId } from '../contract/types.ts'
|
||||
import type { FloatRect, LayoutState, PaneId, TabId, TabRecord } from '../contract/types.ts'
|
||||
import { FLOAT_MIN_SIZE } from '../engine/constraints.ts'
|
||||
import { movedRect, resizedRect } from '../engine/geometry.ts'
|
||||
import { floatRect, getPane, getTab, onlyTabId } from '../engine/tree.ts'
|
||||
@@ -65,8 +65,13 @@ function raised(state: LayoutState, paneId: PaneId): boolean {
|
||||
return state.activePaneId === paneId && state.floats.at(-1) === paneId
|
||||
}
|
||||
|
||||
/** Every floating panel, in z order. */
|
||||
export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle, canCloseTab }: FloatLayerProps): ReactNode {
|
||||
/**
|
||||
* Shared move/resize gestures; changing layout mode never owns the content DOM.
|
||||
* @param state - committed layout and float stacking order.
|
||||
* @param intents - gesture settlements.
|
||||
* @returns the current preview and focus/gesture callbacks.
|
||||
*/
|
||||
export function useFloatGestures(state: LayoutState, intents: DockIntents) {
|
||||
const [preview, setPreview] = useState<{ paneId: PaneId; rect: FloatRect } | undefined>(undefined)
|
||||
const begin = useGesture(() => { setPreview(undefined) })
|
||||
|
||||
@@ -93,6 +98,54 @@ export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle,
|
||||
})
|
||||
}
|
||||
|
||||
return { preview, raise, drag }
|
||||
}
|
||||
|
||||
/**
|
||||
* Header-only controls, usable above either retained or visibility-mounted bodies.
|
||||
* @param props - owning pane/tab, localized labels and gesture callbacks.
|
||||
* @returns the float header without a content container.
|
||||
*/
|
||||
export function FloatHeader({ paneId, tab, labels, intents, renderTabTitle, canCloseTab, drag }: {
|
||||
readonly paneId: PaneId
|
||||
readonly tab: TabRecord
|
||||
readonly labels: DockLabels
|
||||
readonly intents: DockIntents
|
||||
readonly renderTabTitle: TabRenderer | undefined
|
||||
readonly canCloseTab: ((id: TabId) => boolean) | undefined
|
||||
readonly drag: ReturnType<typeof useFloatGestures>['drag']
|
||||
}): ReactNode {
|
||||
return (
|
||||
<header className={clsx(css.tabStrip, css.floatHeader)} data-dockkit-float-grip={paneId}
|
||||
onPointerDown={(event) => { drag('move', paneId, event) }}>
|
||||
<div className={clsx(css.tab, css.floatTitle)} data-dockkit-float-title>
|
||||
<TabTitle>{renderTabTitle?.(tab) ?? tab.title}</TabTitle>
|
||||
</div>
|
||||
<div className={css.stripFill} />
|
||||
<Tooltip label={labels.dockFloat} side="bottom" delayMs={500}>
|
||||
<button type="button" className={css.iconButton} aria-label={labels.dockFloat}
|
||||
data-dockkit-float-dock={paneId} onPointerDown={(event) => { event.stopPropagation() }}
|
||||
onClick={() => { intents.unfloatPane(paneId) }}>
|
||||
<IconPanelLeftOutlineRegular className={css.dockGlyph} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{(canCloseTab?.(tab.id) ?? true) && (
|
||||
<Tooltip label={labels.closeFloat} side="bottom" delayMs={500}>
|
||||
<button type="button" className={css.iconButton} aria-label={labels.closeFloat}
|
||||
data-dockkit-float-close={paneId} onPointerDown={(event) => { event.stopPropagation() }}
|
||||
onClick={() => { intents.closeTab(tab.id) }}>
|
||||
<IconCloseOutlineRegular />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
/** Every floating panel, in z order. */
|
||||
export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle, canCloseTab }: FloatLayerProps): ReactNode {
|
||||
const { preview, raise, drag } = useFloatGestures(state, intents)
|
||||
|
||||
return (
|
||||
<>
|
||||
{state.floats.map((paneId, depth) => {
|
||||
@@ -117,42 +170,7 @@ export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle,
|
||||
}}
|
||||
onPointerDown={() => { raise(paneId) }}
|
||||
>
|
||||
<header
|
||||
className={clsx(css.tabStrip, css.floatHeader)}
|
||||
data-dockkit-float-grip={paneId}
|
||||
onPointerDown={(event) => { drag('move', paneId, event) }}
|
||||
>
|
||||
<div className={clsx(css.tab, css.floatTitle)} data-dockkit-float-title>
|
||||
<TabTitle>{renderTabTitle?.(tab) ?? tab.title}</TabTitle>
|
||||
</div>
|
||||
<div className={css.stripFill} />
|
||||
<Tooltip label={labels.dockFloat} side="bottom" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={labels.dockFloat}
|
||||
data-dockkit-float-dock={paneId}
|
||||
onPointerDown={(event) => { event.stopPropagation() }}
|
||||
onClick={() => { intents.unfloatPane(paneId) }}
|
||||
>
|
||||
<IconPanelLeftOutlineRegular className={css.dockGlyph} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{(canCloseTab?.(tab.id) ?? true) && (
|
||||
<Tooltip label={labels.closeFloat} side="bottom" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={labels.closeFloat}
|
||||
data-dockkit-float-close={paneId}
|
||||
onPointerDown={(event) => { event.stopPropagation() }}
|
||||
onClick={() => { intents.closeTab(tab.id) }}
|
||||
>
|
||||
<IconCloseOutlineRegular />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</header>
|
||||
<FloatHeader {...{ paneId, tab, labels, intents, renderTabTitle, canCloseTab, drag }} />
|
||||
<div className={css.floatBody}>{renderTab(tab)}</div>
|
||||
<div
|
||||
className={css.floatResize}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/** Stable tab siblings in one horizontal Grid, including viewport-positioned floats. */
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { MutableRefObject, ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import type { DockIntents } from '../contract/adapter.ts'
|
||||
import type { LayoutState, PaneNode, TabId, TabRecord } from '../contract/types.ts'
|
||||
import { findTabPane, floatRect, getNode, getPane } from '../engine/tree.ts'
|
||||
import type { SizePreview } from './PaneTree.tsx'
|
||||
import type { PaneCallbacks } from './render.ts'
|
||||
import { FloatHeader, useFloatGestures } from './FloatLayer.tsx'
|
||||
import { PaneDropHints, TabPanel, TabStrip } from './TabPanel.tsx'
|
||||
import css from './dockkit.module.css'
|
||||
|
||||
/** Host policy for lazily retaining a tab body without reparenting it. */
|
||||
export interface TabRetention {
|
||||
/** Whether a visited body survives hiding; omitted means visibility-mounted. */
|
||||
readonly keepMounted?: (tab: TabRecord) => boolean
|
||||
/** Whether this layout's Session is on screen; defaults to true. */
|
||||
readonly active?: boolean
|
||||
}
|
||||
|
||||
interface LayoutProps extends TabRetention {
|
||||
readonly state: LayoutState
|
||||
readonly callbacks: PaneCallbacks
|
||||
readonly preview: SizePreview | undefined
|
||||
readonly intents: DockIntents
|
||||
}
|
||||
|
||||
interface TabHostProps extends LayoutProps {
|
||||
readonly tab: TabRecord
|
||||
readonly pane: PaneNode
|
||||
readonly column: number
|
||||
readonly floats: ReturnType<typeof useFloatGestures>
|
||||
readonly focusRequest: MutableRefObject<{ readonly tabId: TabId; readonly origin: Element | null } | undefined>
|
||||
}
|
||||
|
||||
/** A tab's ancestors stay identical across selection, pane moves and floating. */
|
||||
function TabHost({ state, callbacks, intents, tab, pane, column, floats, focusRequest, keepMounted,
|
||||
active = true }: TabHostProps): ReactNode {
|
||||
const floating = pane.host === 'float'
|
||||
const selected = floating || pane.activeTabId === tab.id
|
||||
const visible = active && selected && (floating || state.expanded)
|
||||
const retained = keepMounted?.(tab) ?? false
|
||||
const [visited, setVisited] = useState(visible)
|
||||
if (visible && !visited) setVisited(true)
|
||||
const host = useRef<HTMLElement | null>(null)
|
||||
const body = useRef<HTMLDivElement | null>(null)
|
||||
useLayoutEffect(() => {
|
||||
// Both refs target unconditional descendants attached before these effects.
|
||||
const section = host.current as HTMLElement
|
||||
section.inert = !visible
|
||||
const focused = document.activeElement
|
||||
if (!visible && focused instanceof HTMLElement && section.contains(focused)) focused.blur()
|
||||
const request = focusRequest.current
|
||||
if (!visible || request?.tabId !== tab.id) return
|
||||
focusRequest.current = undefined
|
||||
// Preserve deliberate focus taken by another control or the newly shown body.
|
||||
if (focused !== null && focused !== document.body && focused !== document.documentElement && focused !== request.origin) return
|
||||
const strip = section.querySelector('[data-dockkit-strip]')
|
||||
const chip = [...strip?.querySelectorAll<HTMLElement>('[data-dockkit-tab]') ?? []]
|
||||
.find(element => element.dataset.dockkitTab === tab.id)
|
||||
chip?.focus({ preventScroll: true })
|
||||
}, [visible, tab.id, focusRequest])
|
||||
// Electron emits a non-bubbling focus event on the webview element.
|
||||
useEffect(() => {
|
||||
const element = body.current as HTMLDivElement
|
||||
const focus = (): void => {
|
||||
if (!visible) return
|
||||
if (floating) floats.raise(pane.id)
|
||||
else if (state.activePaneId !== pane.id) callbacks.onFocusPane(pane.id)
|
||||
}
|
||||
element.addEventListener('focus', focus, true)
|
||||
return () => { element.removeEventListener('focus', focus, true) }
|
||||
}, [visible, floating, floats, pane.id, state.activePaneId, callbacks])
|
||||
const lifted = floating && floats.preview?.paneId === pane.id ? floats.preview.rect : undefined
|
||||
const rect = floating ? lifted ?? floatRect(pane) : undefined
|
||||
const depth = lifted === undefined ? state.floats.indexOf(pane.id) + 1 : state.floats.length + 1
|
||||
return (
|
||||
<div className={clsx(css.tabCell, floating && css.floatingCell)} hidden={!selected}
|
||||
data-dockkit-host={floating ? 'float' : 'dock'} data-dockkit-column={floating ? undefined : column}
|
||||
style={{ gridColumn: floating ? 1 : column * 2 + 1, gridRow: 1, order: floating ? depth : 0 }}>
|
||||
<section ref={host} className={clsx(css.tabHost, floating ? css.float : css.pane)}
|
||||
aria-hidden={!visible || undefined}
|
||||
data-dockkit-content={tab.id}
|
||||
data-dockkit-pane={!floating && selected ? pane.id : undefined}
|
||||
data-dockkit-pane-active={!floating && state.activePaneId === pane.id || undefined}
|
||||
data-dockkit-float={floating ? pane.id : undefined}
|
||||
data-dockkit-float-active={floating && state.activePaneId === pane.id || undefined}
|
||||
data-dockkit-column={!floating ? column : undefined}
|
||||
style={rect === undefined ? undefined : {
|
||||
left: rect.x, top: rect.y, width: rect.width, height: rect.height,
|
||||
}}
|
||||
onPointerDown={() => { if (floating) floats.raise(pane.id) }}
|
||||
onClick={() => { if (!floating && state.activePaneId !== pane.id) callbacks.onFocusPane(pane.id) }}>
|
||||
<div className={css.tabHostHeader}>
|
||||
{selected && (floating
|
||||
? <FloatHeader paneId={pane.id} tab={tab} labels={callbacks.labels} intents={intents}
|
||||
renderTabTitle={callbacks.renderTabTitle} canCloseTab={callbacks.canCloseTab} drag={floats.drag} />
|
||||
: <TabStrip state={state} pane={pane} callbacks={callbacks} />)}
|
||||
</div>
|
||||
<div ref={body} className={clsx(css.tabHostBody, floating ? css.floatBody : css.paneBody)}>
|
||||
{visited && (retained || (active && selected)) ? callbacks.renderTab(tab) : null}
|
||||
{!floating && <PaneDropHints pane={pane} callbacks={callbacks} />}
|
||||
</div>
|
||||
{floating && <div className={css.floatResize} data-dockkit-float-resize={pane.id}
|
||||
onPointerDown={(event) => { floats.drag('resize', pane.id, event) }} />}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One or two horizontal panes; CSS resolves content sizes from track fractions.
|
||||
* @param props - committed layout, gesture preview and body retention policy.
|
||||
* @returns stable tab containers, including floating tabs.
|
||||
* @throws if the docked tree is not one pane or two horizontal panes.
|
||||
*/
|
||||
export function TabLayout(props: LayoutProps): ReactNode {
|
||||
const { state, callbacks, preview } = props
|
||||
const focusRequest = useRef<{ readonly tabId: TabId; readonly origin: Element | null }>()
|
||||
const tabCallbacks: PaneCallbacks = {
|
||||
...callbacks,
|
||||
onFocusTab: (tabId) => {
|
||||
// Only changing the selected body replaces its strip; pane-only focus does not.
|
||||
focusRequest.current = findTabPane(state, tabId).activeTabId === tabId
|
||||
? undefined : { tabId, origin: document.activeElement }
|
||||
callbacks.onFocusTab(tabId)
|
||||
},
|
||||
}
|
||||
const root = getNode(state, state.rootId)
|
||||
if (root.kind === 'split' && (root.axis !== 'row' || root.children.length !== 2)) {
|
||||
throw new Error('DockLayout requires one pane or two horizontally split panes')
|
||||
}
|
||||
const panes = root.kind === 'pane' ? [root] : root.children.map(id => getPane(state, id))
|
||||
const sizes = root.kind === 'split' ? preview?.splitId === root.id ? preview.sizes : root.sizes : [1]
|
||||
const floats = useFloatGestures(state, props.intents)
|
||||
const columns = sizes.map(size => `minmax(0, ${size}fr)`).join(' 0px ')
|
||||
return (
|
||||
<div className={css.tabLayout} data-dockkit-split={root.kind === 'split' ? root.id : undefined}
|
||||
style={{ gridTemplateColumns: columns }}>
|
||||
{/* Logical order changes must not make React move a connected webview's ancestor. */}
|
||||
{Object.values(state.tabs).sort((a, b) => a.id.localeCompare(b.id)).map((tab) => {
|
||||
const pane = findTabPane(state, tab.id)
|
||||
return <TabHost key={tab.id} {...props} callbacks={tabCallbacks} tab={tab} pane={pane}
|
||||
column={panes.findIndex(candidate => candidate.id === pane.id)} floats={floats} focusRequest={focusRequest} />
|
||||
})}
|
||||
{panes.filter(pane => pane.tabs.length === 0).map(pane => (
|
||||
<div key={pane.id} className={css.emptyTabHost} data-dockkit-empty style={{ gridColumn: panes.indexOf(pane) * 2 + 1, gridRow: 1 }}>
|
||||
<TabPanel state={state} pane={pane} callbacks={callbacks} />
|
||||
</div>
|
||||
))}
|
||||
{root.kind === 'split' && <div className={clsx(css.divider, css.tabLayoutDivider)}
|
||||
data-dockkit-divider={`${root.id}:0`} style={{ gridColumn: 2, gridRow: 1 }}
|
||||
onPointerDown={(event) => { callbacks.onDividerPressed(root.id, 0, event) }} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -208,8 +208,8 @@ function splitBlockedTitle(labels: PaneCallbacks['labels'], block: SplitBlock):
|
||||
}
|
||||
}
|
||||
|
||||
/** The pane's tab strip, split control, and body. */
|
||||
export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode {
|
||||
/** @param props - pane state and gestures. @returns its tab strip without a content container. */
|
||||
export function TabStrip({ state, pane, callbacks }: TabPanelProps): ReactNode {
|
||||
// The open context menu and the chip that opened it; the menu positions
|
||||
// itself against that chip from its portal.
|
||||
const [menu, setMenu] = useState<{ readonly tabId: TabId; readonly anchor: HTMLElement } | undefined>(undefined)
|
||||
@@ -218,15 +218,11 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode {
|
||||
const stripTabs = useRef<HTMLDivElement | null>(null)
|
||||
useStripScrollFades(stripTabs, pane.tabs)
|
||||
useActiveChipInView(stripTabs, chips, pane.tabs, pane.activeTabId)
|
||||
const active = pane.activeTabId === undefined ? undefined : getTab(state, pane.activeTabId)
|
||||
const block = callbacks.splitBlock(pane.id)
|
||||
const target = callbacks.dropTarget
|
||||
const stripIndex = target !== undefined && target.kind === 'strip' && target.paneId === pane.id
|
||||
? target.index
|
||||
: undefined
|
||||
const zone = target !== undefined && target.kind === 'zone' && target.paneId === pane.id
|
||||
? target.zone
|
||||
: undefined
|
||||
|
||||
/** Select a tab from a click or a key, unless it is the active pane's selected tab already: that changes nothing. */
|
||||
const activate = (tabId: TabId): void => {
|
||||
@@ -241,187 +237,196 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode {
|
||||
chip.focus()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.tabStrip} role="tablist" data-dockkit-strip={pane.id}>
|
||||
<div ref={stripTabs} className={css.stripTabs} role="presentation" data-dockkit-strip-tabs={pane.id}>
|
||||
{pane.tabs.map((tabId, index) => {
|
||||
const tab = getTab(state, tabId)
|
||||
const selected = tabId === pane.activeTabId
|
||||
const closable = callbacks.canCloseTab(tabId)
|
||||
// A pane's lone unclosable chip is a label, not a choice: there is
|
||||
// no other tab to select against and nothing to do to it.
|
||||
const quiet = !closable && pane.tabs.length === 1
|
||||
return (
|
||||
<Fragment key={tabId}>
|
||||
{(index > 0 || stripIndex === index) && (
|
||||
<div
|
||||
className={clsx(css.slot, stripIndex === index && css.slotCaret)}
|
||||
data-dockkit-caret={stripIndex === index ? index : undefined}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
role="tab"
|
||||
aria-selected={selected}
|
||||
tabIndex={selected ? 0 : -1}
|
||||
className={clsx(
|
||||
css.tab,
|
||||
selected && css.tabActive,
|
||||
quiet && css.tabQuiet,
|
||||
callbacks.draggingTabId === tabId && css.tabDragging,
|
||||
)}
|
||||
data-dockkit-tab={tabId}
|
||||
data-dockkit-tab-quiet={quiet || undefined}
|
||||
ref={(element) => {
|
||||
if (element === null) chips.delete(tabId)
|
||||
else chips.set(tabId, element)
|
||||
}}
|
||||
// Focus lands on click, not on press: a state change between
|
||||
// pointerdown and the first pointermove rebuilds this subtree,
|
||||
// and Chromium cancels the pointer when the pressed element is
|
||||
// replaced — which would abandon every drag. A drag that ends
|
||||
// elsewhere fires no click, and its own operation carries focus.
|
||||
onPointerDown={(event) => {
|
||||
// A secondary press is the menu, never a drag.
|
||||
if (event.button === 2) return
|
||||
callbacks.onTabPressed(tabId, event)
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
activate(tabId)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
// Keys on the chip's nested close control are that control's.
|
||||
if (event.target !== event.currentTarget) return
|
||||
const next = chipToFocus(event.key, pane.tabs, tabId)
|
||||
if (next !== undefined) {
|
||||
event.preventDefault()
|
||||
focusChip(next)
|
||||
return
|
||||
}
|
||||
if (selects(event.key)) {
|
||||
event.preventDefault()
|
||||
activate(tabId)
|
||||
}
|
||||
}}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
const anchor = event.currentTarget
|
||||
setMenu(current => current?.tabId === tabId ? undefined : { tabId, anchor })
|
||||
}}
|
||||
>
|
||||
<TabTitle>{callbacks.renderTabTitle?.(tab) ?? tab.title}</TabTitle>
|
||||
{closable && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.tabClose}
|
||||
aria-label={callbacks.labels.closeTab}
|
||||
data-dockkit-tab-close={tabId}
|
||||
// A nested control stops its own press: otherwise the press
|
||||
// starts a drag, captures the pointer, and this click never lands.
|
||||
onPointerDown={(event) => { event.stopPropagation() }}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
callbacks.onCloseTab(tabId)
|
||||
}}
|
||||
>
|
||||
<IconCloseFillRegular size={14} />
|
||||
</button>
|
||||
)}
|
||||
{menu?.tabId === tabId && (
|
||||
<TabMenu
|
||||
labels={callbacks.labels}
|
||||
anchor={menu.anchor}
|
||||
onClose={closable ? () => { setMenu(undefined); callbacks.onCloseTab(tabId) } : undefined}
|
||||
onDismiss={() => { setMenu(undefined) }}
|
||||
extras={callbacks.renderTabMenuItems?.(tab, () => { setMenu(undefined) })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
{stripIndex === pane.tabs.length && <div className={clsx(css.slot, css.slotCaret)} data-dockkit-caret={stripIndex} />}
|
||||
</div>
|
||||
{callbacks.canAddTab(pane.id) && (
|
||||
<Tooltip label={callbacks.labels.addTab} side="bottom" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.addTab}
|
||||
aria-label={callbacks.labels.addTab}
|
||||
data-dockkit-add-tab={pane.id}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
callbacks.onAddTab(pane.id)
|
||||
}}
|
||||
>
|
||||
<IconPlusOutlineRegular size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div className={css.stripFill} data-dockkit-strip-fill />
|
||||
{!(callbacks.hideSplitWhenBlocked && block !== undefined) && (
|
||||
<Tooltip label={callbacks.labels.splitPane} side="bottom" delayMs={500} disabled={block !== undefined}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={callbacks.labels.splitPane}
|
||||
// Disabled buttons fire no hover events, so the blocked reason
|
||||
// stays a native title.
|
||||
title={block === undefined ? undefined : splitBlockedTitle(callbacks.labels, block)}
|
||||
disabled={block !== undefined}
|
||||
data-dockkit-split-button={pane.id}
|
||||
data-dockkit-split-blocked={block}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
callbacks.onSplitPane(pane.id)
|
||||
}}
|
||||
>
|
||||
<SplitGlyph />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* The embedder's surface-wide controls, in the top-right pane only: the
|
||||
strip is the surface's top edge, and this pane's end is its corner. */}
|
||||
{pane.id === callbacks.chromePaneId && callbacks.chrome !== undefined && (
|
||||
// The embedder's controls report their own intents; the pane's
|
||||
// click-to-focus must not add a focus entry to each of them.
|
||||
<div
|
||||
className={css.stripChrome}
|
||||
data-dockkit-strip-chrome
|
||||
onClick={(event) => { event.stopPropagation() }}
|
||||
>
|
||||
{callbacks.chrome}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** @param props - target pane and drag preview. @returns body-local drop feedback, or nothing. */
|
||||
export function PaneDropHints({ pane, callbacks }: Pick<TabPanelProps, 'pane' | 'callbacks'>): ReactNode {
|
||||
const target = callbacks.dropTarget
|
||||
const zone = target?.kind === 'zone' && target.paneId === pane.id ? target.zone : undefined
|
||||
if (zone === undefined) return null
|
||||
return <>
|
||||
<div className={css.dockScrim} data-dockkit-dock-scrim />
|
||||
{callbacks.horizontalDrops && zone !== 'center'
|
||||
? <>
|
||||
<DockHint zone="left" active={zone === 'left'} labels={callbacks.labels} />
|
||||
<DockHint zone="right" active={zone === 'right'} labels={callbacks.labels} />
|
||||
</>
|
||||
: <DockHint zone={zone} active labels={callbacks.labels} />}
|
||||
</>
|
||||
}
|
||||
|
||||
/** @param props - pane state and gestures. @returns one pane in the recursive, visibility-mounted layout. */
|
||||
export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode {
|
||||
const active = pane.activeTabId === undefined ? undefined : getTab(state, pane.activeTabId)
|
||||
return (
|
||||
<section
|
||||
className={css.pane}
|
||||
data-dockkit-pane={pane.id}
|
||||
data-dockkit-pane-active={state.activePaneId === pane.id || undefined}
|
||||
// A click on the pane's body or strip focuses the pane, unless it is the
|
||||
// active one already: that click changes nothing and records nothing. The
|
||||
// chips and the strip's controls stop their own clicks: each reports one
|
||||
// intent, and that intent already decides which pane is active.
|
||||
onClick={() => {
|
||||
if (state.activePaneId === pane.id) return
|
||||
callbacks.onFocusPane(pane.id)
|
||||
if (state.activePaneId !== pane.id) callbacks.onFocusPane(pane.id)
|
||||
}}
|
||||
>
|
||||
<div className={css.tabStrip} role="tablist" data-dockkit-strip={pane.id}>
|
||||
<div ref={stripTabs} className={css.stripTabs} role="presentation" data-dockkit-strip-tabs={pane.id}>
|
||||
{pane.tabs.map((tabId, index) => {
|
||||
const tab = getTab(state, tabId)
|
||||
const selected = tabId === pane.activeTabId
|
||||
const closable = callbacks.canCloseTab(tabId)
|
||||
// A pane's lone unclosable chip is a label, not a choice: there is
|
||||
// no other tab to select against and nothing to do to it.
|
||||
const quiet = !closable && pane.tabs.length === 1
|
||||
return (
|
||||
<Fragment key={tabId}>
|
||||
{(index > 0 || stripIndex === index) && (
|
||||
<div
|
||||
className={clsx(css.slot, stripIndex === index && css.slotCaret)}
|
||||
data-dockkit-caret={stripIndex === index ? index : undefined}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
role="tab"
|
||||
aria-selected={selected}
|
||||
tabIndex={selected ? 0 : -1}
|
||||
className={clsx(
|
||||
css.tab,
|
||||
selected && css.tabActive,
|
||||
quiet && css.tabQuiet,
|
||||
callbacks.draggingTabId === tabId && css.tabDragging,
|
||||
)}
|
||||
data-dockkit-tab={tabId}
|
||||
data-dockkit-tab-quiet={quiet || undefined}
|
||||
ref={(element) => {
|
||||
if (element === null) chips.delete(tabId)
|
||||
else chips.set(tabId, element)
|
||||
}}
|
||||
// Focus lands on click, not on press: a state change between
|
||||
// pointerdown and the first pointermove rebuilds this subtree,
|
||||
// and Chromium cancels the pointer when the pressed element is
|
||||
// replaced — which would abandon every drag. A drag that ends
|
||||
// elsewhere fires no click, and its own operation carries focus.
|
||||
onPointerDown={(event) => {
|
||||
// A secondary press is the menu, never a drag.
|
||||
if (event.button === 2) return
|
||||
callbacks.onTabPressed(tabId, event)
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
activate(tabId)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
// Keys on the chip's nested close control are that control's.
|
||||
if (event.target !== event.currentTarget) return
|
||||
const next = chipToFocus(event.key, pane.tabs, tabId)
|
||||
if (next !== undefined) {
|
||||
event.preventDefault()
|
||||
focusChip(next)
|
||||
return
|
||||
}
|
||||
if (selects(event.key)) {
|
||||
event.preventDefault()
|
||||
activate(tabId)
|
||||
}
|
||||
}}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
const anchor = event.currentTarget
|
||||
setMenu(current => current?.tabId === tabId ? undefined : { tabId, anchor })
|
||||
}}
|
||||
>
|
||||
<TabTitle>{callbacks.renderTabTitle?.(tab) ?? tab.title}</TabTitle>
|
||||
{closable && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.tabClose}
|
||||
aria-label={callbacks.labels.closeTab}
|
||||
data-dockkit-tab-close={tabId}
|
||||
// A nested control stops its own press: otherwise the press
|
||||
// starts a drag, captures the pointer, and this click never lands.
|
||||
onPointerDown={(event) => { event.stopPropagation() }}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
callbacks.onCloseTab(tabId)
|
||||
}}
|
||||
>
|
||||
<IconCloseFillRegular size={14} />
|
||||
</button>
|
||||
)}
|
||||
{menu?.tabId === tabId && (
|
||||
<TabMenu
|
||||
labels={callbacks.labels}
|
||||
anchor={menu.anchor}
|
||||
onClose={closable ? () => { setMenu(undefined); callbacks.onCloseTab(tabId) } : undefined}
|
||||
onDismiss={() => { setMenu(undefined) }}
|
||||
extras={callbacks.renderTabMenuItems?.(tab, () => { setMenu(undefined) })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
{stripIndex === pane.tabs.length && <div className={clsx(css.slot, css.slotCaret)} data-dockkit-caret={stripIndex} />}
|
||||
</div>
|
||||
{callbacks.canAddTab(pane.id) && (
|
||||
<Tooltip label={callbacks.labels.addTab} side="bottom" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.addTab}
|
||||
aria-label={callbacks.labels.addTab}
|
||||
data-dockkit-add-tab={pane.id}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
callbacks.onAddTab(pane.id)
|
||||
}}
|
||||
>
|
||||
<IconPlusOutlineRegular size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div className={css.stripFill} data-dockkit-strip-fill />
|
||||
{!(callbacks.hideSplitWhenBlocked && block !== undefined) && (
|
||||
<Tooltip label={callbacks.labels.splitPane} side="bottom" delayMs={500} disabled={block !== undefined}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={callbacks.labels.splitPane}
|
||||
// Disabled buttons fire no hover events, so the blocked reason
|
||||
// stays a native title.
|
||||
title={block === undefined ? undefined : splitBlockedTitle(callbacks.labels, block)}
|
||||
disabled={block !== undefined}
|
||||
data-dockkit-split-button={pane.id}
|
||||
data-dockkit-split-blocked={block}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
callbacks.onSplitPane(pane.id)
|
||||
}}
|
||||
>
|
||||
<SplitGlyph />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* The embedder's surface-wide controls, in the top-right pane only: the
|
||||
strip is the surface's top edge, and this pane's end is its corner. */}
|
||||
{pane.id === callbacks.chromePaneId && callbacks.chrome !== undefined && (
|
||||
// The embedder's controls report their own intents; the pane's
|
||||
// click-to-focus must not add a focus entry to each of them.
|
||||
<div
|
||||
className={css.stripChrome}
|
||||
data-dockkit-strip-chrome
|
||||
onClick={(event) => { event.stopPropagation() }}
|
||||
>
|
||||
{callbacks.chrome}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TabStrip state={state} pane={pane} callbacks={callbacks} />
|
||||
<div className={css.paneBody}>
|
||||
{active === undefined
|
||||
? <p className={css.empty}>{callbacks.labels.emptyPane}</p>
|
||||
: callbacks.renderTab(active)}
|
||||
{zone !== undefined && (
|
||||
<>
|
||||
<div className={css.dockScrim} data-dockkit-dock-scrim />
|
||||
{callbacks.horizontalDrops && zone !== 'center'
|
||||
? <>
|
||||
<DockHint zone="left" active={zone === 'left'} labels={callbacks.labels} />
|
||||
<DockHint zone="right" active={zone === 'right'} labels={callbacks.labels} />
|
||||
</>
|
||||
: <DockHint zone={zone} active labels={callbacks.labels} />}
|
||||
</>
|
||||
)}
|
||||
<PaneDropHints pane={pane} callbacks={callbacks} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -129,6 +129,82 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Every tab remains a sibling. Explicit tracks determine placement; order only
|
||||
changes painting, so raising a float never moves a connected DOM ancestor. */
|
||||
.tabLayout {
|
||||
display: grid;
|
||||
flex: 1 1 auto;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tabCell,
|
||||
.emptyTabHost {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
z-index: var(--dsh-dockkit-dock-layer, 10);
|
||||
}
|
||||
|
||||
.tabCell[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* A zero-sized Grid item supplies a stacking context, not a containing block
|
||||
for its fixed child. Floats share one layer and use Grid paint order within it. */
|
||||
.floatingCell {
|
||||
width: 0;
|
||||
height: 0;
|
||||
z-index: var(--dsh-dockkit-float-layer, 60);
|
||||
}
|
||||
|
||||
.tabHost {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.tabHost:not(.float),
|
||||
.emptyTabHost {
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
.tabCell[data-dockkit-host='dock'][data-dockkit-column='0'] > .tabHost {
|
||||
border-left: 0.5px solid var(--dsw-alias-border-l4);
|
||||
}
|
||||
|
||||
.tabHostHeader {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.tabHostBody {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tabLayoutDivider {
|
||||
width: 0;
|
||||
cursor: col-resize;
|
||||
z-index: calc(var(--dsh-dockkit-dock-layer, 10) + 1);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.tabLayoutDivider::before {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -0.25px;
|
||||
width: 0.5px;
|
||||
}
|
||||
|
||||
.tabLayoutDivider::after {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -4px;
|
||||
right: -4px;
|
||||
background: linear-gradient(to bottom, transparent, var(--dsw-alias-label-caption) 50%, transparent) center / 1px 100% no-repeat;
|
||||
}
|
||||
|
||||
/* A body that overflows scrolls inside its pane; nothing escapes the pane. */
|
||||
.pane {
|
||||
display: flex;
|
||||
@@ -234,6 +310,7 @@
|
||||
:global(html[data-platform='darwin']) .stripTabs,
|
||||
:global(html[data-platform='darwin']) .stripChrome,
|
||||
:global(html[data-platform='darwin']) .paneBody,
|
||||
:global(html[data-platform='darwin']) .float,
|
||||
:global(html[data-platform='darwin']) .divider {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,10 @@ export function followPointer(element: HTMLElement, pointerId: number, followers
|
||||
capturePointer(element, pointerId)
|
||||
const controller = new AbortController()
|
||||
const { signal } = controller
|
||||
element.dataset.dockkitPointer = String(pointerId)
|
||||
signal.addEventListener('abort', () => {
|
||||
if (element.dataset.dockkitPointer === String(pointerId)) delete element.dataset.dockkitPointer
|
||||
}, { once: true })
|
||||
const own = (event: PointerEvent): boolean => event.pointerId === pointerId
|
||||
window.addEventListener('pointermove', (event) => { if (own(event)) followers.move(event) }, { signal })
|
||||
window.addEventListener('pointerup', (event) => {
|
||||
|
||||
@@ -66,3 +66,5 @@ export { DockSurface } from './components/DockSurface.tsx'
|
||||
export type { DockSurfaceProps } from './components/DockSurface.tsx'
|
||||
export { FloatLayer } from './components/FloatLayer.tsx'
|
||||
export type { FloatLayerProps } from './components/FloatLayer.tsx'
|
||||
export { DockLayout } from './components/DockSurface.tsx'
|
||||
export type { DockLayoutProps } from './components/DockSurface.tsx'
|
||||
Loaded 100 of 249 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user