Merge pull request #609 from omdsh-dev/feat/dsh-0.1.5-alpha.2

feat(dsh): adapt to DSH 0.1.5-alpha.2
This commit is contained in:
Atlas Zeng
2026-09-09 23:52:57 +08:00
committed by GitHub
43 changed files with 951 additions and 930 deletions
+2 -2
View File
@@ -131,10 +131,10 @@ jobs:
# Pin the DSH CLI the lane mounts into; the profile's bundle stack
# (dsh-base / dsh-web-app) resolves from this installation. Keep in
# step with the plugin's @deepseek-ai/* peer range when bumping.
# DSH 0.1.5-alpha.1 is published under the `alpha` dist-tag; the lanes
# DSH 0.1.5-alpha.2 is published under the `alpha` dist-tag; the lanes
# speak the slash-only RPC dialect (tests/e2e/host-protocol.ts).
- name: Install dsh CLI (pinned)
run: npm install -g @deepseek-ai/dsh@0.1.5-alpha.1
run: npm install -g @deepseek-ai/dsh@0.1.5-alpha.2
# lib/ is gitignored; the tarball is produced from this build.
- name: Build and pack the npm tarball
+5 -5
View File
@@ -19,13 +19,13 @@
「npm 打包 → 真实挂载 → 无头渲染」门禁(证明打包产物在真实 DSH 挂载后不 crash):`pnpm build && pnpm pack` 产 tarball → `scripts/e2e-mount.sh` 装进全新 scratch profile(`dsh plugin --profile web add <tarball>`)并启动真实 `dsh web`(keyless,`--port 0`)→ `tests/e2e/mount.e2e.ts`(Playwright)断言 `[data-dsh-better-sidebar]` 挂载、无错误条/pageerror/console 错误,展开 DSH 原生右侧栏后经其 guide 页逐个打开插件 tab 类型(含终端懒加载 chunk),再经插件文件树(原生 `files` kind 接管)打开 seed 文件强制加载 editor chunk(`client-editor.js`),并跑 mermaid / README 预览与 sidechat 宿主路由烟测。
本地:`pnpm build && pnpm pack && pnpm exec playwright install chromium && pnpm test:mount`。CI 钉 `@deepseek-ai/dsh@0.1.5-alpha.1`(`alpha` dist-tag;peer 下限 `^0.1.5-alpha.1`)。e2e spec 命名 `*.e2e.ts` + vitest `exclude` 双保险;**改 `exclude` 必须保留默认排除项**(exclude 整体替换默认值)。
本地:`pnpm build && pnpm pack && pnpm exec playwright install chromium && pnpm test:mount`。CI 钉 `@deepseek-ai/dsh@0.1.5-alpha.2`(`alpha` dist-tag;peer 下限 `^0.1.5-alpha.2`)。e2e spec 命名 `*.e2e.ts` + vitest `exclude` 双保险;**改 `exclude` 必须保留默认排除项**(exclude 整体替换默认值)。
---
## 3. DSH 0.1.5 适配要点(0.1.5-alpha.1+ 基线)
## 3. DSH 0.1.5 适配要点(0.1.5-alpha.2+ 基线)
**v0.19.0-alpha.0 起仅支持 DSH 0.1.5-alpha.1+**(peer 下限 `^0.1.5-alpha.1`,CI 钉 `@deepseek-ai/dsh@0.1.5-alpha.1`,DSH alpha 线发在 `alpha` dist-tag)。0.1.2-rc.1 稳定线用户继续用 v0.18.x(npm `latest`)——0.1.5 的会话事件模型与文件打开漏斗都变了,插件不再对 0.1.2 做运行时兼容。发版:release.yml 按版本号是否含 `-` 自动选 `alpha`/`latest` dist-tag。
**v0.19.0-alpha.1 起仅支持 DSH 0.1.5-alpha.2+**(peer 下限 `^0.1.5-alpha.2`,CI 钉 `@deepseek-ai/dsh@0.1.5-alpha.2`,DSH alpha 线发在 `alpha` dist-tag)。alpha.1 用户停留在 v0.19.0-alpha.0——alpha.2 删除了指南条目的 `description` 字段并改了文件地址语法,插件不再对 alpha.1 做运行时兼容。0.1.2-rc.1 稳定线用户继续用 v0.18.x(npm `latest`)——0.1.5 的会话事件模型与文件打开漏斗都变了,插件不再对 0.1.2 做运行时兼容。发版:release.yml 按版本号是否含 `-` 自动选 `alpha`/`latest` dist-tag。
宿主契约(均经真机挂载冒烟 14/14 验证):
@@ -37,8 +37,8 @@
6. **`dsh-subagent` 的 `SUBAGENT_DESCRIPTOR_VERSION` 2 → 3**:sidechat 种子的 `subagent/descriptor` 版本由宿主包盖章,插件不硬编码;测试断言跟随常量(`tests/sidechat-routes.spec.ts`),勿钉字面量。
7. **`@deepseek-ai/dsh-client-runtime` 包已消亡**(继任 seed 是裸名 `dsh-client-store`,无 `/client` 子路径):peerDependencies、devDependencies、`dsh.client.inject`、chunk externals 白名单(`src/client/chunk-loader.ts` / `tsdown.config.ts` / `tests/chunk-loader.spec.ts` / `tests/manifest-consistency.spec.ts` 四处同步)均已无该条目。
8. **e2e scratch profile 的 `minimumReleaseAgeExclude` 含 `'@deepseek-ai/*'`**(`scripts/e2e-mount.sh` / `e2e-aggregate-mount.sh`,与仓库根 `pnpm-workspace.yaml` 同策):alpha 版本常在发布后 24h 内跑 lane,pnpm 11 的 `minimumReleaseAge` 默认会拒装新鲜包。
9. **插件开发树的 dsh-* 传递 peer 需提升为 devDependencies**(alpha.3 首见):`dsh-subagent` 等 npm 包把 `dsh-attachment` 等 dsh-* 姊妹包全部声明为 peerDependencies(由宿主 bundle 树统一提供,宿主侧无此问题),插件仓库若只直接依赖其中一部分,其余 peer 在 pnpm 下会解析到树上残留的旧版——如 `dsh-attachment@0.1.1-rc.1` 缺 `admitPromptContent` 导出、`dsh-subagent` 产物 import 它时测试加载即崩。因此 devDependencies 需涵盖 dev 树实际触达的全部 peer(attachment / code-runtime / scope / session-projection / system-prompt / user-approval / util-time 七个即为此提升,与直接依赖同款精确钉版);`@deepseek-ai/cordis` peer 自 alpha.3 起要求 `^4.0.2`(上游全线 peer 已升)。适配新 alpha 版本时先跑 `pnpm peers check`,把新失配的传递 peer 一并提升进 devDependencies。例外:**`dsh-client-locale` 的 peer/devDep 允许落后于基线**(alpha.5 时上游停在 0.1.2-alpha.3 未发新版)——peer 下限 `^0.1.2-alpha.3` 天然容纳同 tuple 的 alpha.5 运行时,此时保持旧钉版并在 `pnpm-workspace.yaml` 注明,待上游发版再追平;rc.1 起上游恢复发版,peer/devDep 已与基线一并钉 `0.1.2-rc.1`,该例外消除。
10. **右侧栏是 DSH 原生栏,插件只提供 tab 类型**:聊天里一切文件打开(工具行 / 产物行 / 正文提及 / 行内代码路径)统一走 `ctx.sidebarRight.openResource(fileAddressFor(sessionId, cwd, path))`(`packages/client/ui-chat/src/client/apply.ts` 是唯一调用点),`remote.session.openWorkspacePath` 在 0.1.5 客户端已无调用者,插件的 openpath 拦截随之删除。插件的每个 `TabDescriptor` 注册成原生 tab 类型(`kind = descriptor.id`,`extension` 带)+ 原生 tab 体(`sidebar.right.pane.tab` keyed 槽,key = `dsh-better-sidebar:<id>`);`editor` 类型同时认领 `dsh-resource://file/**`(压过内置 `text` 的 `fallback`),并接管内置 `files` 页面 kind(`openTab('files')` 打开插件文件树,注销即复位)。`ctx.sidebarRight.openTab/openResource/close` 只对**在屏会话**写入;跨会话用具体类上的 `openTabIn/openResourceIn/closeIn`(不在 `ISidebarRight` 接口里,需结构化探测),目标会话未挂载时排队到上屏重放(`src/client/native/surface.ts`)。原生 tab 的插件侧状态(合成 `SidebarTab`、树展开集合、实例编号)在 `src/client/native/tab-adapter.tsx`;原生布局只在内存,不做持久化。**注册必须等服务、不能等槽声明**:原生栏先声明 `sidebar.right.pane.tab` 再 `provide('sidebarRightTabs')`,真机 profile(web,0.1.5-alpha.1)上槽声明回调里 `ctx.get('sidebarRightTabs')` 仍是 undefined(实测 3 秒后才出现),按槽触发注册会静默什么都不注册且永不重试——用 `ctx.inject(['sidebarRightTabs'], cb)` 驱动类型注册(`tests/native-surface.spec.ts` 以「槽先触发、服务后到达」的顺序守护),槽声明只用来挂 tab 体。指南 §0 列了全部行为差异。
9. **插件开发树的 dsh-* 传递 peer 需提升为 devDependencies**(alpha.3 首见):`dsh-subagent` 等 npm 包把 `dsh-attachment` 等 dsh-* 姊妹包全部声明为 peerDependencies(由宿主 bundle 树统一提供,宿主侧无此问题),插件仓库若只直接依赖其中一部分,其余 peer 在 pnpm 下会解析到树上残留的旧版——如 `dsh-attachment@0.1.1-rc.1` 缺 `admitPromptContent` 导出、`dsh-subagent` 产物 import 它时测试加载即崩。因此 devDependencies 需涵盖 dev 树实际触达的全部 peer(attachment / code-runtime / scope / session-projection / system-prompt / user-approval / util-time 七个即为此提升,与直接依赖同款精确钉版);`@deepseek-ai/cordis` peer 自 alpha.3 起要求 `^4.0.2`(上游全线 peer 已升)。适配新 alpha 版本时先跑 `pnpm peers check`,把新失配的传递 peer 一并提升进 devDependencies。例外:**`dsh-client-locale` 的 peer/devDep 允许落后于基线**(alpha.5 时上游停在 0.1.2-alpha.3 未发新版)——peer 下限 `^0.1.2-alpha.3` 天然容纳同 tuple 的 alpha.5 运行时,此时保持旧钉版并在 `pnpm-workspace.yaml` 注明,待上游发版再追平;rc.1 起上游恢复发版,peer/devDep 已与基线一并钉 `0.1.2-rc.1`,该例外消除。**alpha.2 起还有一类:上游包自身的 `dependencies` 丢失**——`@deepseek-ai/dsh-client-ui-primitives@0.1.5-alpha.2` 的 manifest 不再声明任何 `dependencies`(alpha.1 声明了 19 个),但 `lib/*.js` 仍裸 import `anser` / `shiki` / `@shikijs/langs/*` / `mdast-util-*` / `micromark-*` / `katex`。宿主由预构建前端 bundle 满足,独立安装的 dev/test 树不会——vitest 一碰 primitives 就 `Cannot find package 'anser'`。此时把 alpha.1 声明的同一组版本提升进 devDependencies(`clsx` 已在 dependencies 无需重复)。
10. **右侧栏是 DSH 原生栏,插件只提供 tab 类型**:聊天里一切文件打开(工具行 / 产物行 / 正文提及 / 行内代码路径)统一走 `ctx.sidebarRight.openResource(fileAddressFor(sessionId, cwd, path))`(`packages/client/ui-chat/src/client/apply.ts` 是唯一调用点),`remote.session.openWorkspacePath` 在 0.1.5 客户端已无调用者,插件的 openpath 拦截随之删除。插件的每个 `TabDescriptor` 注册成原生 tab 类型(`kind = descriptor.id`,`extension` 带)+ 原生 tab 体(`sidebar.right.pane.tab` keyed 槽,key = `dsh-better-sidebar:<id>`);`editor` 类型同时认领 `dsh-resource://file/**`(压过内置 `text` 的 `fallback`),并接管内置 `files` 页面 kind(`openTab('files')` 打开插件文件树,注销即复位)。`ctx.sidebarRight.openTab/openResource/close` 只对**在屏会话**写入;跨会话用具体类上的 `openTabIn/openResourceIn/closeIn`(不在 `ISidebarRight` 接口里,需结构化探测),目标会话未挂载时排队到上屏重放(`src/client/native/surface.ts`)。原生 tab 的插件侧状态(合成 `SidebarTab`、树展开集合、实例编号)在 `src/client/native/tab-adapter.tsx`;原生布局只在内存,不做持久化。**注册必须等服务、不能等槽声明**:原生栏先声明 `sidebar.right.pane.tab` 再 `provide('sidebarRightTabs')`,真机 profile(web,0.1.5-alpha.1)上槽声明回调里 `ctx.get('sidebarRightTabs')` 仍是 undefined(实测 3 秒后才出现),按槽触发注册会静默什么都不注册且永不重试——用 `ctx.inject(['sidebarRightTabs'], cb)` 驱动类型注册(`tests/native-surface.spec.ts` 以「槽先触发、服务后到达」的顺序守护),槽声明只用来挂 tab 体。指南 §0 列了全部行为差异。**alpha.2 起(v0.19.0-alpha.1)**:指南条目删除 `description`(原生指南渲染成「图标+标题」胶囊),插件随之下线 `TabDescriptor.description` 与 6 个 `guideDesc*` 词条;新建标签页的默认页改为从注册表选(恰好 1 个指南条目 → 直接开它,0 或 ≥2 → 开指南),`revealIfOpened` 对「页面」在同一 pane 内强制去重。原生 tab 体宿主 `.paneBody` 是**有确定高度的块级滚动容器**(非 flex 容器),native 适配层因此给每个 tab 体包一层 `height:100%` 的列 flex 宿主(`sidebar.module.css` 的 `.nativeTabHost`),tab 组件根继续用 `flex:1`/`height:100%`——否则根盒塌成内容高度,sidechat 的输入框就贴不到面板底。全局面板(根级 `main` keyed 槽 + `sidebar.panellist` + `ctx.layout.selectPanel/beginNavigation`、`rightbar` 改根级并新增 `rightbar.session`)插件**不接入**,只做兼容。底部工作台的中心列锚点改认 `[data-slot="main.conversation"]`(并跳过 `display: contents` 祖先,同时保留 alpha.1 的 `[data-slot="conversation"]`);文件地址语法跟随 alpha.2(`fileAddressFor` 一律 session 作用域、绝对路径保留前导 `/`、`parseFileAddress` 前缀解析并去 `?`/`#`)。
11. **自定义种子必须带 fork 标记对(`meta.isSeeded: true` + `inheritedEventCount`)**:dsh-session 契约「只给 seed 不标 isSeeded,种子算重放历史而非继承前缀」——缺标记时 `Session.ownEvents()` 含整个种子,子会话的 inbox 折(`inboxProjectionDefinition`,0.1.5 起 `@deepseek-ai/dsh-agent` 只导出 `Inbox` 接口,实现是该投影)重放种子里的 `agent/inbox/spliced`,**继承父会话切割时刻未领取的 inbox 输入**(排队用户消息 next-turn、长回合中工具结果上下文/steering next-step——后者即「上下文很长时侧边对话先把之前的 User msg 发出去」的根因,幽灵消息排在 boundary 之前最先发给模型)。宿主 `session.fork`(api-session-controller)的调用即规范形态;回归由 `tests/sidechat-seed-validation.spec.ts`(真实 `Session.create` + 对 `ownEvents()` 跑 inbox 折)守护。0.1.5 复评:无更优雅的 sidechat 宿主 API(`ContinuableStartSpec` 仍无 seed 字段),`AgentRegistry.create` 接缝补齐标记即为规范用法;`Session.create` 的 header `version` 必须用 `SESSION_FORMAT_VERSION`(0.1.5 是 `3` 字面量类型),勿钉 `0`。
---
+18
View File
@@ -262,6 +262,24 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
**支持的 DSH 版本**:<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.18.1 正式版):0.1.2-rc.1+" src="https://img.shields.io/badge/DSH-0.1.2--rc.1%2B-4d6bfe" /></a> · 完整发布历史见 [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases)
### v0.19.0-alpha.1
> 🧪 **alpha 通道**(npm dist-tag `alpha`,安装 `dsh-better-sidebar@alpha`):本版仅支持 **DSH 0.1.5-alpha.2+**(peer 下限 `^0.1.5-alpha.2`,CI 钉 `@deepseek-ai/dsh@0.1.5-alpha.2`)。0.1.5-alpha.1 请继续用 **v0.19.0-alpha.0**;0.1.2-rc.1 稳定线用 **v0.18.1**(npm `latest`)。
**✨ 新功能**
- 🪟 **每个 tab 体都填满面板**(#609):原生右侧栏的 tab 体宿主是「有确定高度的块级滚动容器」而非 flex 容器,此前插件各 tab 的根只写 `flex: 1`,在块容器里塌成内容高度——侧边对话的输入框因此贴不到面板底(转录一长就被推出可视区)。现在 native 适配层统一给每个 tab 体包一层 `height: 100%` 的列 flex 宿主,插件全部 tab(含第三方 `registerTab` 注册的 descriptor)恢复与底部工作台一致的填满语义。
**🐛 修复**
- 🧭 **跟随 DSH 0.1.5-alpha.2 的文件地址语法**:`fileAddressFor` 一律产出 session 作用域地址、绝对路径保留前导 `/`;`parseFileAddress` 改为前缀解析并忽略 `?`/`#` 后缀。
- 🪟 **底部工作台的中心列定位跟随 alpha.2 全局面板改动**:`conversation` 槽改为根级 `main` keyed 槽下的 `main.conversation`,定位器改认新 key 并跳过 `display: contents` 槽宿主(alpha.1 的旧 key 仍兼容)。
**🧰 CI 与内部**
- 基线推进到 DSH 0.1.5-alpha.2(#609):peer 下限、22 个 devDependency 钉版、CI 挂载车道与 `dsh.plugin.json` engines 同步;`pnpm peers check` 干净(按 §3-9 补提 `dsh-session-persistence` 传递 peer)。
- **移除 `TabDescriptor.description`**:alpha.2 的原生指南条目不再渲染第二行(改为「图标+标题」胶囊),该字段与 6 个 `guideDesc*` 词条(20 份词典)一并下线;新建标签页的默认页改由注册表选(恰好 1 个指南条目则直接打开它)。
### v0.19.0-alpha.0
> 🧪 **alpha 通道**(npm dist-tag `alpha`,安装 `dsh-better-sidebar@alpha`):本版仅支持 **DSH 0.1.5-alpha.1+**(peer 下限 `^0.1.5-alpha.1`,CI 钉 `@deepseek-ai/dsh@0.1.5-alpha.1`)。0.1.2-rc.1 稳定线请继续用 **v0.18.1**(npm `latest`)。
+18
View File
@@ -266,6 +266,24 @@ The GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sid
**Supported DSH versions**: <a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions (v0.18.1): 0.1.2-rc.1+" src="https://img.shields.io/badge/DSH-0.1.2--rc.1%2B-4d6bfe" /></a> · full release history on the [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases) page
### v0.19.0-alpha.1
> 🧪 **alpha channel** (npm dist-tag `alpha`, install `dsh-better-sidebar@alpha`): supports **DSH 0.1.5-alpha.2+** only (peer floor `^0.1.5-alpha.2`, CI pins `@deepseek-ai/dsh@0.1.5-alpha.2`). Stay on **v0.19.0-alpha.0** for 0.1.5-alpha.1; the 0.1.2-rc.1 stable line keeps using **v0.18.1** (npm `latest`).
**✨ New**
- 🪟 **Every tab body now fills its pane** (#609): the native sidebar's tab-body host is a block scroller with a definite height, not a flex container, and the plugin's tab roots declared only `flex: 1` — so they collapsed to content height and the side-chat composer sat right after the transcript instead of at the pane bottom (a long transcript pushed it out of view). The native adapter now wraps every tab body in a `height: 100%` column flex host, restoring the same fill semantics the bottom workbench has for all plugin tabs, third-party `registerTab` descriptors included.
**🐛 Fixes**
- 🧭 **File address grammar follows DSH 0.1.5-alpha.2**: `fileAddressFor` always produces a session-scoped address and absolute paths keep their leading `/`; `parseFileAddress` is prefix-based and ignores `?`/`#` suffixes.
- 🪟 **Center-column anchor follows alpha.2's global panels**: the `conversation` slot became `main.conversation` under the root-scoped keyed `main` slot, so the locator now resolves the new key and skips `display: contents` slot hosts (alpha.1's old key still works).
**🧰 CI & internals**
- Baseline moved to DSH 0.1.5-alpha.2 (#609): peer floor, 22 devDependency pins, the CI mount lane, and `dsh.plugin.json` engines all follow; `pnpm peers check` is clean (hoisted the `dsh-session-persistence` transitive peer per §3-9).
- **`TabDescriptor.description` removed**: alpha.2's native guide entries no longer render a second line (they are icon + title capsules), so the field and the six `guideDesc*` keys (20 dictionaries) are gone; a new pane's default page is now selected from the registry (exactly one guide entry opens that page directly).
### v0.19.0-alpha.0
> 🧪 **alpha channel** (npm dist-tag `alpha`, install `dsh-better-sidebar@alpha`): supports **DSH 0.1.5-alpha.1+** only (peer floor `^0.1.5-alpha.1`, CI pins `@deepseek-ai/dsh@0.1.5-alpha.1`). The 0.1.2-rc.1 stable line keeps using **v0.18.1** (npm `latest`).
+7 -10
View File
@@ -2,7 +2,7 @@
> 面向 **消费插件开发者**:如何让你的插件向 better-sidebar 注册新的侧边栏页面(tab)和文件类型预览器。
>
> 适用版本:**v0.4.0+**(`ctx.betterSidebar` 服务);声明式设置 **v0.4.1+**;text/number 设置行 **v0.11.0+**;badge/生命周期/定向打开/插件设置/版本探测 **v0.12.0+**;select 设置行(`settingSelect`)与外链认领(`urlTarget`)**v0.13.0+**;统一 `@deepseek-ai/cordis` 类型基底 **v0.15.2+**;终端固定(pin)**v0.17.0+**。当前版本 **v0.19.0-alpha.0**(alpha 通道,仅支持 DSH **0.1.5-alpha.1+**;0.1.2-rc.1 稳定线请用 v0.18.x)。**v0.19.0 移除了自绘右侧面板与自由窗口**(见 §0、§11)。
> 适用版本:**v0.4.0+**(`ctx.betterSidebar` 服务);声明式设置 **v0.4.1+**;text/number 设置行 **v0.11.0+**;badge/生命周期/定向打开/插件设置/版本探测 **v0.12.0+**;select 设置行(`settingSelect`)与外链认领(`urlTarget`)**v0.13.0+**;统一 `@deepseek-ai/cordis` 类型基底 **v0.15.2+**;终端固定(pin)**v0.17.0+**。当前版本 **v0.19.0-alpha.1**(alpha 通道,仅支持 DSH **0.1.5-alpha.2+**;**0.1.5-alpha.1 不再支持**——alpha.1 用户请用 v0.19.0-alpha.0,那是它的最后一版;0.1.2-rc.1 稳定线请用 v0.18.x)。**v0.19.0 移除了自绘右侧面板与自由窗口**(见 §0、§11)。
> 权威代码:`src/client/service.ts`(服务实现)、`src/client/builtins/`(内置 8 tab + 6 viewer 参考实现)、`lib/types/client/service.d.ts`(类型声明)。
> 仓库开发规则(硬约束 / CI / 发版)见 [AGENTS.md](../AGENTS.md)。
@@ -24,10 +24,12 @@
| 去重 | 原生按 `(kind, 地址)` 去重:有 `createTab` 的类型每次新开一个 tab(terminal / browser / sidechat / diff),其余聚焦已有 tab;`dedupeKey` 的自定义语义不参与原生面 |
| 布局持久化 | 原生栏的布局**只在内存**(刷新后回到折叠默认),插件自己的底部工作台仍然持久化 |
| 跨会话打开 | 目标会话的右侧栏 store 未挂载时,打开会排队到该会话上屏后重放 |
| 内置类型接管 | 插件的 `editor` 类型以 `extension` 优先级认领 `dsh-resource://file/**`(压过内置 `text` 预览),并接管内置 `files` 页面 kind(`openTab('files')` 打开插件的文件树);插件卸载/禁用时内置实现自动复位 |
| 内置类型接管 | 插件的 `editor` 类型以 `extension` 优先级认领 `dsh-resource://file/**`(压过内置 `ui-sidebar-documentpreview` 的 `text` 预览——即 `fallback` 带),并接管内置 `files` 页面 kind(`openTab('files')` 打开插件的文件树);插件卸载/禁用时内置实现自动复位 |
| 终端上限 | 终端 tab 的数量上限只统计插件自己底部工作台里的终端;原生栏里的终端不计入 |
| 底部工作台的开合 | 落到底部工作台的打开一律展开它(新建与聚焦都算),因此 `openTab` 的落点永远可见;开合按钮注册在 DSH 会话头的 utilities 槽(`conversation.session.header.utilities`),不在插件自己的宿主里 |
| 新建标签页列表 | 每个 tab 类型在原生 guide 里占一行:标题取 `title`,说明取 `description`(未声明则回退通用句),图标取 `icon`;`hidden: true` 的类型不占行。插件的 `editor` 类型不再单独占行(它认领的文件资源由 `files` 接管页承载同一视图) |
| 新建标签页列表 | 每个 tab 类型在原生 guide 里占一行:标题取 `title` + 图标取 `icon`(**无第二行说明**——alpha.2 起原生指南把条目渲染成「图标+标题」胶囊,`description` 已从宿主契约删除,插件随之移除 `TabDescriptor.description` 与 6 个 `guideDesc*` 词条);`hidden: true` 的类型不占行。插件的 `editor` 类型不再单独占行(它认领的文件资源由 `files` 接管页承载同一视图) |
| 新建面板的种子(alpha.2) | 在新会话打开原生新面板时,宿主从已注册的 guide 条目里播种:恰好 1 个条目 → 直接打开那一页;0 或 ≥2 个条目 → 打开指南。`revealIfOpened` 打开的「页面」在**同一 pane 内**强制去重(已在该 pane 就不再新建);由已有 tab 地址驱动的打开不受该去重影响 |
| alpha.2 全局面板(不接入) | 插件**不采用** alpha.2 引入的全局主面板模型——根级 keyed `main` 槽(预留 key `conversation`,由 ui-conversation 注册为 `main.conversation`)、根级 `sidebar.panellist` 列表槽(`SidebarPanelMetadata` / `SidebarPanelIconOwnerProps`)、`ctx.layout.selectPanel(MainPanelId|null)` / `beginNavigation()` / `dispose()`、全局标准 prop `usePanelInfo`,以及改根级并新增会话级 `rightbar.session` 子槽的 `rightbar`——这些只作兼容保留,不向其迁移 |
| 已移除 | 插件自绘右侧面板(含宽度拖拽 / 新会话默认宽度)与**自由窗口**(`features` 里的 `'floatWindows'` 已删除,v0.18.x 及更早版本的消费者请勿再 gate 该能力);`openByDefault` / `defaultWidthPercent` / `changesDiffFloat` 三个设置项同步删除(旧文档里的键会被忽略) |
---
@@ -179,12 +181,6 @@ interface TabDescriptor {
id: string
/** 标题(i18n 友好:传字符串或返回字符串的函数) */
title: string | (() => string)
/**
* 一行说明,渲染在标题下方(DSH 原生右侧栏的新建标签页 / guide 列表)。
* 不声明时回退到通用句「在 DSH 原生右侧栏打开」——所有插件页面就都长一样了,
* 所以请写清这一页到底做什么。函数形式在渲染时求值,跟随语言。
*/
description?: string | (() => string)
/** 图标:ReactNode 或 (size: number) => ReactNode */
icon?: ReactNode | ((size: number) => ReactNode)
/** + 菜单排序(升序);默认 100。内置:editor=10, git=20, subagent=30, sidechat=35, terminal=40, browser=50 */
@@ -761,6 +757,7 @@ ctx.effect(() =>
| **第三语言覆盖(ja 等)** | 可选 peer `@huanlin/dsh-plugin-better-locale`(optional)提供 ja/ko 覆盖,**借用 DSH 英文槽位**(仅 DSH=en 时生效,zh 下惰性)。经 `ctx.get('betterLocale')` 注入 `t()`;未安装整段 no-op |
| **懒加载 chunk** | 重依赖(xterm/CodeMirror)在独立 bundle(`lib/client-<name>.js`),经 `/sidebar/bundle` 按需下发;factory 赋到 `globalThis.__dshChunks__[<name>]`,由 `src/client/chunk-loader.ts` 物化,**不经** `__ModuleLoader__`。对消费插件透明 |
| **聊天文件打开漏斗(0.1.5 宿主)** | 聊天里一切文件打开(工具行 / 产物行 / 正文提及 / 行内代码路径)统一走 DSH 原生右侧栏的 `ctx.sidebarRight.openResource(fileAddressFor(sessionId, cwd, path))`(`packages/client/ui-chat/src/client/apply.ts` 是唯一调用点);`remote.session.openWorkspacePath` 在 0.1.5 客户端**已无调用者**,better-sidebar 的 openpath 拦截随之删除。你的插件若要观测/旁路聊天文件打开,注册原生 tab 类型认领 `dsh-resource://file/**`(见 [AGENTS.md §3](AGENTS.md) 第 10 条);不要假设 `ctx.workspaces.openPath`(已删除)或旧 `remote.session` 漏斗存在 |
| **原生 tab 体的高度契约** | 你的 tab 组件被挂在**全高列 flex 宿主**里:native 适配层给每个 tab 体(含 orphaned 回退)包一层 `height:100%` 的 `sidebar.module.css` `.nativeTabHost`(带 `data-dsh-native-tab-host`)——根用 `flex: 1` / `height: 100%` 并加 `min-height: 0` 填满面板。宿主的面板体 `.paneBody`(`dsh-client-ui-dockkit`)是**有确定高度的块级滚动容器,不是 flex 容器**,因此只靠外层 flex 容器支撑的根会塌成内容高度(sidechat 的输入框就贴不到面板底);DSH 自己的 tab 体(`ui-sidebar-documentpreview` 的文档预览、`ui-sidebar-files` 的 FilesBody)正是因此在根上声明 `height: 100%` |
---
@@ -783,7 +780,7 @@ ctx.effect(() =>
- **面板表面**:右/底面板背景 = `var(--dsw-alias-bg-layer-1)`。**绝不消费 `--dsw-specific-sidebar-fill`**(宿主左导航专属,皮肤按左导航语义覆盖它,面板消费会失去填充)。换面板表面 = 覆写 `--dsw-alias-bg-layer-1`。
- **终端/编辑器表面**:`effectiveTokenValue` 读 `--dsw-alias-bg-base`——`transparent` 与 alpha < 0.9 的半透明值回退不透明底色(文字不叠背景画,issue #90);≥ 0.9 放行。
- **根锚点**:宿主 div 带 `data-dsh-better-sidebar`(append 到 body);其内**面板宿主层** `[data-dsh-panel-host]`(`fixed; inset:0; z-25; pointer-events:none; overflow:hidden+clip`,v0.13.1+),面板/开关簇 absolute 定位,免疫中间层 transform 劫持;页面级 transform 触发 `data-dsh-panel-host-degraded` 降级。`overflow` 级联是**契约**(`hidden` 兜底 + `clip` 收尾,`tests/panel-host-css.spec.ts` 守护):`hidden` 盒子仍是滚动容器,脚本滚动或浏览器 scroll-into-view 修正(焦点移入视口外区域、嵌套 iframe/工作台加载时抢焦点、面板滑出动画中 focus() 落点)会沿最近可滚祖先滚走整层——面板与开关簇集体偏离视口角(computed left/right 仍"正确",偏移藏在盒子自身 scroll offset 里);`clip` 裁剪语义相同但不产生滚动盒,任何路径都滚不动这层。皮肤作用域覆盖限定在 `[data-dsh-better-sidebar]` 内。
- **布局变量**(`<html>` 上,面板打开时有效):`--dsh-sidebar-width` / `--dsh-sidebar-height`。右面板宽度 = AppFrame 的 `padding-right` 预留(新版 `#root [data-dsh-frame]` / rc.8 `#root > [data-slot="root"] > div` 双锚点),AppFrame border box 保持完整桌面视口宽度(Harness 以此判定桌面/窄屏布局,避免插件面板展开误入窄屏);AppFrame 的 details 拖拽手柄按同一变量向左平移贴合列边缘。底部面板仍走 centerCol `margin-bottom`;centerCol 锚点 = **JS 标注**(禁止 `nth-child`):侧栏 shell 的定位器给测得的 centerCol 节点打 `[data-dsh-center-col]` 标签(`Sidebar.tsx` locate,节点更换/HMR 时随 ref 迁移),`layout.css` 用 `#root [data-dsh-center-col]` 选中(`drag-layout.e2e.ts` 断言恰一节点且为 `[data-slot="conversation"]` 的父级;frame 宽度与桌面 Session Log 由 `desktop-layout.e2e.ts` 断言)。
- **布局变量**(`<html>` 上,面板打开时有效):`--dsh-sidebar-width` / `--dsh-sidebar-height`。右面板宽度 = AppFrame 的 `padding-right` 预留(新版 `#root [data-dsh-frame]` / rc.8 `#root > [data-slot="root"] > div` 双锚点),AppFrame border box 保持完整桌面视口宽度(Harness 以此判定桌面/窄屏布局,避免插件面板展开误入窄屏);AppFrame 的 details 拖拽手柄按同一变量向左平移贴合列边缘。底部面板仍走 centerCol `margin-bottom`;centerCol 锚点 = **JS 标注**(禁止 `nth-child`):侧栏 shell 的定位器给测得的 centerCol 节点打 `[data-dsh-center-col]` 标签(`Sidebar.tsx` locate,节点更换/HMR 时随 ref 迁移),`layout.css` 用 `#root [data-dsh-center-col]` 选中(`drag-layout.e2e.ts` 断言恰一节点且为对话槽宿主的父级——alpha.2 起 shell 把 `#root [data-slot="main.conversation"]` 解析进列并跳过 `display: contents` 祖先(`center-column.ts` 的 `CENTER_COLUMN_SELECTOR` 同时认 `main.conversation` 与 alpha.1 的 `conversation`),定位器从槽宿主向上取第一个非 `contents` 的祖先;frame 宽度与桌面 Session Log 由 `desktop-layout.e2e.ts` 断言)。
- **桌面信号与标题栏**(v0.14.1+ 四方案模型 `SidebarPrefs.titleBarScheme`,唯一决策点 `src/client/titlebar-strip.ts` 纯函数):
- 壳信号(只读,不自动触发修改):URL `dsh-desktop-mode` / `dsh-desktop-platform` / 可选 `dsh-desktop-titlebar-inset`(0–120 clamp)。
- **strip 取值链**:⓪ `web` 方案强制 0;① `navigator.windowControlsOverlay` 真实几何(`wco.ts` 订阅 `geometrychange`,**为 0 也权威**,`visible=false` 幽灵 API 视为缺失);② URL inset;③ 壳预设 `stripFor`(仅 `preset`);④ 手动 `titleBarStripPx`(仅 `custom`);⑤ 0。驱动 `body[data-dsh-title-bar-compat]` + `--dsh-title-bar-strip`。
@@ -0,0 +1,80 @@
# DSH 0.1.5-alpha.2 适配(v0.19.0-alpha.1 基线)
日期:2026-09-09 分支:`feat/dsh-0.1.5-alpha.2`
## 背景
DSH `dsh-v0.1.5-alpha.1..dsh-v0.1.5-alpha.2` 的宿主契约里有一批会实际波及本插件的变更。本计划记录哪些值得处理、哪些只做兼容、以及每一处的子系统级落点。插件对外的全部行为差异以 [docs/external-plugin-guide.md](../external-plugin-guide.md) 为准;仓库级适配要点(基线、CI 钉版、宿主契约)同步到 [AGENTS.md](../AGENTS.md) §3。
## 上游 delta(alpha.1 → alpha.2,影响本插件的六项)
| # | 上游变更 | 对插件的影响 |
|---|---|---|
| 1 | `SidebarRightGuideEntry` 去掉 `description` 字段;指南条目变为「图标 + 标题」胶囊 | 删除 `TabDescriptor.description` 与 6 个 `guideDesc*` 词典键(连同 `nativeGuideDesc` 兜底句——不再有第二行说明) |
| 2 | 新增**全局面板**:根级 keyed `main` 槽(预留 key `conversation`,由 ui-conversation 注册为 `main.conversation`)、根级 `sidebar.panellist` 列表槽(`SidebarPanelMetadata` / `SidebarPanelIconOwnerProps`)、`ctx.layout.selectPanel(MainPanelId|null)` / `beginNavigation()` / `dispose()`、全局标准 prop `usePanelInfo`;`rightbar` 改根级并新增会话级 `rightbar.session` 子槽 | 插件**不接入**(兼容 only) |
| 3 | 原生 tab 体宿主 `.paneBody`(`dsh-client-ui-dockkit`)是**有确定高度的块级滚动容器**,不是 flex 容器;DSH 自己的 tab 体(`ui-sidebar-documentpreview` 的文档预览、`ui-sidebar-files` 的 FilesBody)在根上声明 `height: 100%` | 原生适配层给每个 tab 体包一层 `height: 100%` 的列 flex 宿主(`sidebar.module.css` `.nativeTabHost`,带 `data-dsh-native-tab-host`),修复 sidechat 输入框贴不到面板底的根塌陷 |
| 4 | 文件地址语法:`fileAddressFor` 一律返回 session 作用域地址(绝对路径保留前导 `/`);`parseFileAddress` 前缀解析并去 `?`/`#`;`absoluteFileAddress` 仍在 | 镜像 alpha.2 语法(插件自实现于 `src/client/resource-address.ts`——客户端 bundle 纯度门禁禁 value-import 未列名的 `@deepseek-ai/*`,不能直接引 DSH 工具包) |
| 5 | `ui-sidebar-textpreview` 被 `ui-sidebar-documentpreview` 取代(同 `text` kind、`fallback` 带、`dsh-resource://file/**` 模式) | 插件 `editor` 仍以 `extension` 优先级认领 `dsh-resource://file/**`,压过其 `text` 的 `fallback` 带 |
| 6 | 基线升到 0.1.5-alpha.2(peer 下限 / manifest / CI 钉版) | 插件基线 = alpha.2 only;alpha.1 用户留在 v0.19.0-alpha.0 |
## 决策
- **基线 = alpha.2 only**:peer 下限 `^0.1.5-alpha.2`、manifest `>=0.1.5-alpha.2`、CI 钉 `@deepseek-ai/dsh@0.1.5-alpha.2`;不再对 alpha.1 做运行时兼容。
- **全局面板:兼容 only,不接入**——不迁移到 `main` 槽 / `sidebar.panellist` / `ctx.layout` / `usePanelInfo` / 根级 `rightbar`,仅保证在 alpha.2 宿主上不破坏。
- **删除 `TabDescriptor.description`**:宿主不再渲染第二行,字段留着只会在类型上撒谎;同步删 6 个 `guideDesc*` 与 `nativeGuideDesc` 兜底句。
- **修复 tab 体填充**:native 适配层为每个 tab 体(含 orphaned 回退)包 `height:100%` 列 flex 宿主 `.nativeTabHost`;tab 组件根遵循 `flex:1` / `height:100%` + `min-height:0` 契约。
- **镜像 alpha.2 地址语法**:`fileAddressFor` 不再产生 `absolute` 作用域(但仍解析旧地址与第三方地址),`parseFileAddress` 前缀解析并去 `?`/`#`。
- **中心列锚点跟随 alpha.2**:`center-column.ts` 的 `CENTER_COLUMN_SELECTOR` 改为 `#root [data-slot="main.conversation"], #root [data-slot="conversation"]`,`resolveCenterColumn` 从对话槽宿主向上跳过 `display: contents` 祖先取真实列;alpha.1 的 `[data-slot="conversation"]` 依旧认可。
## 改动清单(子系统级)
| 子系统 | 文件 | 变更 |
|---|---|---|
| 基线 | `package.json` | 版本 → `0.19.0-alpha.1`;dsh-* peer 全部 `^0.1.5-alpha.2`;devDeps 同步钉 `0.1.5-alpha.2` |
| 基线 | `pnpm-lock.yaml` | 锁文件对齐 alpha.2 |
| 基线 | `dsh.plugin.json` | `version` → `0.19.0-alpha.1`;`engines.dsh` → `>=0.1.5-alpha.2` |
| 基线 | `.github/workflows/ci.yml` | mount lane 钉 `@deepseek-ai/dsh@0.1.5-alpha.2` |
| 基线 | `package.json` | 按 §3-9 补提 `dsh-session-persistence`(`pnpm peers check` 唯一新失配项) |
| 基线 | `package.json` | **上游打包缺陷**:`@deepseek-ai/dsh-client-ui-primitives@0.1.5-alpha.2` 的 manifest 不再声明任何 `dependencies`(alpha.1 声明了 19 个),但其 `lib/*.js` 仍裸 import `anser` / `shiki` / `@shikijs/langs/*` / `mdast-util-*` / `micromark-*` / `katex`。宿主自身由预构建前端 bundle 满足,独立安装的消费方(本仓库的 dev/test 树)必须自备,故把 alpha.1 声明的同一组版本提升进 devDependencies(`@shikijs/langs`、`@types/mdast`、`anser`、`katex`、`mdast-util-from-markdown/gfm/math`、`micromark-core-commonmark/extension-gfm/extension-math/factory-space/util-character/util-classify-character/util-sanitize-uri/util-symbol/util-types`、`shiki`;`clsx` 已在 dependencies) |
| 基线/文档 | `AGENTS.md` | §2/§3 基线戳、§10 补 alpha.2 行为差异(本 workstream 回填) |
| Tab 契约 | `src/client/service.ts` | `TabDescriptor` 删除 `description` 字段;`SIDEBAR_SERVICE_VERSION` → `0.19.0-alpha.1` |
| Tab 契约 | `src/client/builtins/tabs.tsx` | 6 个内置 tab 删除 `description` 行 |
| Tab 契约 | `src/client/native/index.ts` | native 注册的 guide 条目去掉 `description`(删 `descriptionOf` 与通用兜底);`guide` 条目只剩 `order`/`title`/`icon` |
| 词典 | `src/client/locales.ts` + 各 `locales-*.ts` | 删除 `nativeGuideDesc` + 6 个 `guideDesc*` 词条(zh/en + 全部第三方语言) |
| Tab 高度 | `src/client/native/tab-adapter.tsx` | `NativeTabBody` 把 live 组件与 orphaned 回退都包进 `css.nativeTabHost`(带 `data-dsh-native-tab-host`) |
| Tab 高度 | `src/client/sidebar.module.css` | 新增 `.nativeTabHost`(`display:flex; flex-direction:column; height:100%; min-height:0`) |
| 地址语法 | `src/client/resource-address.ts` | 重写为镜像 alpha.2 文件地址语法(`fileAddressFor` 不再产 `absolute` 作用域、`parseFileAddress` 前缀去 `?`/`#`、保留 `absolute` 解析) |
| 中心列锚点 | `src/client/center-column.ts` | `CENTER_COLUMN_SELECTOR` 加 `main.conversation`;新增 `columnOfSlotHost` 跳过 `display: contents` 祖先 |
| 中心列锚点 | `src/client/sidebar/use-center-column.ts` | 宿主查找逻辑对齐 alpha.2 |
| 中心列锚点 | `src/client/layout.css` | 选择器/注释同步 `main.conversation` 与 `display: contents` 跳过 |
| 测试 | `tests/builtins.spec.ts` | 指南断言从 `description` 改成 `title`(图标+标题胶囊) |
| 测试 | `tests/native-surface.spec.ts` | 注册/guide 断言去 `description`;断言 guide 条目不再携带 `description` |
| 测试 | `tests/center-column-locator.spec.ts` | 覆盖 `main.conversation` 解析与 `display: contents` 跳过 |
| 测试 | `tests/resource-address.spec.ts` | 覆盖 alpha.2 地址语法 |
| 测试 | `tests/e2e/mount.e2e.ts` | guide 页断言跟随胶囊形态;新增 tab 体填充断言(可见 tab 的 `[data-dsh-native-tab-host]` 盒高 == 其带盒祖先高 ±2px,且 sidechat composer 底边距面板底 ≤12px) |
| 文档 | `docs/external-plugin-guide.md` | §0 行为差异表、§4.1 删 `description`、§10 加 tab 体高度契约、§12 中心列锚点、`ui-sidebar-documentpreview` |
| 文档 | `docs/plans/2026-09-09-dsh-0.1.5-alpha.2-adaptation.md` | 本计划 |
## 已知差距
- **side-chat 线程仍只携带 `subagent/descriptor`,不携带 alpha.2 的 `subagent/catalog`**:DSH 自己的直接子代理发现现在优先读 `catalog`,而我们沿用的 `subagents.listDescendants`(`src/subagent-live-route.ts`)与转录种子的 `type: 'subagent/descriptor'`(`src/sidechat-routes.ts`)路径不变。catalog 只作为新增读面出现,不构成对现有路径的破坏,本期不跟进。
## 验证
- **门禁**(本分支 HEAD):`pnpm typecheck` / `pnpm lint` / `pnpm check:consumer-types` 全绿;`pnpm vitest run --exclude '**/*.e2e.ts'` = 124 files / 1295 passed / 9 skipped;`pnpm peers check` = No peer dependency issues found。
- **真机挂载冒烟**(`DSH_CMD` 指向 npm 安装的 DSH 0.1.5-alpha.2,`scripts/e2e-mount.sh`):7 passed。新增的 tab 体填充断言(可见 tab 的 `[data-dsh-native-tab-host]` 盒高 == 其带盒祖先 ±2px,sidechat composer 底边距面板底 ≤12px)是这次修复的回归守护——它在修复前失败(`gap -862px`,因为断言最初漏跳过 `display: contents` 槽宿主)。
- **真机 3080(web profile 升到 alpha.2 + 插件 0.19.0-alpha.1)**:
- 原生 guide 六行均为 48px「图标+标题」胶囊,无第二行:文件 / 文件变动 / 任务管理 / 侧边对话(beta) / 终端 / 浏览器。
- 文件树点击 `AGENTS.md` → tab 标题 `AGENTS.md` + CodeMirror 挂载(`editor` 接管成立)。
- **sidechat 输入框贴底**:原生右侧栏 `hostHeight 962 == paneBodyHeight 962`(fillGap 0),composer 底边距面板底 8px(即它自己的 margin);底部工作台同样 8px。
- 底部工作台中心列锚点:`[data-dsh-bottom-panel]` 与 `[data-dsh-center-col]` 左右边完全重合(gap 0)——证明 `main.conversation` + 跳过 `display: contents` 的解析在真机成立;真机 `[data-slot]` 清单同时确认 `main` / `main.conversation` / `rightbar.session` 已存在、旧 `conversation` 槽已消失。
- `pageerror` 0 条;控制台仅 `POST /api/dsh-worktree/attach` 500(第三方 dsh-tauri-worktree 插件,与本次改动无关)。
- 未覆盖:**聊天里的文件链接点击**——当前可见会话没有可解析的文件提及(内联 `code` 是纯文本,不是链接),该漏斗(`ctx.sidebarRight.openResource(fileAddressFor(...))`)由挂载车道与 `tests/resource-address.spec.ts` 覆盖,本实例未实测。
- 仓库没有守护指南文档章节的测试;`tests/builtins.spec.ts` / `tests/native-surface.spec.ts` 守护的是 guide 行形态而非文档文本。
- 早期本分支相关测试曾整体收集失败:`@deepseek-ai/dsh-client-ui-primitives@0.1.5-alpha.2` 在模块加载时裸 `import 'anser'`,而该包 manifest 已不再声明依赖。补齐 devDependencies 后恢复(见改动清单)。
## 不做
- 不接入 alpha.2 全局面板(`main` 槽 / `sidebar.panellist` / `ctx.layout` / `usePanelInfo` / 根级 `rightbar`)。
- 不引入 `subagent/catalog` 读取面。
- 不改 DSH 源码(仓库硬约束 §1)。
+2 -2
View File
@@ -1,10 +1,10 @@
{
"id": "dsh-external/dsh-better-sidebar",
"version": "0.19.0-alpha.0",
"version": "0.19.0-alpha.1",
"main": "./lib/index.js",
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
"engines": {
"dsh": ">=0.1.5-alpha.1"
"dsh": ">=0.1.5-alpha.2"
},
"contributes": {
"tools": [],
+55 -37
View File
@@ -1,6 +1,6 @@
{
"name": "dsh-better-sidebar",
"version": "0.19.0-alpha.0",
"version": "0.19.0-alpha.1",
"description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.",
"type": "module",
"repository": {
@@ -89,20 +89,20 @@
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.2",
"@deepseek-ai/dsh-agent": "^0.1.5-alpha.1",
"@deepseek-ai/dsh-client-locale": "^0.1.5-alpha.1",
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.5-alpha.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.5-alpha.1",
"@deepseek-ai/dsh-client-ui-settings": "^0.1.5-alpha.1",
"@deepseek-ai/dsh-client-ui-sidebar-right": "^0.1.5-alpha.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.1.5-alpha.1",
"@deepseek-ai/dsh-host-webserver": "^0.1.5-alpha.1",
"@deepseek-ai/dsh-invariants": "^0.1.5-alpha.1",
"@deepseek-ai/dsh-llm": "^0.1.5-alpha.1",
"@deepseek-ai/dsh-session": "^0.1.5-alpha.1",
"@deepseek-ai/dsh-settings": "^0.1.5-alpha.1",
"@deepseek-ai/dsh-subagent": "^0.1.5-alpha.1",
"@deepseek-ai/dsh-tools": "^0.1.5-alpha.1",
"@deepseek-ai/dsh-agent": "^0.1.5-alpha.2",
"@deepseek-ai/dsh-client-locale": "^0.1.5-alpha.2",
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.5-alpha.2",
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.5-alpha.2",
"@deepseek-ai/dsh-client-ui-settings": "^0.1.5-alpha.2",
"@deepseek-ai/dsh-client-ui-sidebar-right": "^0.1.5-alpha.2",
"@deepseek-ai/dsh-client-ui-slots": "^0.1.5-alpha.2",
"@deepseek-ai/dsh-host-webserver": "^0.1.5-alpha.2",
"@deepseek-ai/dsh-invariants": "^0.1.5-alpha.2",
"@deepseek-ai/dsh-llm": "^0.1.5-alpha.2",
"@deepseek-ai/dsh-session": "^0.1.5-alpha.2",
"@deepseek-ai/dsh-settings": "^0.1.5-alpha.2",
"@deepseek-ai/dsh-subagent": "^0.1.5-alpha.2",
"@deepseek-ai/dsh-tools": "^0.1.5-alpha.2",
"@huanlin/dsh-plugin-better-locale": "^0.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
@@ -149,39 +149,57 @@
},
"devDependencies": {
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-agent": "0.1.5-alpha.1",
"@deepseek-ai/dsh-agent-loop": "0.1.5-alpha.1",
"@deepseek-ai/dsh-attachment": "0.1.5-alpha.1",
"@deepseek-ai/dsh-client-locale": "0.1.5-alpha.1",
"@deepseek-ai/dsh-client-ui-conversation": "0.1.5-alpha.1",
"@deepseek-ai/dsh-client-ui-primitives": "0.1.5-alpha.1",
"@deepseek-ai/dsh-client-ui-settings": "0.1.5-alpha.1",
"@deepseek-ai/dsh-client-ui-sidebar-right": "0.1.5-alpha.1",
"@deepseek-ai/dsh-client-ui-slots": "0.1.5-alpha.1",
"@deepseek-ai/dsh-code-runtime": "0.1.5-alpha.1",
"@deepseek-ai/dsh-host-webserver": "0.1.5-alpha.1",
"@deepseek-ai/dsh-invariants": "0.1.5-alpha.1",
"@deepseek-ai/dsh-llm": "0.1.5-alpha.1",
"@deepseek-ai/dsh-scope": "0.1.5-alpha.1",
"@deepseek-ai/dsh-session": "0.1.5-alpha.1",
"@deepseek-ai/dsh-session-projection": "0.1.5-alpha.1",
"@deepseek-ai/dsh-settings": "0.1.5-alpha.1",
"@deepseek-ai/dsh-subagent": "0.1.5-alpha.1",
"@deepseek-ai/dsh-system-prompt": "0.1.5-alpha.1",
"@deepseek-ai/dsh-tools": "0.1.5-alpha.1",
"@deepseek-ai/dsh-user-approval": "0.1.5-alpha.1",
"@deepseek-ai/dsh-util-time": "0.1.5-alpha.1",
"@deepseek-ai/dsh-agent": "0.1.5-alpha.2",
"@deepseek-ai/dsh-agent-loop": "0.1.5-alpha.2",
"@deepseek-ai/dsh-attachment": "0.1.5-alpha.2",
"@deepseek-ai/dsh-client-locale": "0.1.5-alpha.2",
"@deepseek-ai/dsh-client-ui-conversation": "0.1.5-alpha.2",
"@deepseek-ai/dsh-client-ui-primitives": "0.1.5-alpha.2",
"@deepseek-ai/dsh-client-ui-settings": "0.1.5-alpha.2",
"@deepseek-ai/dsh-client-ui-sidebar-right": "0.1.5-alpha.2",
"@deepseek-ai/dsh-client-ui-slots": "0.1.5-alpha.2",
"@deepseek-ai/dsh-code-runtime": "0.1.5-alpha.2",
"@deepseek-ai/dsh-host-webserver": "0.1.5-alpha.2",
"@deepseek-ai/dsh-invariants": "0.1.5-alpha.2",
"@deepseek-ai/dsh-llm": "0.1.5-alpha.2",
"@deepseek-ai/dsh-scope": "0.1.5-alpha.2",
"@deepseek-ai/dsh-session": "0.1.5-alpha.2",
"@deepseek-ai/dsh-session-persistence": "0.1.5-alpha.2",
"@deepseek-ai/dsh-session-projection": "0.1.5-alpha.2",
"@deepseek-ai/dsh-settings": "0.1.5-alpha.2",
"@deepseek-ai/dsh-subagent": "0.1.5-alpha.2",
"@deepseek-ai/dsh-system-prompt": "0.1.5-alpha.2",
"@deepseek-ai/dsh-tools": "0.1.5-alpha.2",
"@deepseek-ai/dsh-user-approval": "0.1.5-alpha.2",
"@deepseek-ai/dsh-util-time": "0.1.5-alpha.2",
"@huanlin/dsh-plugin-better-locale": "^0.1.0",
"@playwright/test": "^1.62.1",
"@shikijs/langs": "^4.3.1",
"@types/mdast": "^4.0.4",
"@types/node": "^24.0.0",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.1",
"@types/ws": "^8.5.10",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"anser": "^2.3.5",
"cordis": "^4.0.0-rc.8",
"jsdom": "^29.1.1",
"katex": "^0.16.47",
"lightningcss": "^1.32.0",
"mdast-util-from-markdown": "^2.0.3",
"mdast-util-gfm": "^3.1.0",
"mdast-util-math": "^3.0.0",
"micromark-core-commonmark": "^2.0.3",
"micromark-extension-gfm": "^3.0.0",
"micromark-extension-math": "^3.1.0",
"micromark-factory-space": "^2.0.1",
"micromark-util-character": "^2.1.1",
"micromark-util-classify-character": "^2.0.1",
"micromark-util-sanitize-uri": "^2.0.1",
"micromark-util-symbol": "^2.0.1",
"micromark-util-types": "^2.0.2",
"shiki": "^4.3.1",
"react": "^18.2.0",
"react-dom": "18.2.0",
"tsdown": "^0.22.2",
+429 -611
View File
File diff suppressed because it is too large Load Diff
-6
View File
@@ -82,7 +82,6 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
return [
{
id: 'editor',
description: () => t('guideDescFiles'),
// The single files window: an editor tab with no path IS the file
// explorer (empty hint + docked tree); with a path it previews/edits
// the file. Visible in the + menu in the explorer's old slot.
@@ -149,7 +148,6 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
// log, and the git status needs a fetch — both stay out of the badge).
id: 'git',
title: () => t('changes'),
description: () => t('guideDescGit'),
icon: (size: number) => <IconDiffOutline16 size={size} />,
order: 20,
single: true,
@@ -172,7 +170,6 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
{
id: 'subagent',
title: () => t('subagent'),
description: () => t('guideDescSubagent'),
icon: (size: number) => <IconThinkOutline16 size={size} />,
order: 30,
single: true,
@@ -201,7 +198,6 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
{
id: 'sidechat',
title: () => t('sideChat'),
description: () => t('guideDescSidechat'),
icon: (size: number) => <IconNewChatOutline16 size={size} />,
order: 35,
// Codex-style: EVERY side conversation is its own tab. A plain open
@@ -247,7 +243,6 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
{
id: 'terminal',
title: () => t('terminal'),
description: () => t('guideDescTerminal'),
icon: (size: number) => <IconTerminalOutline16 size={size} />,
order: 40,
available: (_ctx, _scope, state) => uiTerminalCount(state) < TERMINAL_LIMIT,
@@ -312,7 +307,6 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
{
id: 'browser',
title: () => t('browser'),
description: () => t('guideDescBrowser'),
icon: (size: number) => <IconGlobeOutline16 size={size} />,
order: 50,
// Declarative settings: the sandbox escape hatch, the link-takeover
+30 -3
View File
@@ -1,5 +1,15 @@
/** Stable selector for the DSH AppFrame conversation slot. */
const CENTER_COLUMN_SELECTOR = '#root [data-slot="conversation"]'
/**
* Anchor selectors for the DSH AppFrame conversation slot host, newest first:
*
* - DSH 0.1.5-alpha.2 replaced the root `conversation` slot with a root-scoped
* keyed `main` slot, so the conversation renders under `main.conversation`
* (ConversationPanel -> `renderSlot('main.conversation')`);
* - alpha.1 and earlier spell the same host `conversation`.
*
* Both keys are accepted deliberately so one build spans the alpha.1 → alpha.2
* boundary; only one exists in any given host.
*/
const CENTER_COLUMN_SELECTOR = '#root [data-slot="main.conversation"], #root [data-slot="conversation"]'
/** Match Sidebar's existing last-resort retry cadence (issue #248). */
export const CENTER_COLUMN_REVALIDATE_MS = 1500
@@ -27,6 +37,22 @@ const validatedAt = new WeakMap<HTMLElement, number>()
*/
const documentStyleState = new WeakMap<Document, string | null>()
/**
* From the matched conversation slot host, walk up through transparent slot
* hosts to the real column: DSH slot hosts render with `display: contents`,
* so on alpha.2 the `main.conversation` host (and any further keyed slot
* wrappers above it) contributes no box, and the center column is the first
* ancestor whose computed display is NOT `contents`.
*/
function columnOfSlotHost(host: Element): HTMLElement | null {
const view = host.ownerDocument.defaultView
let node = host.parentElement
while (node !== null && (view?.getComputedStyle(node).display ?? '') === 'contents') {
node = node.parentElement
}
return node
}
/**
* Resolve the AppFrame center column while keeping streaming mutations cheap.
*
@@ -74,7 +100,8 @@ export function resolveCenterColumn(
}
const query = options.query ?? (() => doc.querySelector(CENTER_COLUMN_SELECTOR))
const col = query()?.parentElement as HTMLElement | null | undefined
const host = query()
const col = host === null || host === undefined ? null : columnOfSlotHost(host)
if (col === null || col === undefined || !col.isConnected) return undefined
validatedAt.set(col, now)
+5 -2
View File
@@ -15,8 +15,11 @@
* transitions so the reserved edge tracks the pointer.
*/
/* The AppFrame's center column. The Sidebar shell's locator (Sidebar.tsx
locate/measureCenter) tags the measured column node with
[data-dsh-center-col] — one attribute write per node lifetime. The rule
locate/measureCenter, resolving via center-column.ts) tags the measured
column node with [data-dsh-center-col] — one attribute write per node
lifetime. The locator anchors on the conversation slot host
(`main.conversation` on DSH 0.1.5-alpha.2+, `conversation` on alpha.1)
and skips the transparent display:contents slot hosts above it. The rule
used to anchor on a structural has()-selector over the conversation slot
wrapper, whose match cache invalidates on every #root subtree mutation
(the streaming chat mutates it constantly), re-evaluating the selector
-7
View File
@@ -18,13 +18,6 @@
/** The ar dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const ar: Record<string, string> = {
files: 'الملفات',
nativeGuideDesc: 'Open in the DSH sidebar',
guideDescFiles: 'استعرض شجرة ملفات مساحة العمل؛ انقر للمعاينة أو التحرير',
guideDescGit: 'تغييرات Git وتعديلات ملفات هذه الجلسة في تبويب واحد',
guideDescSubagent: 'بنية الوكلاء الفرعيين والمهام الخلفية مباشرةً',
guideDescSidechat: 'خيط متابعة مستقل يرث سياق هذه الجلسة',
guideDescTerminal: 'صدفة حقيقية (xterm + pty) مع إعادة الاتصال',
guideDescBrowser: 'متصفح مدمج يمكنه فتح روابط المحادثة',
changesSessionEmpty: 'لا توجد عمليات ملفات في هذه الجلسة بعد',
changesRead: 'قراءة',
changesMdReading: 'قراءة',
-7
View File
@@ -3,13 +3,6 @@
*/
export const de: Record<string, string> = {
files: 'Dateien',
nativeGuideDesc: 'Open in the DSH sidebar',
guideDescFiles: 'Dateibaum des Arbeitsbereichs durchsuchen; Klick öffnet Vorschau oder Editor',
guideDescGit: 'Git-Änderungen und Dateiänderungen dieser Sitzung in einem Tab',
guideDescSubagent: 'Subagent-Topologie und Hintergrundaufgaben, live',
guideDescSidechat: 'Ein unabhängiger Folge-Thread mit dem Kontext dieser Sitzung',
guideDescTerminal: 'Eine echte Shell (xterm + pty) mit Wiederverbindung',
guideDescBrowser: 'Eingebetteter Browser, der Chat-Links übernehmen kann',
changesSessionEmpty: 'Noch keine Dateioperationen in dieser Sitzung',
changesRead: 'Lesen',
changesMdReading: 'Lesen',
-7
View File
@@ -10,13 +10,6 @@
/** fr dictionary for the `betterSidebar` namespace. */
export const fr: Record<string, string> = {
files: 'Fichiers',
nativeGuideDesc: 'Open in the DSH sidebar',
guideDescFiles: 'Parcourir l\'arborescence du workspace ; un clic ouvre l\'aperçu ou l\'édition',
guideDescGit: 'Modifications Git et éditions de cette session dans un seul onglet',
guideDescSubagent: 'Topologie des sous-agents et tâches d\'arrière-plan, en direct',
guideDescSidechat: 'Un fil de suivi indépendant héritant du contexte de cette session',
guideDescTerminal: 'Un vrai shell (xterm + pty) avec reconnexion',
guideDescBrowser: 'Navigateur intégré, qui peut reprendre les liens du chat',
changesSessionEmpty: 'Aucune opération de fichier dans cette session',
changesRead: 'Lecture',
changesMdReading: 'Lecture',
-7
View File
@@ -17,13 +17,6 @@
/** The hi dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const hi: Record<string, string> = {
files: 'फ़ाइलें',
nativeGuideDesc: 'Open in the DSH sidebar',
guideDescFiles: 'वर्कस्पेस फ़ाइल ट्री देखें; क्लिक करने पर प्रीव्यू या संपादन खुलता है',
guideDescGit: 'Git बदलाव और इस सत्र के फ़ाइल बदलाव एक ही टैब में',
guideDescSubagent: 'सबएजेंट टोपोलॉजी और बैकग्राउंड कार्य, लाइव',
guideDescSidechat: 'इस सत्र का संदर्भ विरासत में लेने वाला स्वतंत्र फ़ॉलो-अप थ्रेड',
guideDescTerminal: 'असली शेल (xterm + pty), रीकनेक्ट सहित',
guideDescBrowser: 'अंतर्निहित ब्राउज़र, चैट के लिंक भी खोल सकता है',
changesSessionEmpty: 'इस सत्र में अभी कोई फ़ाइल ऑपरेशन नहीं',
changesRead: 'पढ़ना',
changesMdReading: 'पठन',
-7
View File
@@ -15,13 +15,6 @@
export const id: Record<string, string> = {
files: 'Berkas',
nativeGuideDesc: 'Open in the DSH sidebar',
guideDescFiles: 'Telusuri pohon berkas workspace; klik untuk pratinjau atau edit',
guideDescGit: 'Perubahan Git dan perubahan berkas sesi ini dalam satu tab',
guideDescSubagent: 'Topologi subagent dan tugas latar belakang, langsung',
guideDescSidechat: 'Utas lanjutan independen yang mewarisi konteks sesi ini',
guideDescTerminal: 'Shell sungguhan (xterm + pty), bisa menyambung ulang',
guideDescBrowser: 'Peramban tertanam, bisa mengambil alih tautan chat',
changesSessionEmpty: 'Belum ada operasi file di sesi ini',
changesRead: 'Baca',
changesMdReading: 'Baca',
-7
View File
@@ -8,13 +8,6 @@
export const it: Record<string, string> = {
files: 'File',
nativeGuideDesc: 'Open in the DSH sidebar',
guideDescFiles: 'Sfoglia l\'albero del workspace; un clic apre anteprima o editor',
guideDescGit: 'Modifiche Git e modifiche ai file di questa sessione in un solo tab',
guideDescSubagent: 'Topologia dei subagent e attività in background, in tempo reale',
guideDescSidechat: 'Un thread di approfondimento indipendente che eredita il contesto della sessione',
guideDescTerminal: 'Una vera shell (xterm + pty) con riconnessione',
guideDescBrowser: 'Browser integrato, può aprire i link della chat',
changesSessionEmpty: 'Ancora nessuna operazione sui file in questa sessione',
changesRead: 'Lettura',
changesMdReading: 'Lettura',
-7
View File
@@ -17,13 +17,6 @@
/** The ja dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const ja: Record<string, string> = {
files: 'ファイル',
nativeGuideDesc: 'DSH のサイドバーで開く',
guideDescFiles: 'ワークスペースのファイルツリーを閲覧し、クリックでプレビュー/編集',
guideDescGit: 'Git の変更と今回のセッションのファイル変更を 1 つのタブに',
guideDescSubagent: 'サブエージェントの構成とバックグラウンドタスクをリアルタイム表示',
guideDescSidechat: '現在のセッションの文脈を引き継ぐ独立した追加スレッド',
guideDescTerminal: '本物のシェル(xterm + pty)、再接続対応',
guideDescBrowser: '埋め込みブラウザ。チャット内のリンクも引き受け可能',
changesSessionEmpty: 'このセッションにはファイル操作がまだありません',
changesRead: '読み取り',
changesMdReading: '閲覧',
-7
View File
@@ -9,13 +9,6 @@
export const ko: Record<string, string> = {
files: '파일',
nativeGuideDesc: 'Open in the DSH sidebar',
guideDescFiles: '워크스페이스 파일 트리를 보고 클릭하면 미리보기/편집',
guideDescGit: 'Git 변경 사항과 이번 세션의 파일 변경을 한 탭에서',
guideDescSubagent: '하위 에이전트 구조와 백그라운드 작업을 실시간으로',
guideDescSidechat: '현재 세션의 컨텍스트를 이어받는 독립 후속 스레드',
guideDescTerminal: '실제 셸(xterm + pty), 재연결 지원',
guideDescBrowser: '내장 브라우저, 채팅 링크도 대신 열 수 있음',
changesSessionEmpty: '이 세션에 아직 파일 작업이 없습니다',
changesRead: '읽기',
changesMdReading: '읽기',
-7
View File
@@ -15,13 +15,6 @@
*/
export const nl: Record<string, string> = {
files: 'Bestanden',
nativeGuideDesc: 'Open in the DSH sidebar',
guideDescFiles: 'Blader door de bestandsboom van de workspace; klik opent voorbeeld of editor',
guideDescGit: 'Git-wijzigingen en bestandswijzigingen van deze sessie in één tabblad',
guideDescSubagent: 'Subagent-topologie en achtergrondtaken, live',
guideDescSidechat: 'Een onafhankelijke vervolgthread met de context van deze sessie',
guideDescTerminal: 'Een echte shell (xterm + pty) met herverbinding',
guideDescBrowser: 'Ingebouwde browser die chatlinks kan overnemen',
changesSessionEmpty: 'Nog geen bestandsbewerkingen in deze sessie',
changesRead: 'Lezen',
changesMdReading: 'Lezen',
-7
View File
@@ -19,13 +19,6 @@
/** The pl dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const pl: Record<string, string> = {
files: 'Pliki',
nativeGuideDesc: 'Open in the DSH sidebar',
guideDescFiles: 'Przeglądaj drzewo plików obszaru roboczego; klik otwiera podgląd lub edycję',
guideDescGit: 'Zmiany Git i zmiany plików z tej sesji w jednej karcie',
guideDescSubagent: 'Topologia subagentów i zadania w tle na żywo',
guideDescSidechat: 'Niezależny wątek uzupełniający dziedziczący kontekst tej sesji',
guideDescTerminal: 'Prawdziwy shell (xterm + pty) z ponownym łączeniem',
guideDescBrowser: 'Wbudowana przeglądarka, może przejmować linki z czatu',
changesSessionEmpty: 'Brak operacji na plikach w tej sesji',
changesRead: 'Odczyt',
changesMdReading: 'Czytanie',
-7
View File
@@ -1,12 +1,5 @@
export const pt: Record<string, string> = {
files: 'Arquivos',
nativeGuideDesc: 'Open in the DSH sidebar',
guideDescFiles: 'Navegue pela árvore do workspace; um clique abre a pré-visualização ou a edição',
guideDescGit: 'Alterações do Git e edições desta sessão em um único separador',
guideDescSubagent: 'Topologia de subagentes e tarefas em segundo plano, ao vivo',
guideDescSidechat: 'Uma conversa paralela independente que herda o contexto desta sessão',
guideDescTerminal: 'Um shell real (xterm + pty) com reconexão',
guideDescBrowser: 'Navegador incorporado, que pode abrir links do chat',
changesSessionEmpty: 'Ainda sem operações de arquivo nesta sessão',
changesRead: 'Leitura',
changesMdReading: 'Leitura',
-7
View File
@@ -13,13 +13,6 @@
export const ru: Record<string, string> = {
files: 'Файлы',
nativeGuideDesc: 'Open in the DSH sidebar',
guideDescFiles: 'Дерево файлов рабочей области: клик открывает просмотр или редактор',
guideDescGit: 'Изменения Git и правки файлов этой сессии в одной вкладке',
guideDescSubagent: 'Топология субагентов и фоновые задачи в реальном времени',
guideDescSidechat: 'Независимый дополнительный тред с контекстом этой сессии',
guideDescTerminal: 'Настоящий shell (xterm + pty) с переподключением',
guideDescBrowser: 'Встроенный браузер, перехватывает ссылки из чата',
changesSessionEmpty: 'В этой сессии пока нет файловых операций',
changesRead: 'Чтение',
changesMdReading: 'Чтение',
-7
View File
@@ -1,12 +1,5 @@
export const sv: Record<string, string> = {
files: 'Filer',
nativeGuideDesc: 'Open in the DSH sidebar',
guideDescFiles: 'Bläddra i arbetsytans filträd; klick öppnar förhandsvisning eller redigering',
guideDescGit: 'Git-ändringar och sessionens filändringar i en flik',
guideDescSubagent: 'Subagenttopologi och bakgrundsjobb, live',
guideDescSidechat: 'En oberoende uppföljningstråd som ärver sessionens kontext',
guideDescTerminal: 'Ett riktigt skal (xterm + pty) med återanslutning',
guideDescBrowser: 'Inbäddad webbläsare som kan ta över chattlänkar',
changesSessionEmpty: 'Inga filoperationer i den här sessionen ännu',
changesRead: 'Läs',
changesMdReading: 'Läsning',
-7
View File
@@ -17,13 +17,6 @@
/** The th dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const th: Record<string, string> = {
files: 'ไฟล์',
nativeGuideDesc: 'Open in the DSH sidebar',
guideDescFiles: 'เรียกดูแผนผังไฟล์ของเวิร์กสเปซ คลิกเพื่อดูตัวอย่างหรือแก้ไข',
guideDescGit: 'การเปลี่ยนแปลง Git และไฟล์ที่แก้ในเซสชันนี้ในแท็บเดียว',
guideDescSubagent: 'โครงสร้างซับเอเจนต์และงานเบื้องหลังแบบเรียลไทม์',
guideDescSidechat: 'เธรดถามต่ออิสระที่สืบทอดบริบทของเซสชันนี้',
guideDescTerminal: 'เชลล์จริง (xterm + pty) เชื่อมต่อใหม่ได้',
guideDescBrowser: 'เบราว์เซอร์ในตัว รับช่วงลิงก์จากแชทได้',
changesSessionEmpty: 'ยังไม่มีการดำเนินการไฟล์ในเซสชันนี้',
changesRead: 'อ่าน',
changesMdReading: 'อ่าน',
-7
View File
@@ -17,13 +17,6 @@
/** The tr dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const tr: Record<string, string> = {
files: 'Dosyalar',
nativeGuideDesc: 'Open in the DSH sidebar',
guideDescFiles: 'Çalışma alanı dosya ağacına göz atın; tıklayınca önizleme veya düzenleme açılır',
guideDescGit: 'Git değişiklikleri ve bu oturumun dosya değişiklikleri tek sekmede',
guideDescSubagent: 'Alt ajan topolojisi ve arka plan görevleri, canlı',
guideDescSidechat: 'Bu oturumun bağlamını devralan bağımsız bir yan konu',
guideDescTerminal: 'Gerçek bir shell (xterm + pty), yeniden bağlanma destekli',
guideDescBrowser: 'Sohbet bağlantılarını devralabilen gömülü tarayıcı',
changesSessionEmpty: 'Bu oturumda henüz dosya işlemi yok',
changesRead: 'Okuma',
changesMdReading: 'Okuma',
-7
View File
@@ -17,13 +17,6 @@
/** The vi dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const vi: Record<string, string> = {
files: 'Tệp',
nativeGuideDesc: 'Open in the DSH sidebar',
guideDescFiles: 'Duyệt cây tệp của workspace; bấm để xem trước hoặc chỉnh sửa',
guideDescGit: 'Thay đổi Git và thay đổi tệp của phiên này trong một tab',
guideDescSubagent: 'Sơ đồ subagent và tác vụ nền, theo thời gian thực',
guideDescSidechat: 'Luồng hỏi tiếp độc lập kế thừa ngữ cảnh của phiên này',
guideDescTerminal: 'Shell thật (xterm + pty), tự kết nối lại',
guideDescBrowser: 'Trình duyệt nhúng, có thể mở link trong chat',
changesSessionEmpty: 'Chưa có thao tác tệp nào trong phiên này',
changesRead: 'Đọc',
changesMdReading: 'Đọc',
-7
View File
@@ -32,13 +32,6 @@
/** The zh-HK dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const zhHK: Record<string, string> = {
files: '檔案',
nativeGuideDesc: 'Open in the DSH sidebar',
guideDescFiles: '瀏覽工作區檔案樹,點開即可預覽或編輯',
guideDescGit: 'Git 變更與本輪檔案改動,雙視角合一',
guideDescSubagent: '子代理拓撲與背景任務,即時狀態',
guideDescSidechat: '繼承目前工作階段上下文的獨立追問執行緒',
guideDescTerminal: '真實 shell 終端(xterm + pty),斷線重連',
guideDescBrowser: '內嵌網頁瀏覽器,可接管聊天中的外鏈',
changesSessionEmpty: '本工作階段還沒有檔案操作',
changesRead: '讀取',
changesMdReading: '閱讀',
-7
View File
@@ -32,13 +32,6 @@
/** The zh-HK dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const zhMO: Record<string, string> = {
files: '檔案',
nativeGuideDesc: 'Open in the DSH sidebar',
guideDescFiles: '瀏覽工作區檔案樹,點開即可預覽或編輯',
guideDescGit: 'Git 變更與本輪檔案改動,雙視角合一',
guideDescSubagent: '子代理拓撲與背景任務,即時狀態',
guideDescSidechat: '繼承目前工作階段上下文的獨立追問執行緒',
guideDescTerminal: '真實 shell 終端(xterm + pty),斷線重連',
guideDescBrowser: '內嵌網頁瀏覽器,可接管聊天中的外鏈',
changesSessionEmpty: '本工作階段還沒有檔案操作',
changesRead: '讀取',
changesMdReading: '閱讀',
-7
View File
@@ -32,13 +32,6 @@
/** The zh-HK dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const zhTW: Record<string, string> = {
files: '檔案',
nativeGuideDesc: 'Open in the DSH sidebar',
guideDescFiles: '瀏覽工作區檔案樹,點開即可預覽或編輯',
guideDescGit: 'Git 變更與本輪檔案改動,雙視角合一',
guideDescSubagent: '子代理拓撲與背景任務,即時狀態',
guideDescSidechat: '繼承目前工作階段上下文的獨立追問執行緒',
guideDescTerminal: '真實 shell 終端(xterm + pty),斷線重連',
guideDescBrowser: '內嵌網頁瀏覽器,可接管聊天中的外鏈',
changesSessionEmpty: '本作業階段還沒有檔案操作',
changesRead: '讀取',
changesMdReading: '閱讀',
-14
View File
@@ -24,13 +24,6 @@
/** The zh dictionary (also registered into the DSH locale registry under {@link LOCALE_NS}). */
export const zh = {
files: '文件',
nativeGuideDesc: '在 DSH 原生右侧栏打开',
guideDescFiles: '浏览工作区文件树,点开即预览或编辑',
guideDescGit: 'Git 变更与本轮文件改动,双视角合一',
guideDescSubagent: '子代理拓扑与后台任务,实时状态',
guideDescSidechat: '继承当前会话上下文的独立追问线程',
guideDescTerminal: '真实 shell 终端(xterm + pty),断线重连',
guideDescBrowser: '内嵌网页浏览器,可接管聊天里的外链',
changesSessionEmpty: '本会话还没有文件操作',
changesRead: '读取',
changesMdReading: '阅读',
@@ -465,13 +458,6 @@ export const zh = {
/** The en dictionary (key-set-equal to zh, enforced by the type annotation). */
export const en: Record<keyof typeof zh, string> = {
files: 'Files',
nativeGuideDesc: 'Open in the DSH sidebar',
guideDescFiles: 'Browse the workspace tree; click a file to preview or edit',
guideDescGit: 'Git changes and this session\'s file edits, in one tab',
guideDescSubagent: 'Subagent topology and background jobs, live',
guideDescSidechat: 'An independent follow-up thread inheriting this session\'s context',
guideDescTerminal: 'A real shell (xterm + pty) with reconnect',
guideDescBrowser: 'An embedded browser that can take over chat links',
changesSessionEmpty: 'No file operations in this session yet',
changesRead: 'Read',
changesMdReading: 'Reading',
+1 -14
View File
@@ -43,7 +43,7 @@ interface NativeTabRegistry {
priority?: 'extension' | 'builtin' | 'fallback'
canOpen?: (address: string) => boolean
title: (address: string) => string
guide?: readonly { order: number; title: () => string; description: () => string; icon?: unknown }[]
guide?: readonly { order: number; title: () => string; icon?: unknown }[]
}): () => void
}
@@ -63,17 +63,6 @@ function titleOf(descriptor: TabDescriptor): string {
return typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title
}
/**
* The descriptor's guide line, evaluated fresh for the current locale. The
* host's new-tab list renders one line per entry; without a declared
* description every plugin page would read the same generic sentence.
*/
function descriptionOf(descriptor: TabDescriptor | undefined): string {
const description = descriptor?.description
if (description === undefined) return t('nativeGuideDesc')
return typeof description === 'function' ? description() : description
}
/**
* The guide row's glyph for a descriptor icon (nothing when it has none).
* The native guide renders `entry.icon`, so a takeover registered without one
@@ -192,7 +181,6 @@ export function registerNativeSurface(deps: NativeSurfaceDeps): () => void {
guide: [{
order: descriptor.order ?? 100,
title: () => titleOf(descriptor),
description: () => descriptionOf(descriptor),
...guideIconOf(icon),
}],
}),
@@ -219,7 +207,6 @@ export function registerNativeSurface(deps: NativeSurfaceDeps): () => void {
guide: [{
order: 10,
title: () => t('files'),
description: () => descriptionOf(editor),
// The takeover IS the editor descriptor's page, so it carries the
// editor's glyph: without it the "Files" row is the only guide
// entry with an empty icon slot.
+39 -23
View File
@@ -281,33 +281,49 @@ export function NativeTabBody(props: NativeBodyInjected & NativeBodyFrameworkPro
})
useEffect(() => () => { records.drop(nativeTab.id) }, [records, nativeTab.id])
if (descriptor === undefined) {
return createElement(OrphanedTab, { ctx, store, scope, tab: view.tab, visible: nativeTab.visible })
// The orphaned fallback sits in the SAME native host as a live body, so
// it gets the same full-height box (its own root also relies on the
// `flex: 1` contract the column host restores). The host div carries
// `data-dsh-native-tab-host` (empty value) so the e2e lane can assert
// the fill, mirroring `data-dsh-better-sidebar`.
return createElement(
'div',
{ className: css.nativeTabHost, 'data-dsh-native-tab-host': '' },
createElement(OrphanedTab, { ctx, store, scope, tab: view.tab, visible: nativeTab.visible }),
)
}
return createElement(
RenderBoundary,
{ className: css.tabBoundaryError },
createElement(descriptor.component, {
ctx,
store,
scope,
tab: view.tab,
visible: nativeTab.visible,
expanded: view.expanded,
revealed: view.revealed,
onToggleDir: (path: string) => { records.toggleExpanded(nativeTab.id, path) },
onReferenceFile: (path: string, isDir: boolean) => { referenceInChat(ctx, sessionId, cwd, path, isDir) },
onOpenDiff: (tab: SidebarTab) => {
service.openTab({
type: 'diff',
title: tab.title,
id: tab.id,
...(tab.diff === undefined ? {} : { diff: tab.diff }),
}, scope)
},
onSubagentJump: (childSessionId: string) => {
service.openTab({ type: 'subagent', meta: { childSessionId } }, scope)
},
}),
// The full-height host wrapper (see the `.nativeTabHost` rule in
// sidebar.module.css for the native `.paneBody` contract); its
// `data-dsh-native-tab-host` attribute lets the e2e lane assert the fill.
createElement(
'div',
{ className: css.nativeTabHost, 'data-dsh-native-tab-host': '' },
createElement(descriptor.component, {
ctx,
store,
scope,
tab: view.tab,
visible: nativeTab.visible,
expanded: view.expanded,
revealed: view.revealed,
onToggleDir: (path: string) => { records.toggleExpanded(nativeTab.id, path) },
onReferenceFile: (path: string, isDir: boolean) => { referenceInChat(ctx, sessionId, cwd, path, isDir) },
onOpenDiff: (tab: SidebarTab) => {
service.openTab({
type: 'diff',
title: tab.title,
id: tab.id,
...(tab.diff === undefined ? {} : { diff: tab.diff }),
}, scope)
},
onSubagentJump: (childSessionId: string) => {
service.openTab({ type: 'subagent', meta: { childSessionId } }, scope)
},
}),
),
)
}
+36 -24
View File
@@ -5,11 +5,16 @@
* tab's content identity: the same address is the same tab. Two scopes exist:
*
* - `dsh-resource://file/session/<sessionId>/<path>` names a file by its path
* relative to that session's workspace root (`src/a.ts`, no leading `/`);
* relative to that session's workspace root OR by its absolute path kept
* absolute inside the session scope (`src/a.ts` vs `/outside/a.ts` — the
* leading `/` survives; the host resolves it against the root it holds for
* the session);
* - `dsh-resource://file/absolute/<path>` names a file by its absolute path
* with the leading `/` dropped (`absolute/home/me/x.txt`; Windows
* `absolute/C:/x/y.txt`; a UNC path keeps an empty first segment,
* `absolute//server/share/x.txt`). It carries no session.
* `absolute//server/share/x.txt`). It carries no session. `fileAddressFor`
* no longer produces this scope on DSH 0.1.5-alpha.2, but it must keep
* parsing: legacy and third-party addresses still spell it.
*
* Every id and path segment is component-encoded, so a name carrying `#`, `?`
* or a space survives the round trip; `:` stays literal so a drive letter
@@ -18,8 +23,10 @@
* The plugin parses these addresses itself instead of importing
* `@deepseek-ai/dsh-util-workspace-path`: the client bundle's purity gate
* forbids value-importing an unlisted `@deepseek-ai/*` package. This module
* mirrors that package's implementation (`packages/util/workspace-path` in
* DSH 0.1.5) and is pinned by tests/resource-address.spec.ts.
* mirrors that package's implementation — `packages/util/workspace-path/src/file-address.ts`
* and `fileAddressFor` in `packages/util/workspace-path/src/index.ts` in
* DSH 0.1.5-alpha.2 (github.com/deepseek-ai/deepseek-harness, tag
* `dsh-v0.1.5-alpha.2`) — and is pinned by tests/resource-address.spec.ts.
*/
/** The scheme and type every file address opens with. */
@@ -31,9 +38,9 @@ export const FILE_ADDRESS_PREFIX = 'dsh-resource://file/'
export type FileAddress =
| {
readonly scope: 'session'
/** The session whose workspace root the path is relative to. */
/** The session whose workspace root resolves the path. */
readonly sessionId: string
/** Workspace-relative `/`-separated path, no leading `/`; empty for the root itself. */
/** Absolute or workspace-relative `/`-separated path; empty for the workspace root itself. */
readonly path: string
}
| {
@@ -58,19 +65,21 @@ function isDriveSegment(segment: string | undefined): boolean {
}
/**
* Build the address of a file inside one session's workspace.
* @param sessionId - the session whose workspace root the path is relative to.
* @param path - workspace-relative path; backslashes normalize to `/`, a leading `./` or `/` is dropped.
* Build the address of a file read through one session.
* @param sessionId - the session whose workspace root resolves the path.
* @param path - absolute or workspace-relative path; backslashes are normalized
* to `/`, and leading `./` prefixes are dropped (a leading `/` is KEPT: an
* absolute path stays absolute inside the session scope).
* @returns the `dsh-resource://file/session/<sessionId>/<path>` address.
*/
export function sessionFileAddress(sessionId: string, path: string): string {
const relative = path.replace(/\\/g, '/').replace(/^(?:\.\/)+/, '').replace(/^\/+/, '')
return `${FILE_ADDRESS_PREFIX}session/${encodeSegment(sessionId)}/${encodePath(relative)}`
const normalized = path.replace(/\\/g, '/').replace(/^(?:\.\/)+/, '')
return `${FILE_ADDRESS_PREFIX}session/${encodeSegment(sessionId)}/${encodePath(normalized)}`
}
/**
* Build the address of a file by its absolute path.
* @param path - absolute path; backslashes normalize to `/` and the leading `/`
* @param path - absolute path; backslashes are normalized to `/` and the leading `/`
* is dropped, except that a UNC path keeps one empty first segment.
* @returns the `dsh-resource://file/absolute/<path>` address.
*/
@@ -82,16 +91,18 @@ export function absoluteFileAddress(path: string): string {
}
/**
* Read a file address back into its parts.
* Read a file address back into its parts without resolving `.` or `..`.
* Query and fragment suffixes are ignored; encoded path segments are decoded.
* @param address - a candidate address.
* @returns the parts, or `undefined` when the string is not a
* `dsh-resource://file/` URI in a known scope with a valid path.
* `dsh-resource://file/` URI in a known scope with a path, or a segment is
* not validly encoded.
*/
export function parseFileAddress(address: string): FileAddress | undefined {
try {
const url = new URL(address)
if (url.protocol !== 'dsh-resource:' || url.host !== 'file') return undefined
const [, scope, ...rest] = url.pathname.split('/')
if (!address.startsWith(FILE_ADDRESS_PREFIX)) return undefined
const end = address.search(/[?#]/)
const [scope, ...rest] = address.slice(FILE_ADDRESS_PREFIX.length, end === -1 ? undefined : end).split('/')
if (scope === 'session') {
const [id, ...segments] = rest
if (id === undefined || id === '' || segments.length === 0) return undefined
@@ -107,8 +118,7 @@ export function parseFileAddress(address: string): FileAddress | undefined {
}
return undefined
} catch {
// `new URL` throws on a non-URL and `decodeURIComponent` on a malformed
// escape; both mean "not a file address".
// `decodeURIComponent` throws URIError on a malformed escape.
return undefined
}
}
@@ -119,10 +129,12 @@ function isAbsolutePath(path: string): boolean {
}
/**
* The address for a path as a caller holds it: a relative path, or an absolute
* path inside the session's workspace, becomes a `session`-scoped address; an
* absolute path outside it, or one whose workspace root is unknown, becomes an
* `absolute`-scoped address. (Mirror of `fileAddressFor`.)
* The address for a path as a caller holds it: ALWAYS session-scoped. A
* relative path, or an absolute path inside the session's workspace, becomes
* the relative spelling; an absolute path outside it, or one whose workspace
* root is unknown, keeps its absolute path in that session's address. (Mirror
* of `fileAddressFor` — on alpha.2 this helper no longer produces the
* `absolute` scope, though such addresses still parse.)
* @param sessionId - the session the path is read in.
* @param cwd - that session's workspace root, when known.
* @param path - absolute or workspace-relative path, either separator spelling.
@@ -134,5 +146,5 @@ export function fileAddressFor(sessionId: string, cwd: string | undefined, path:
const root = cwd === undefined ? '' : cwd.replace(/\\/g, '/').replace(/\/+$/, '')
if (root !== '' && normalized === root) return sessionFileAddress(sessionId, '')
if (root !== '' && normalized.startsWith(`${root}/`)) return sessionFileAddress(sessionId, normalized.slice(root.length + 1))
return absoluteFileAddress(normalized)
return sessionFileAddress(sessionId, normalized)
}
+1 -9
View File
@@ -162,14 +162,6 @@ export interface TabDescriptor {
/** Unique id; also the `SidebarTab.type` value (`'explorer'`, `'my-plugin:db'`). */
id: string
title: string | (() => string)
/**
* One-line description of what this tab shows, rendered under the title in
* the host's new-tab list (DSH's native right Sidebar guide page). Without
* it the entry falls back to a generic "opens in the DSH sidebar" line, so
* every plugin page reads the same — declare the real purpose instead.
* Evaluated at render time, so a function follows the active locale.
*/
description?: string | (() => string)
icon?: ReactNode | ((size: number) => ReactNode)
/** + menu sort order (ascending); default 100. */
order?: number
@@ -526,7 +518,7 @@ export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDes
* The plugin version this service instance reports. Keep in lockstep with
* `package.json`'s version — `tests/service.spec.ts` asserts the pair.
*/
export const SIDEBAR_SERVICE_VERSION = '0.19.0-alpha.0'
export const SIDEBAR_SERVICE_VERSION = '0.19.0-alpha.1'
/**
* Monotonic capability list consumers use to gate new API usage (features
+18
View File
@@ -2004,6 +2004,24 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
color: var(--dsw-alias-state-error-primary);
}
/* ── Native right-Sidebar tab-body host ──────────────────────────────────
DSH's native tab body host (`.paneBody` in dsh-client-ui-dockkit) is a
BLOCK scroller with a definite height, not a flex container — the host's
own tab bodies (TextPreview, FilesBody) declare `height: 100%` on their
root for exactly this reason. Our tab roots declare `flex: 1; min-height:
0`, which collapses to content height in that block host (the side-chat
composer would float mid-pane). A column flex host of `height: 100%`
restores the `flex: 1` semantics our tab roots rely on, and gives
third-party descriptors' components the same box inside the native pane.
The wrapper div carries `data-dsh-native-tab-host` so the e2e lane can
assert the fill (mirroring `data-dsh-better-sidebar`). */
.nativeTabHost {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
/* ── Diff tab (generic states + shell; the changes tab owns the rest in
changes.module.css, and the diff rows themselves live in
diff/diff.module.css) ────────────────────────────────────────────────── */
+16 -11
View File
@@ -3,8 +3,11 @@
* the bottom workbench spans ONLY the app shell's center column ("squeezes
* the agent output area") — it starts at the app sidebar's right edge and
* ends at the details column's left edge. Measured directly from the
* AppFrame's center column DOM (the parent of the [data-slot="conversation"]
* wrapper — layout.css's center column) so the workbench tracks the column's
* AppFrame's center column DOM (center-column.ts anchors on the conversation
* slot host — `main.conversation` on DSH 0.1.5-alpha.2+, `conversation` on
* alpha.1 — and walks up the transparent `display: contents` slot hosts to
* the column; layout.css's center column) so the workbench tracks the
* column's
* real horizontal edges, including the animated track moves while DSH's
* native right Sidebar opens/closes; a frame that never appears keeps the
* initial zero-size fallback (the panel renders at 0 width until measured).
@@ -67,15 +70,17 @@ export function useCenterColumn(
let disposed = false
let observer: ResizeObserver | undefined
// Locate the AppFrame's center column. DSH 0.1.x wraps slot hosts in
// [data-slot] containers: the conversation slot wrapper
// ([data-slot="conversation"]) sits directly inside the center column,
// so its parent IS that column — no hashed-class or positional
// dependency (layout.css uses the same anchor). The shell swaps the
// boot page for the AppFrame only AFTER boot settles, so the first
// query may miss it. Never give up: watch #root's subtree (the swap and
// HMR re-renders mutate it) and re-run this locator — querying once and
// bailing would strand the panel at the zero-size fallback forever
// (observed: a 1px sliver at the viewport's left edge).
// [data-slot] containers: the conversation slot host (key
// `main.conversation` on 0.1.5-alpha.2+, `conversation` on alpha.1)
// sits inside the center column behind transparent `display: contents`
// slot hosts, so the resolver walks up to the first ancestor that
// renders a box — no hashed-class or positional dependency
// (layout.css uses the same anchor). The shell swaps the boot page for
// the AppFrame only AFTER boot settles, so the first query may miss it.
// Never give up: watch #root's subtree (the swap and HMR re-renders
// mutate it) and re-run this locator — querying once and bailing would
// strand the panel at the zero-size fallback forever (observed: a 1px
// sliver at the viewport's left edge).
const locate = (): void => {
if (disposed) return
// Hot path (#403): streaming output mutates #root at token cadence.
+11 -9
View File
@@ -44,20 +44,22 @@ describe('built-in tab registrations', () => {
expect(changes?.component).toBeDefined()
})
it('every visible tab declares a real guide line (no generic fallback)', () => {
// The native new-tab list renders `description()` under the title; a tab
// without one reads the same generic sentence as every other plugin page.
it('every visible tab declares a non-empty, mutually distinct title', () => {
// The native new-tab list (guide page) is an icon+title capsule at DSH
// 0.1.5-alpha.2 — the `description` field is gone from the host contract.
// The title is therefore the only per-tab guide text: it must exist, and
// no two visible tabs may share one (identical capsules would be
// indistinguishable in the guide and the tab strip).
const { service } = setup()
const visible = service.getTabs().filter(descriptor => descriptor.hidden !== true)
expect(visible.length).toBeGreaterThan(0)
for (const descriptor of visible) {
expect(descriptor.description, `${descriptor.id} must declare a description`).toBeDefined()
const line = typeof descriptor.description === 'function' ? descriptor.description() : descriptor.description
expect(line, `${descriptor.id} description must be non-empty`).toBeTruthy()
const title = typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title
expect(title, `${descriptor.id} must declare a title`).toBeTruthy()
}
const lines = visible.map(descriptor =>
typeof descriptor.description === 'function' ? descriptor.description() : descriptor.description)
expect(new Set(lines).size, 'descriptions must differ per tab').toBe(visible.length)
const titles = visible.map(descriptor =>
typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title)
expect(new Set(titles).size, 'titles must differ per tab').toBe(visible.length)
})
it('the changes tab declares no settings of its own (the diff always docks)', () => {
+38 -1
View File
@@ -1,5 +1,5 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
CENTER_COLUMN_REVALIDATE_MS,
resolveCenterColumn,
@@ -20,6 +20,43 @@ function connectedColumn(doc = document.implementation.createHTMLDocument()): Fi
return { doc, col, slot }
}
// alpha.2 fixture: a real #root in the jsdom global document, because the
// contents-skip walk needs a live defaultView.getComputedStyle (inline
// `display: contents` on the wrapper). Build:
// #root > col > wrapper[display: contents] > host[data-slot="main.conversation"].
function alphaColumn(hostSlot: string): { col: HTMLDivElement; host: HTMLDivElement } {
const root = document.createElement('div')
root.id = 'root'
const col = document.createElement('div')
const wrapper = document.createElement('div')
wrapper.style.display = 'contents'
const host = document.createElement('div')
host.dataset.slot = hostSlot
wrapper.append(host)
col.append(wrapper)
root.append(col)
document.body.append(root)
return { col, host }
}
// The alpha fixtures append into the shared jsdom document; drop them so
// later tests' querySelector scans stay deterministic.
afterEach(() => {
for (const root of document.body.querySelectorAll('div#root')) root.remove()
})
describe('center-column anchor keys (DSH 0.1.5-alpha.2)', () => {
it('skips display:contents slot hosts above a main.conversation host', () => {
const { col } = alphaColumn('main.conversation')
expect(resolveCenterColumn(null, { now: () => 0 })).toBe(col)
})
it('still resolves the alpha.1 conversation key through the default selector', () => {
const { col } = alphaColumn('conversation')
expect(resolveCenterColumn(null, { now: () => 0 })).toBe(col)
})
})
describe('center-column locator (issue #403)', () => {
it('reuses a connected cached column without querying during the hot-path window', () => {
const { doc, col, slot } = connectedColumn()
+33
View File
@@ -314,6 +314,39 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
)
.toBe(true)
// The native tab body host is a BLOCK scroller with a definite height, not a
// flex container, so a tab root that only declares `flex: 1` collapses to its
// content height — exactly how the side-chat composer used to drift away from
// the pane bottom. The native adapter wraps every body in a full-height column
// host; assert the box really fills its pane and the composer sits on the
// pane's floor. This is the regression guard for that fill contract.
await expect
.poll(
() => page.evaluate(() => {
const hosts = [...document.querySelectorAll('[data-dsh-native-tab-host]')]
const host = hosts.find(element => element.getBoundingClientRect().height > 0)
if (host === undefined) return 'no visible native tab host'
// The wrapper's parent is the SLOT HOST, which renders with
// `display: contents` and therefore has no box of its own; the pane
// body is the first ancestor that actually draws one.
const view = host.ownerDocument.defaultView
let paneBody = host.parentElement
while (paneBody !== null && (view?.getComputedStyle(paneBody).display ?? '') === 'contents') {
paneBody = paneBody.parentElement
}
if (paneBody === null) return 'the native tab host has no boxed ancestor'
const fillGap = Math.round(paneBody.getBoundingClientRect().height - host.getBoundingClientRect().height)
if (Math.abs(fillGap) > 2) return `tab body does not fill its pane: gap ${fillGap}px`
const composer = document.querySelector('[class*="sidechatComposer"]')
if (composer === null) return 'the side-chat composer is not rendered'
// The composer's own 8px bottom margin is the only allowed gap.
const bottomGap = Math.round(paneBody.getBoundingClientRect().bottom - composer.getBoundingClientRect().bottom)
return bottomGap <= 12 ? 'filled' : `the composer sits ${bottomGap}px above the pane bottom`
}),
{ timeout: 30_000 },
)
.toBe('filled')
// Side Chat host-route smoke against the REAL host: create a thread child
// under the seeded session (custom-seed creation through AgentRegistry),
// deliver a follow-up, cancel, and release it. The turn itself cannot run
+73 -10
View File
@@ -5,7 +5,10 @@
* (src/client/service.ts `setSurface`).
*/
import { describe, expect, it, vi } from 'vitest'
import { createNativeTabRecords } from '../src/client/native/tab-adapter.tsx'
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { createNativeTabRecords, NativeTabBody } from '../src/client/native/tab-adapter.tsx'
import { registerNativeSurface } from '../src/client/native/index.ts'
import { createBetterSidebarService, type SidebarSurface } from '../src/client/service.ts'
import { createSidebarStore } from '../src/client/state.ts'
@@ -162,8 +165,8 @@ describe('registerNativeSurface lifecycle (service-driven registration)', () =>
const store = createSidebarStore()
store.setSession('s1')
const service = createBetterSidebarService(store)
service.registerTab({ id: 'terminal', title: 'Terminal', component: () => null, description: () => 'Runs a shell' })
service.registerTab({ id: 'editor', title: 'Files', component: () => null, icon: () => null, description: () => 'Browse the tree' })
service.registerTab({ id: 'terminal', title: 'Terminal', component: () => null })
service.registerTab({ id: 'editor', title: 'Files', component: () => null, icon: () => null })
const records = createNativeTabRecords()
const registered: Array<{ id: string; kind: string; title: (address: string) => string; guide: unknown }> = []
@@ -225,16 +228,76 @@ describe('registerNativeSurface lifecycle (service-driven registration)', () =>
expect(filesType?.guide).toBeDefined()
// The takeover carries the editor's glyph, so the "Files" guide row is
// not the only one with a blank icon slot.
const filesGuide = filesType?.guide as Array<{ icon?: unknown; description?: () => string }> | undefined
const filesGuide = filesType?.guide as Array<{ icon?: unknown; title: () => string; description?: unknown }> | undefined
expect(filesGuide?.[0]?.icon).toBeDefined()
// The guide line is the descriptor's OWN description (the takeover reuses
// the editor's), and a descriptor that declares none falls back to the
// generic line — otherwise every plugin page would read identically.
expect(filesGuide?.[0]?.description?.()).toBe('Browse the tree')
// DSH 0.1.5-alpha.2 renders guide entries as icon+title capsules — the
// `description` field is GONE from the host contract, so no entry may
// carry one (a leftover would be silently ignored, not rendered).
expect(filesGuide?.[0]?.description).toBeUndefined()
expect('description' in (filesGuide?.[0] ?? {})).toBe(false)
expect(filesGuide?.[0]?.title?.()).toBe('Files')
const terminalGuide = registered.find(entry => entry.kind === 'terminal')?.guide as
Array<{ description?: () => string }> | undefined
expect(terminalGuide?.[0]?.description?.()).toBe('Runs a shell')
Array<{ title: () => string; description?: unknown }> | undefined
expect(terminalGuide?.[0]?.description).toBeUndefined()
expect('description' in (terminalGuide?.[0] ?? {})).toBe(false)
expect(terminalGuide?.[0]?.title?.()).toBe('Terminal')
dispose()
})
})
describe('NativeTabBody full-height host wrapper', () => {
it('renders the descriptor component inside the [data-dsh-native-tab-host] wrapper', () => {
// DSH's native tab body host (`.paneBody`) is a BLOCK scroller with a
// definite height, not a flex container — our tab roots (`flex: 1;
// min-height: 0`) collapse there without a column-flex wrapper of
// height:100%. The stable `data-dsh-native-tab-host` marker (mirroring
// `data-dsh-better-sidebar`) lets the e2e lane assert the fill; this
// unit check pins the structure: the marker wrapper exists and the
// descriptor's output is INSIDE it (before the fix the component was
// rendered bare, with no wrapper at all).
const store = createSidebarStore()
store.setSession('s1')
const service = createBetterSidebarService(store)
service.registerTab({
id: 'stub',
title: 'Stub',
component: () => createElement('div', { 'data-stub-body': '' }, 'stub body'),
})
const records = createNativeTabRecords()
const sessions = { list: { subscribe: () => () => {}, getSnapshot: () => ({ byId: {} }) } }
const ctx = { sessions } as never
const info = {
tab: {
id: 'native-9',
kind: 'stub',
title: 'Stub',
contentId: 'sidebar://stub',
visible: true,
navigation: { address: 'sidebar://stub', params: undefined, revision: 0 },
signal: new AbortController().signal,
},
}
const host = document.createElement('div')
document.body.appendChild(host)
let root: Root | undefined
act(() => {
root = createRoot(host)
root.render(createElement(NativeTabBody, {
sessionId: 's1',
ctx,
store,
service,
records,
descriptorId: 'stub',
useTabInfo: () => info,
}))
})
const wrapper = host.querySelector('[data-dsh-native-tab-host=""]')
expect(wrapper, 'the full-height host wrapper must exist').not.toBeNull()
expect(wrapper!.querySelector('[data-stub-body]'), 'the descriptor component renders inside the wrapper').not.toBeNull()
expect(wrapper!.childElementCount).toBe(1)
act(() => { root?.unmount() })
host.remove()
})
})
+34 -4
View File
@@ -2,7 +2,8 @@
* Unit tests for the file-address grammar (src/client/resource-address.ts):
* the plugin parses DSH's `dsh-resource://file/…` addresses itself (the
* client bundle's purity gate forbids value-importing the upstream util), so
* these tests pin the upstream shapes it must agree with.
* these tests pin the upstream shapes it must agree with — DSH 0.1.5-alpha.2
* (`packages/util/workspace-path` in github.com/deepseek-ai/deepseek-harness).
*/
import { describe, expect, it } from 'vitest'
import {
@@ -19,6 +20,12 @@ describe('parseFileAddress', () => {
})
})
it('reads a session-scoped address whose path is absolute inside the scope', () => {
expect(parseFileAddress('dsh-resource://file/session/s1//outside/a.ts')).toEqual({
scope: 'session', sessionId: 's1', path: '/outside/a.ts',
})
})
it('reads an absolute address (POSIX, drive, UNC)', () => {
expect(parseFileAddress('dsh-resource://file/absolute/home/me/notes.txt')).toEqual({
scope: 'absolute', path: '/home/me/notes.txt',
@@ -37,14 +44,31 @@ describe('parseFileAddress', () => {
})
})
it('strips a query or fragment suffix', () => {
expect(parseFileAddress('dsh-resource://file/session/s1/src/a.ts? freshness=2')).toEqual({
scope: 'session', sessionId: 's1', path: 'src/a.ts',
})
expect(parseFileAddress('dsh-resource://file/session/s1/src/a.ts#L12')).toEqual({
scope: 'session', sessionId: 's1', path: 'src/a.ts',
})
// The first `?` or `#` ends the path; everything after is ignored.
expect(parseFileAddress('dsh-resource://file/session/s1/src/a%3Fb.ts#c%3Fd')).toEqual({
scope: 'session', sessionId: 's1', path: 'src/a?b.ts',
})
})
it('refuses anything that is not a file address', () => {
for (const address of [
'sidebar://guide',
'dsh-resource://attachment/session/s1/a.png',
'dsh-resource://file/other/s1/a.ts',
'dsh-resource://file/session/s1',
// Empty session id (alpha.2: leading `/` is a path, not an empty id).
'dsh-resource://file/session//a.ts',
'dsh-resource://file/absolute/',
'not a url',
// Malformed percent escape: decodeURIComponent throws inside the try.
'dsh-resource://file/session/s1/a%zz.ts',
]) {
expect(parseFileAddress(address), address).toBeUndefined()
}
@@ -58,6 +82,12 @@ describe('address builders', () => {
expect(parseFileAddress(address)).toEqual({ scope: 'session', sessionId: 's1', path: 'src/a b.ts' })
})
it('keeps an absolute path absolute inside the session scope (round-trip)', () => {
const address = sessionFileAddress('s1', '/outside/a.ts')
expect(address).toBe('dsh-resource://file/session/s1//outside/a.ts')
expect(parseFileAddress(address)).toEqual({ scope: 'session', sessionId: 's1', path: '/outside/a.ts' })
})
it('builds an absolute address and round-trips it', () => {
for (const [path, address] of [
['/home/me/x.txt', 'dsh-resource://file/absolute/home/me/x.txt'],
@@ -88,8 +118,8 @@ describe('fileAddressFor', () => {
expect(fileAddressFor('s1', '/work', '/work')).toBe('dsh-resource://file/session/s1/')
})
it('falls back to an absolute address outside the workspace (or with no cwd)', () => {
expect(fileAddressFor('s1', '/work', '/other/a.ts')).toBe('dsh-resource://file/absolute/other/a.ts')
expect(fileAddressFor('s1', undefined, '/other/a.ts')).toBe('dsh-resource://file/absolute/other/a.ts')
it('keeps an absolute path outside the workspace session-scoped with its leading slash', () => {
expect(fileAddressFor('s1', '/work', '/outside/a.ts')).toBe('dsh-resource://file/session/s1//outside/a.ts')
expect(fileAddressFor('s1', undefined, '/outside/a.ts')).toBe('dsh-resource://file/session/s1//outside/a.ts')
})
})