# 外部插件接入指南:基于 dsh-better-sidebar 实现新页面 > 面向 **消费插件开发者**:如何让你的插件向 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+**;路由前缀安全解析(`hostRouteUrl`)**v0.25.0+**。当前版本 **v0.30.0**(14 条 DSH peer 下限 `^0.2.2-rc.1`,仅支持 DSH **0.2.2-rc.1+**;0.2.1 线(0.2.1-rc.1 ~ rc.5)请固定 v0.28.2;0.2.1-alpha.5 请固定 v0.28.0-alpha.0,0.2.0-rc.2 请固定 v0.26.1(v0.27.0 尚未发布),0.2.0-rc.1 请用 v0.24.1,0.1.7 线请用 v0.22.1——caret 范围跨 minor 不成立,`^0.1.7-rc.1` 会被 0.2.0 宿主的启动预检静默禁用)。**v0.19.0 移除了自绘右侧面板与自由窗口**(见 §0、§11);**自 v0.20.0 开发线起**(**注意:0.20.0 从未发布到 npm,这些变更全部落在 v0.21.1**)插件**移除了自带的终端**(宿主 0.1.6 的 `ui-sidebar-terminal` 取代,见 §4.4)、**移除了终端固定(pin)**,并在 0.1.7 上**把浏览器视图与只读文件预览整体让给宿主**(`ui-sidebar-browser` / `ui-sidebar-documentpreview`,见 §4.4、§5.4)、**收敛了外链接管**(见 §4.1)、**重写了设置接入面**(`SettingsForms`,见 §8.2)、**给文件树加了实时刷新**(见 §10);同时**删除了轮尾产物行接管**(DSH 0.1.6 把 `conversation.chat.turnTail` 从 chain 改成只能追加的 list,替换语义不复存在)。 > **版本对照提醒**:当前版本 **v0.30.0**(DSH 0.2.2-rc.1+)。0.x 的 caret 不跨 minor,因此 0.2.1 线(0.2.1-rc.1 ~ 0.2.1-rc.5,即今天的宿主 npm `latest`)必须固定插件 **v0.28.2**——v0.29.0 从未发布;DSH 0.2.1-alpha.5 固定 v0.28.0-alpha.0,0.2.0-rc.2 固定 v0.26.1。市场安装器按插件 `latest` 安装、不按宿主版本选版本。 > 上一次 alpha.5 → rc.1 适配只动了 `package.json`(完整树差异 355 项,源码零变化);**本次 0.2.1 → 0.2.2 有真实代码改动**(`AgentModel` 取代可安装的 `ModelSelectionRef`、PTC 事件类型声明被移除、宿主图标改为 sprite),逐项记录见 [0.2.2 适配记录](plans/2026-11-04-dsh-0.2.2-rc.1-adaptation.md);伴随依赖仍为 Cordis peer `^4.0.5` / dev `4.0.5`、group `1.0.5`、schemastery `~3.18.5`、include `1.0.10`。 > 权威代码:`src/client/service.ts`(服务实现)、`src/client/builtins/`(内置 6 tab + 3 viewer 参考实现)、`lib/types/client/service.d.ts`(类型声明)。 > alpha.5 已删除 `@deepseek-ai/dsh-invariants` 与上游 `/invariant` 子路径;本插件同步移除 invariant companion,不提供替代注册服务。HTTP/WS 准入遵循宿主 Connection(见 §6)。 > 当前目录语义:**已 attach** 的会话里,文件 / Git / 终端与新建 sidechat 跟随会话 `working_directory`(live 读 `ctx.workingDirectory.get(session)`);**cold / detached** 会话读存储 header(`ctx.sessionPersistence.stat(id)`,只读第一个 zstd 帧,不解码日志;进程内 memo + 在飞去重),`activeWorktreeRootOf` 对 detached 直接返回该工作区地址。不要为取一个 cwd 调 `ctx.sessionQuery.observeSession`:它会解码整份会话日志并水合全部投影,启动期 fs-watch 每目录一帧的扇出会把它放大成数十次整读(详见 [2026-10-27 记录](plans/2026-10-27-workspace-cwd-over-cold-reads.md))。linked worktree 仍按 base 目录记账;新 sidechat seed 尾部盖章当前 cwd,防止继承历史目录覆盖它。 > alpha.5 的 `TerminalBlockLabels` 要求 `commandLine(line)`;两个渲染消费者与全部 21 份词典同步。宿主兼容性优先读 `package.json#engines.dsh`,缺席才读 DSH peers;本插件没有该字段,仍用 14 条 peers(第三方 `dsh.plugin.json` 不参与)。 > 仓库开发规则(硬约束 / CI / 发版)见 [AGENTS.md](../AGENTS.md)。 --- ## 0. 承载面:DSH 原生右侧栏 + 插件底部工作台(v0.19.0-alpha.0 起) 从 v0.19.0-alpha.0 起,**右列完全属于 DSH**:你的 tab 渲染在 **DSH 自己的右侧栏**里(`ctx.sidebarRight` / `ctx.sidebarRightTabs`),插件把每个 `TabDescriptor` 注册成原生 tab 类型(`kind = descriptor.id`)+ 一个原生 tab 体。插件自己只保留**底部工作台**(分栏树、会话内持久化;**其中不再有终端**——宿主 0.1.6 的右侧栏终端取代了它)。对你的接入代码**没有影响**——仍然只调用 `ctx.betterSidebar`: - `registerTab` / `registerFileViewer` 签名不变; - `openTab` / `openFile` 默认落到原生右侧栏;新增可选 `OpenTabSeed.target`(`'right'` 默认 / `'bottom'` 落插件的底部工作台 / `'side'` 落**原生栏的第二个格**,见下表「在侧边打开」); - `updateTab` / `closeTab` / `activateTab` 认识原生 tab id(插件为每个原生 tab 维护一条合成 `SidebarTab` 记录,`tab.meta` / `tab.path` 的写入照旧生效);`activateTab` 对原生 tab 经宿主 `ISidebarRight.focus`(dsh ≥ 0.1.5)聚焦 tab 及其所在 pane——多实例外部插件 tab(无 `(kind, 地址)` 身份可重开)因此也能被程序化唤起到前台。 - 新增可选 `OpenTabSeed.reveal: false`(只放置、不聚焦,后台激活用)与配套的 `clearUnread(type, sessionId)`(消除「有新页面」红点),两者均 **v0.25.0+**。 行为差异(写在这里以免踩坑): | 事项 | 说明 | |---|---| | 生命周期回调 | 原生面的「打开」不区分新建/聚焦,因此 `onOpen` 每次打开都触发(`onActivate` 仅在插件自己的底部工作台里触发)。程序化 `closeTab` 与 `activateTab` 在原生 tab 上正常工作:关闭走宿主 per-session 面,回调拿到完整 `tab`(**含 `meta`**);激活经 `ISidebarRight.focus` 聚焦 tab 与其 pane。**例外**:用户直接点原生 tab 上的 × 关闭不会触发 `onClose`(宿主不广播该事件,插件在 tab 体卸载时丢弃记录) | | 去重 | 原生按 `(kind, 地址)` 去重:有 `createTab` 的类型每次新开一个 tab(sidechat / diff),其余聚焦已有 tab;`dedupeKey` 的自定义语义不参与原生面 | | 布局持久化 | 原生栏的布局**只在内存**(刷新后回到折叠默认),插件自己的底部工作台仍然持久化 | | 跨会话打开 | 目标会话的右侧栏 store 未挂载时,打开会排队到该会话上屏后重放 | | 内置类型接管 | 插件的 `editor` 类型以 `extension` 优先级认领 `dsh-resource://file/**`(压过内置 `ui-sidebar-documentpreview` 的 `text` 预览——即 `fallback` 带),并接管内置 `files` 页面 kind(`openTab('files')` 打开插件的文件树);插件卸载/禁用时内置实现自动复位。**但认领是有选择的**:宿主自己的文档预览已经覆盖的格式(表格 / PDF / 图片 / Office,清单见 §5.4)由 `editor.canOpen` 主动**拒绝**,地址交回内置 `text` 档 | | 文件树实时刷新(v0.21.1+) | 插件接管了内置 `files` 页,所以宿主自己的按目录 watch 覆盖不到这棵树——插件自带一条 `/sidebar/ws/fs-watch` socket:客户端上报**已展开**的目录集,宿主侧按目录 `fs.watch`(150ms 去抖、每连接上限 64 个句柄),变动后只让那一层缓存失效并重列;目录折叠即退订。路径仍走 `fs.tree` 同一套词法解析 | | 编辑器预览自动刷新(#855) | 模型在会话里改完文件后,**正在看的那个预览**自己刷新,不必再手点刷新或重开文件。信号是插件自有的会话事件增量流 `changes.ops`(宿主 `src/index.ts` 路由),**每会话一个共享轮询器**(N 个编辑器 tab 每 tick 仍只有 1 个请求,全部隐藏即停;**叠加口径**:Git 页的会话透镜有它自己的轮询器(primed 之后改用 `wait` 长轮询等宿主事件,不再是固定间隔重放整窗),两者同开时该会话每 tick 至多 **2 个** `changes.ops` 请求,「1 个」只算编辑器这一份),**不新增 fs.watch / fsRead 指纹轮询**。只有 `write` / `edit` **结算成功**且路径命中本页文件的那些调用会触发重载(走既有的 `fsRead`/`load` 路径),未命中的文件零重读;**dirty 草稿与编辑态一律跳过**(脏态优先)。第三方 viewer 要保住自己的草稿就必须如实上报 `onToolbarState.dirty` / `mode`,见 §5.2 | | 地址里的行号(#826) | 宿主的 markdown 链接语法只认 GitHub 的 `#L131` 片段,`path:131` 形式(编译器诊断 / `grep` 的写法,模型输出很常见)会被当成**文件名的一部分**送进地址。插件的 `editor` 类型在把地址转成合成记录时把尾部行号规格(`:行` / `:起-止` / `:行:列`)切掉,tab 标题同样按切完的文件名给——否则点开就是一个报「文件不存在」的 `x.c:131`。切分只在这一步发生(`src/client/path-line.ts`),文件树 / 搜索 / 插件自有流程的路径不经过它;代价是**从聊天链接**打开一个真名以 `:数字` 结尾的文件会丢掉后缀(Windows 上 `:` 本就不是合法文件名字符)。行号随后顺着记录一路走到 viewer:两种写法落到**同一行**(`#L131` 由宿主解析成 `navigation.params.line`,`path:131` 由插件切出来,两者合流到 `SidebarTab.line`),文本编辑器在**到达时定位一次**并高亮该行,越界行号钳到最后一行。区间只取起始行、列号丢弃——编辑器只有一处光标。`line` 是**瞬态**:`sanitizePersistedTab` 的白名单不含它,刷新后不会把读者拽回几天前的那一行。markdown / html 收到行号时会**强制切到编辑态**再定位(预览里没有源码行号可放,而它自己的挂载滚动恢复也会和跳转打架)——引用要的是源码。这条强制是**一次性导航、不是文件的模式**:viewer 定位完成后回调 `onLineConsumed?.()`,宿主(`EditorHost`,它比 viewer 活得久——一次 reload 会把 viewer 整个重建)就停止再下发同一个 `line`;不这样做的话,读者点「预览」触发的 reload 会让重建出来的 viewer 重新强制切回编辑态,永远回不去。不消费 `line` 的 viewer(如按扩展名认领、只渲染图片的)与本行为无关。**两条入口现在一致**:聊天正文由宿主解析出 `navigation.params.line`;插件自绘的 markdown 预览(编辑器预览 / 侧边对话转录 / 变更阅读窗 / 任务窗口)在 `MarkdownDelegate.openFile(path, { line })` 里收到同一个数字,转成 seed 的 `line` 再走同一条链路——所以预览里点 `#L42` 与聊天里点 `#L42` 行为相同(跳到第 42 行 + 切到编辑态)| | 链接接管(v0.21.1+) | DOM 层只接管**有类型通过 `urlTarget` 声明认领**的外链(Ctrl/Cmd/Shift/Alt 点击一律放行);一个都没认领到时**不阻止默认行为**,交回宿主。见 §4.1 的 `urlTarget` | | path 种子的去向(v0.19.2+) | `path` seed 的含义**跟随类型**:只有 `editor`(唯一认领 `dsh-resource://file/**` 的类型)把 path 转成资源地址打开(文件落在编辑器);**其余类型保留页面型打开**,path 随导航 params 落到合成记录的 `tab.path` 供组件消费——组件型 tab 的 path seed 不会被改道到文件编辑器(v0.19.0/0.19.1 上一切 path seed 都被改道,组件从未挂载,#632) | | 文件打开的认领判定 | 插件自己的一切文件入口(文件树点击 / 路径输入 / Git 页 / 「新标签页打开」,合并与分屏两种模式)都先问宿主原生 tab 注册表:`ctx.sidebarRightTabs.candidates(address)` 的头名若不是本插件的 `editor`,地址改走 `ctx.sidebarRight.openResource`,由**认领类型**渲染自己的 tab——第三方直接在宿主注册表按更具体 glob 注册的原生类型(如 `.drawio` 画板)从此在文件树里点得开(#695);无人认领、宿主无注册表或探测失败时行为逐字保持原样(落到 `editor` / code 兜底) | | 终端(宿主一条 + 插件底部一条,彼此独立) | 插件**不在原生右侧栏提供终端**:宿主 0.1.6 起自带 `ui-sidebar-terminal`(kind `terminal`),插件侧 PTY 栈与 `terminal_*` 工具整体删除。**但插件自己的底部工作台有一条终端**(`terminal-bottom`,#774):它不注册原生类型、不占 guide 条目(`bottomOnly`),页面是插件自绘的 xterm,进程由**宿主的会话级终端服务** `ctx.webTerminals` 供给——插件不持有 PTY,也不与右列那条共享进程(两条终端各自独立是有意的,右列仍是宿主的地盘)。宿主没挂终端控制器时该类型 `available` 为 false(菜单置灰),页面显示「宿主未提供终端能力」而不是崩 | | `bottomOnly`(#774) | 只投底部、永不注册原生承载面的类型(字段说明见 §4)。它同时意味着:`openTab` 落点强制 `'bottom'`(连 `target: 'side'` 也被改道——原生栏里的 tab 不在底部工作台的分栏树里,两者归属矛盾)、原生 tab 类型与 guide 条目都不注册。`hidden: true` **不是**它的替代品:那会连 `+` 菜单一起摘掉(`buildNewTabOptions` 过滤 `!hidden`),而它要的正是「留在 `+` 菜单、只离开原生栏」 | | 底部终端的恢复语义(#774) | 刷新后重新上屏同一页签时,页面用**同一份 `(sessionId, key, contentId)`** 再次调用宿主的 `webTerminals.view()`:宿主持久化在 localStorage 的 `(sessionId, contentId) → 终端 id` 绑定因此把**同一个** PTY 认回来(`contentId` 是 `dsh-better-sidebar:terminal-bottom:`——**每个页签一个实例,不再是恒定值**,#926)。插件**不把 terminalId 写进 `tab.meta`**:`view()` 显式传 terminalId 会关掉宿主的「缺了就建」分支,一个过期的 id 会把页签永久卡在 `missingTerminal` 上。DSH 重启后 PTY 全没了而 localStorage 绑定还在,所以「新建终端」按钮把 `tab.meta.terminalRun` +1 换一代 `contentId`,让宿主另铸一个进程,而不是反复认领死绑定 | | 底部工作台的开合 | 落到底部工作台的打开一律展开它(新建与聚焦都算),因此 `openTab` 的落点永远可见;开合按钮注册在 DSH 会话头的 utilities 槽(`conversation.session.header.utilities`),不在插件自己的宿主里 | | 在侧边打开(`target: 'side'`,v0.22.0+) | 原生栏里的 tab **不在插件底部工作台的分栏树里**,所以「在侧边打开」不能写 `bottomSplits`(那样会落到用户没展开的底部工作台 = 点了没反应)。服务改为把这一步交给宿主:带 path 的 `editor` seed 走 `openResource(address, { preferNewPane: true, revealIfOpened: false })`——宿主先按自己的两格上限与空间规则尝试分栏,分不了才回退到当前格;`revealIfOpened: false` 允许与已打开的同名资源并存,因此对同一个文件再点一次也会新开一格。其余类型若传 `target: 'side'`,同样以 `preferNewPane` 落原生栏(组件型 tab 的 path 仍是组件种子)——**唯一的例外是 `bottomOnly` 类型**(#774):它没有原生承载面可落,`target: 'side'` 被改道回 `'bottom'`。path-less 的 `editor`(文件页)与 `'bottom'` 行为不变 | | 新建标签页列表 | 每个 tab 类型在原生 guide 里占一行:标题取 `title` + 图标取 `icon`(缺图标时宿主补一个方块占位),说明取可选的 `description`——**宿主只在 guide 列出的条目 ≤ 4 条时渲染说明**(上游 `MAX_DESCRIBED_ENTRIES = 4`),更长的列表整列丢掉所有说明;未声明 `description` 的条目渲染成单行「图标 + 标题」(rc.1 起 `description` 回到宿主契约,但**宿主与插件都没有兜底句**,所以插件恢复字段而不恢复旧的通用句);`hidden: true` 的类型不占行,**`bottomOnly: true` 的类型同样不占行**(#774:它只活在插件自己的底部 `+` 菜单里)。插件的 `editor` 类型不再单独占行(它认领的文件资源由 `files` 接管页承载同一视图)。**本插件默认贡献 4 个 guide 条目**(文件 / Git / 任务管理 / 侧边对话,恰好在上限内),**但宿主的终端条目也占一行**——装了宿主终端即是 5 条,说明整列不渲染;要让说明回来,需在插件设置页关掉足够多的 tab 类型把总数压到 ≤ 4 条 | | 新建面板的种子(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`——这些只作兼容保留,不向其迁移 | | dsh-context 可选联动(开发线) | 通过 Cordis 子作用域等待宿主服务,并订阅原生类型及内容槽注册;仅当 Context 侧栏可用时显示 Agent 详情、后台任务真实属主与当前会话标题栏的诊断入口。跨会话先导航到目标再打开 Context,复用该会话其他分栏已有标签;发起导航即关闭来源详情/输出浮层,插件级请求不随浮层卸载取消;导航最多等待 10 秒,用户继续导航或依赖消失即结束。后台任务没有真实 `job.owner` 或可靠子代理地址时不提供诊断,不回退到观察会话。只借用 `ctx.layout.beginNavigation()` 的取消语义,不贡献全局主面板;不扩展 `ctx.betterSidebar` 公共 API,也不要求消费插件改代码或安装 dsh-context。 | | 已移除 | 插件自绘右侧面板(含宽度拖拽 / 新会话默认宽度)与**自由窗口**(`features` 里的 `'floatWindows'` 已删除,v0.18.x 及更早版本的消费者请勿再 gate 该能力);`openByDefault` / `defaultWidthPercent` / `changesDiffFloat` 三个设置项同步删除(旧文档里的键会被忽略);**插件自带的终端与浏览器 tab 类型**(宿主 0.1.6/0.1.7 自带两者)与**只读文件预览**(image / pdf / Office / 表格,宿主 0.1.7 的 `ui-sidebar-documentpreview` 接手)同样不再内置,详见 §4.4 与 §5.4 | --- ## 1. 总览:你能扩展什么 better-sidebar 从 v0.4.0 起把自己改造成一个**注册表服务**: - **新页面(tab)**:注册一种新的侧边栏 tab 类型,出现在侧边栏 `+` 菜单里,用户点击后在自己的分栏里打开你的 React 页面; - **文件预览器(file viewer)**:注册一种文件类型预览器,让用户在侧边栏打开文件时走你的渲染组件(覆盖或补充内置的 markdown/html/code)。**宿主自己也有文档预览**——DSH 0.1.7 的 `ui-sidebar-documentpreview` 拿走了表格 / PDF / 图片 / Office 等只读格式,本插件因此**拒绝**认领那些扩展名,你的 viewer 也接不到它们(清单与原因见 §5.4)。 内置的 6 个 tab(editor / git——「Git」统一 tab(Source Control 视角 + 本轮文件视角,Git 视角内含只读提交图)/ subagent / sidechat / terminal-bottom / diff)和 4 个 viewer(markdown / html / jsonl / code)**自己也是通过同一套 API 注册的**(吃自己的狗粮),所以外部插件的能力与内置功能完全对等。**不再内置 terminal / browser 两个 tab 类型**:DSH 0.1.6 自带右列终端(`ui-sidebar-terminal`、kind `terminal`),浏览器是 0.1.6 的 `ui-sidebar-browser`(kind `browser`,**0.1.7 起只在 desktop profile 挂载**,Web profile 没有这个 kind);插件的同名类型会让读者看到两份实现。同理,0.1.7 的 `ui-sidebar-documentpreview` 让插件的 image / pdf / Office / 表格预览失去意义,那三个 viewer 描述符(image / pdf / binary-download)已删除。 关键机制一句话:better-sidebar 的 client half 在 `apply()` 开头执行 `ctx.provide('betterSidebar', service)`(`src/client/index.tsx`),消费插件在 `inject` 里声明 `'betterSidebar'`,Cordis 保证服务就绪后才激活你的插件,然后你调用 `ctx.betterSidebar.registerTab(...)` / `registerFileViewer(...)` 完成注册,返回的 disposer 由 Cordis fiber 在卸载(HMR / 禁用)时自动调用。 > ⚠️ **服务只在 client half**:`ctx.betterSidebar` 只存在于浏览器侧。你的插件 **host 半没有这个服务**;host 半需要读 better-sidebar 状态时,走它自己的 HTTP/WS 路由(`/sidebar/api/*`、`/sidebar/file`、`/sidebar/ws/*`),不走服务。 --- ## 2. 前置:类型合并与依赖声明 ### 2.1 类型合并(统一 `@deepseek-ai/cordis`) DSH 运行时和生态的类型正主是 vendored `@deepseek-ai/cordis`。你的插件解析到它的 `Context` 时,`ctx.betterSidebar` 不会自动出现——由 better-sidebar 用 `declare module '@deepseek-ai/cordis'` 补上: ```ts import type {} from 'dsh-better-sidebar' // 触发 declare module '@deepseek-ai/cordis' 类型合并 ``` 这个 **type-only import** 在编译时被擦除,不产生任何运行时依赖,也不会触发构建纯度门(见 §10)。 ### 2.2 package.json 声明 ```jsonc { "name": "my-plugin", "peerDependencies": { "@deepseek-ai/cordis": "^4.0.1", "dsh-better-sidebar": "workspace:*" }, "peerDependenciesMeta": { "dsh-better-sidebar": { "optional": true } } } ``` - `dsh-better-sidebar` 必须是 **peerDependency**(不是 dependency),避免两份实例; - `optional: true`:better-sidebar 未安装时你的插件照常加载,注册代码因为 `ctx.betterSidebar` 为 undefined 而安全跳过。 > ⚠️ **计划上架 DSH 插件市场**的插件还须遵守市场 manifest 约束(`dependencies`/`peerDependencies`/`optionalDependencies` 一律不得出现 `cordis`、`scripts` 不得含 install 类钩子),详见 [AGENTS.md](../AGENTS.md) 硬约束一节。 > 📌 本插件自己的 DSH peer 下限自 v0.30.0 起统一为 `^0.2.2-rc.1`(**v0.30.0 起 14 条**——新增 `@deepseek-ai/dsh-client-ui-input-trigger`,划选引用 chip 的点击与发送展开走它的 `ctx.inputTriggers` 服务,见 §7.2)。宿主预检按名字为 `@deepseek-ai/dsh` 或 `@deepseek-ai/dsh-` 开头的 peer 判定兼容性,所以**只加在 devDependencies 里是不够的**。 ### 2.3 类型导入路径 ```ts // 方式一:主入口(推荐,src/index.ts 已 re-export 全部描述符类型) import type { BetterSidebarService, TabDescriptor, TabComponentProps, FileViewerDescriptor, FileViewerProps, FileFetchStrategy, } from 'dsh-better-sidebar' // 方式二:子路径(与主入口等价) import type { TabDescriptor } from 'dsh-better-sidebar/client/service' // 别名 ./client/api ``` v0.12.0 起,服务模块还 re-export 了完整的状态词汇表,消费者可以直接命名(不再只能靠推断): ```ts import type { SidebarTab, SidebarState, SidebarStore, SidebarSnapshot, SidebarDiffRef, TabType, SessionScope, SidebarPrefs, OpenTabSeed, SidebarSettingsRenderProps, } from 'dsh-better-sidebar/client/service' ``` > 💡 **类型合并触发路径**:`import type {} from 'dsh-better-sidebar/client/service'` 同样会加载 `Context` 的 augmentation(`declare module '@deepseek-ai/cordis'` 在 context-types.d.ts 中)——**纯浏览器侧插件建议走 `client/service` 路径**,避免拉进宿主半的 Node 类型图(主入口 `dsh-better-sidebar` 的声明面含宿主代码;宿主消费者本就处于 Node 环境则无所谓)。client 可达声明图(`client/*` + context-types + html-route + prefs-shared)自 v0.12.0 起**零 Node 依赖**(`scripts/check-consumer-types.sh` 守护),没有 `@types/node`、`skipLibCheck: false` 也能编译。 --- ## 3. 最小骨架(client half) ```ts // my-plugin/src/client/index.ts import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并 import type { Context } from '@deepseek-ai/cordis' export const inject = ['betterSidebar', 'slots'] // 声明服务依赖(slots 可选,按需) export function apply(ctx: Context): void { // 注册一个 sidebar tab:ctx.effect 包裹 → 卸载时自动撤销注册(HMR-safe) ctx.effect(() => ctx.betterSidebar.registerTab({ id: 'my-plugin:db', title: () => 'Database', icon: , order: 50, component: ({ scope }) => , }) ) // 注册一个文件预览器 ctx.effect(() => ctx.betterSidebar.registerFileViewer({ id: 'my-plugin:csv', exts: ['csv'], fetchStrategy: 'custom', load: async (path, scope) => parseCsv(await fetchCsvBytes(scope, path)), component: ({ customData, path }) => , }) ) } ``` 要点: - **注册必须包在 `ctx.effect(...)` 里**。`registerTab` / `registerFileViewer` 返回 `() => void` disposer,Cordis fiber 卸载时自动调用;不包 effect,HMR / 插件禁用后注册残留,下次激活会抛 `"already registered"`。 - `inject = ['betterSidebar']` 让 Cordis 在 better-sidebar 激活后才激活你的插件,注册时机无忧(顺序无关)。 - 注册在 `apply` 内任意时刻都行;服务在 better-sidebar 的 `apply()` 开头就绪。 --- ## 4. 新页面(Tab)注册 API ### 4.1 `TabDescriptor` 完整字段 ```ts // 设置行控件形状(settings.toggles / settings.pluginToggles 共用;text/number v0.11.0+,select v0.13.0+,patterns 为可编辑字符串数组): interface SettingRow { key: string // toggles: SidebarPrefs 字段名;pluginToggles: 插件局部 key title: string | (() => string) desc?: string | (() => string) type?: 'switch' | 'text' | 'number' | 'select' | 'patterns' // 缺省 'switch' min?: number; max?: number // number 行提交钳制 placeholder?: string; unit?: string // text 行占位符 / 单位后缀(如 'px') patternsPlaceholder?: string // patterns 行添加输入框占位符 options?: readonly { // select 行选项 value: string | number | boolean title: string | (() => string) desc?: string | (() => string) icon?: ReactNode | ((size: number) => ReactNode) }[] multi?: boolean // select 多选(缺省 false;存 value 数组,按 options 顺序提交) } // text/number 行 blur/Enter 提交;select 任一项带 icon 时渲染大图标选项卡,否则单行文本。 interface TabDescriptor { /** 唯一 id;也是 SidebarTab.type 的值。建议带包前缀:'my-plugin:db'。 */ id: string /** 标题(i18n 友好:传字符串或返回字符串的函数) */ title: string | (() => string) /** * 一行说明,渲染在标题下方(DSH 原生右侧栏的新建标签页 / guide 列表)。 * **宿主只在 guide 列出的条目 ≤ 4 条时渲染它**(上游 `MAX_DESCRIBED_ENTRIES = 4`), * 更长的列表整列丢掉所有说明——也就是说这是一行「锦上添花」,别把关键信息只放这里。 * 不声明就不发 `description` 字段:宿主没有兜底句,插件也不补(通用句在所有页面上 * 长得一样,纯噪音),条目就渲染成单行「图标 + 标题」。函数形式在渲染时求值,跟随语言。 */ description?: string | (() => string) /** 图标:ReactNode 或 (size: number) => ReactNode(不声明时宿主补一个方块占位) */ icon?: ReactNode | ((size: number) => ReactNode) /** + 菜单排序(升序);默认 100。内置:editor=10, git=20, subagent=30, sidechat=35, terminal-bottom=40 */ order?: number /** * 从 + 菜单隐藏(diff 用:由其他流程触发打开,不在菜单里)。同时不占原生 * guide 条目、也不出现在「侧边卡片」设置清单里——它没有任何可见承载面, * 开关无处可关(旧版本给它发过开关,点掉后唯一效果是 `openTab` 静默拒绝, * 见 #1009)。因此 hidden 类型**恒为启用**:`isTabEnabled()` 忽略 * `tabsEnabled[id]`。 */ hidden?: boolean /** * 只投放到插件自己的底部工作台,**永不注册原生右侧栏承载面**(#774)。 * * 效果有两处,都必须同时成立才有意义: * - `openTab` 落点强制为 `'bottom'`(即使调用方传了 `target: 'side'`)—— * 原生栏里的 tab 不在底部工作台的分栏树里,「在侧边打开」一类请求原本会 * 在宿主侧另起一个 tab,与这个页面的归属矛盾; * - 原生承载面既不为它注册 tab 类型,也不为它占 guide 条目——缺了这一半, * `+` 菜单里那一行会变成原生栏里第二个语义重复的胶囊。 * * **它不是 `hidden` 的替代品**:`hidden` 会连 `+` 菜单一起摘掉(`buildNewTabOptions` * 过滤 `!hidden`),而 `bottomOnly` 要的正是「留在 `+` 菜单、只离开原生栏」。 * 内置用例是 `terminal-bottom`:DSH 自带的右列终端已经占住 `terminal` 这一格, * 插件自己的底部终端必须**不被看成第二个终端入口**(挂载 lane 钉住宿主 * `terminal` guide 条目恰好 1 条)。用它的类型请自备页面组件——原生栏的 * 「页面级」承载面(guide 胶囊 / 原生芯片 / `keepMounted`)一概不提供。 */ bottomOnly?: boolean /** + 菜单禁用判定(用量配额一类)。返回 false 只影响菜单 disabled,不拦截 openTab(只有设置页禁用开关会)。 */ available?: (ctx: Context, scope: SessionScope, state: SidebarState) => boolean /** * 单实例语法糖:`single: true` ≡ `dedupeKey: () => id`(打开时聚焦既有 * 同类型 tab 而非新开)。显式给出 dedupeKey 时优先于 single。 */ single?: boolean /** * 去重键:openTab 时若已存在 dedupeKey 相同的 tab,则聚焦而非新开。 * 返回 undefined 表示不去重(每次都新开,但同 id 会被 id 安全网聚焦)。 * 内置策略:git/subagent 用 single;editor 用 tab.path;diff 用 tab.id。 * 必须纯函数:每次 open 求值两次,抛错向外传播。 */ dedupeKey?: (tab: SidebarTab) => string | undefined /** * 自定义 tab 创建(minting SidebarTab + 状态 patch)。 * 返回 null 拒绝创建。sidechat 用它实现「一个线程一个 tab」。 * 省略时用默认 { id, type, title } + seed 里的 path/diff。 */ createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial } | null /** * 外链认领(v0.13.0+,`features.includes('urlTarget')` gate):声明后,DOM 层 * 捕获到的外链点击里**只有被某个启用类型认领的那些**会被接管——第一个 * urlTarget 返回 true 的类型以 openTab({ type, url, title: hostname }) 打开, * URL 预填 tab.path。先到先得;谓词抛错被吞。 * * **没被认领的链接一律放行**(插件不 preventDefault):DSH 0.1.7 起正文链接的 * 去向由宿主的用户设置 `linkOpening` 决定(进侧栏还是新标签页),插件自绘 * markdown 里则走 `` 自己的默认行为。**v0.21.1 起那三个「按协议分流」 * 的旧设置项已删除**(旧文档里的键会被忽略),本插件不再有任何接管总闸—— * 认领与否完全由你的 urlTarget 决定;Ctrl/Cmd/Shift/Alt 点击永远绕过接管。 * 认领成功但目标类型在打开那一刻已不可用(插件卸载 / 被设置关闭)时,点击 * 兜底为 `window.open(url, '_blank', 'noopener,noreferrer')`,不会变成静默无反应。 * * 多 URL 并存需 createTab 铸造 per-URL id,否则二次点击被 id 安全网聚焦、不覆写 path。 */ urlTarget?: (url: URL) => boolean /** * 声明式设置(v0.4.1+):见 §8。v0.12.0 起增加 `pluginToggles`(插件自有 * 设置行,key 无需宿主 schema 字段)与 `render`(自定义设置面板)。 */ settings?: SidebarSettingsDeclaration /** * tab 角标(v0.12.0+):tab 图标旁的小圆角 pill。number 渲染计数(99+ 封顶), * string 原样文本,null/undefined 不显示。每次 tab 栏渲染都会调用——保持廉价; * 抛错会被吞掉(不显示角标,不影响渲染)。 */ badge?: (ctx: Context, scope: SessionScope, state: SidebarState) => string | number | null | undefined /** * tab 栏右侧操作区(v0.25.0+):tab 激活时,返回的 ReactNode 渲染在该 pane 的 * tab 栏右端(+ 按钮右侧、面板关闭按钮左侧,右对齐)。让 tab 把自己的工具栏 * 直接放进 tab 栏,而不是在内容区顶部再单独渲染一行头部。返回 null/undefined * 完全不渲染(连空容器都不挂载);抛错被吞掉(不渲染操作)。每次 tab 栏渲染 * 都会调用——保持廉价。 * * `tab` 是当前渲染的打开实例:`single: false` 且有分栏时同一类型可同时开多个 * 实例、每个 pane 各渲染自己的 tab 栏,重启/关闭等操作必须针对 `tab` 而不是 * 「该类型的那个 tab」。`paneId` 是这条 tab 栏所在的 pane(与 * `state.activePane` 比较可知该 pane 是否为聚焦 pane)。 */ rightActions?: (ctx: Context, scope: SessionScope, state: SidebarState, tab: SidebarTab, paneId: string) => ReactNode /** * 生命周期回调(v0.12.0+),只由 SERVICE 路径触发: * - onOpen:openTab 真正**新建** tab 后(dedupe/id 安全网聚焦不算打开;原生面每次打开都触发); * - onActivate:tab 被聚焦时(dedupe 聚焦、id 安全网聚焦、tab 栏点击激活); * - onClose:closeTab 关闭 tab 后(原生 tab 同样携带完整 tab,含 meta;用户点宿主 × 的关闭除外)。 * 内置专属流程(diff 拆分放置、sidechat 线程重开)直接改 state,不触发 * 回调——但它们只作用于内置类型(diff / sidechat),外部插件的 tab 永远走 * service 路径。回调抛错只 console.error,绝不打断打开/关闭流程。 * openTab 回调 scope 携带调用者传入的 { sessionId, cwd? }; * closeTab/activateTab 仅显式传 scope 时带 cwd。 */ onOpen?: (tab: SidebarTab, scope: SessionScope) => void onActivate?: (tab: SidebarTab, scope: SessionScope) => void onClose?: (tab: SidebarTab, scope: SessionScope) => void /** 渲染函数 */ component: (props: TabComponentProps) => ReactNode } /** 声明式设置声明(行控件形状见上方 SettingRow)。 */ interface SidebarSettingsDeclaration { /** 偏好字段行(key 必须落在本插件的 `PrefsSchema` 里——它已并入本插件 Loader 行的 `Config`,内置键清单见 §8)。 */ toggles?: readonly SettingRow[] /** 插件自有设置行(v0.12.0+):key 插件局部, * 持久化在 pluginSettings[],无需宿主 schema 字段。 */ pluginToggles?: readonly SettingRow[] /** 自定义设置面板(v0.12.0+):追加渲染在行列表之后,可单独存在; * 抛错被吞并显示内联错误。 */ render?: (props: SidebarSettingsRenderProps) => ReactNode } /** settings.render 收到的 props(v0.12.0+)。 */ interface SidebarSettingsRenderProps { store: SidebarStore service: BetterSidebarService prefs: SidebarPrefs /** 本 descriptor 自己的持久化设置 blob(pluginSettings[id])。 */ pluginSettings: Record /** 持久化一条本 descriptor 的插件设置(值须 JSON 可序列化)。 */ updatePluginSetting(key: string, value: unknown): void /** 关闭设置弹窗。 */ close(): void } ``` ### 4.2 `TabComponentProps`(你的页面组件收到的 props) ```ts interface TabComponentProps { ctx: Context // client cordis context store: SidebarStore // better-sidebar 的状态 store(可调 reduce 等) scope: SessionScope // { sessionId, cwd? } —— 会话标识,调用 /sidebar API 必带 tab: SidebarTab // 当前 tab 实例(含 id/type/title/path?/diff?/meta?) visible: boolean // 是否当前激活 tab 且面板打开(不可见时暂停轮询等) // 以下由内置 tab 使用,外部 tab 可忽略: expanded?: string[] // 文件树的展开目录集 onToggleDir?: (path: string) => void // 在会话 composer 插入一条 @ 引用。isDir=true 为目录:纯文本 `@dir/` // (含空格时按宿主语法加引号成 `@"my dir/"`,否则宿主读不成 folder), // 保留宿主文件夹装饰与补全;false 为文件:走宿主结构化引用 chip // (显示 @basename、序列化为完整 @path),宿主拒绝时回退纯文本。 // path 按宿主确认的 workspace root(客户端 `ctx.get('workspaces')` 的 // `WorkspaceView.path`,按会话成员匹配)投影,不是会话 cwd:@ 语法以 // workspace root 为基准。工作区数据不可用(服务缺失 / 快照未就绪 / // 会话不属于任何工作区)或路径在 workspace 之外时不写入草稿,只留一行 // warn——绝不产出猜测的相对路径或绝对路径。目录插入会带一个尾随分隔空格, // 使连续两次点击不会粘成一条 token。 onReferenceFile?: (path: string, isDir: boolean) => void onOpenFile?: (path: string) => void onOpenDiff?: (tab: SidebarTab) => void onSubagentJump?: (childSessionId: string) => void } ``` 实践建议: - **用 `visible` 做性能门**:subagent 内置页在 `visible === false` 时暂停轮询;你的页面若有轮询/订阅,同样处理。 - **用 `scope.sessionId`(+ `scope.cwd`)访问会话数据**:所有 `/sidebar/api/*` 请求都要带这两个字段(见 §6)。 ### 4.3 注册示例 **最简单实例 tab**(+ 菜单可见): ```ts ctx.effect(() => ctx.betterSidebar.registerTab({ id: 'my-plugin:notes', title: 'Notes', icon: , order: 50, single: true, // ≡ dedupeKey: () => 'my-plugin:notes' component: ({ scope }) => , }) ) ``` **多实例 tab + 外部触发打开**(每次新开,带自定义 id): ```ts ctx.effect(() => ctx.betterSidebar.registerTab({ id: 'my-plugin:doc', title: 'Doc', icon: , order: 60, // 不设 dedupeKey:每次 openTab 都新开 component: ({ tab, scope }) => , }) ) // 外部触发打开(你的插件其他流程、甚至用户操作): ctx.betterSidebar.openTab({ type: 'my-plugin:doc', title: 'Spec.md', id: 'doc:spec' }) ``` **条件可见**(仅满足条件时 + 菜单可用;返回 false 显示为 disabled 行而非隐藏): ```ts ctx.effect(() => ctx.betterSidebar.registerTab({ id: 'my-plugin:commits', title: 'Commits', icon: , order: 70, available: (ctx, scope, state) => hasGitRepo(state), dedupeKey: () => 'my-plugin:commits', component: ({ scope }) => , }) ) ``` **自定义创建**(mint 自增 id): ```ts ctx.effect(() => ctx.betterSidebar.registerTab({ id: 'my-plugin:console', title: 'Console', order: 80, createTab: (state) => ({ tab: { id: `console:${state.nextBrowser}`, type: 'my-plugin:console', title: `Console ${state.nextBrowser}` }, patch: { nextBrowser: state.nextBrowser + 1 }, // 借内置计数器;也可自建 state 字段 }), component: ({ tab, scope }) => , }) ) ``` **认领外链点击**(v0.13.0+,`urlTarget` + `createTab` 组合): ```ts ctx.effect(() => { if (!ctx.betterSidebar.features.includes('urlTarget')) return // 老版本优雅降级 return ctx.betterSidebar.registerTab({ id: 'my-plugin:web-docs', title: () => 'Docs', order: 80, urlTarget: (url) => url.hostname === 'docs.my-site.com', createTab: (state) => ({ // per-URL id,多 URL 并存 tab: { id: `my-plugin:web-docs:${state.nextBrowser}`, type: 'my-plugin:web-docs', title: 'Docs' }, patch: { nextBrowser: state.nextBrowser + 1 }, }), component: ({ tab, scope }) => , }) }) ``` ### 4.4 内置 tab 清单(不可重复注册) | id | order | single | hidden | 用途 | |---|---|---|---|---| | `editor` | 10 | 否(按 path 去重) | 否 | 唯一「文件窗口」(编辑/预览 + 资源管理)。chrome 恒合并形态:路径输入框 + 编辑器控件 + 可开关内嵌文件树(全局搜索 `fs.search`;状态存 `tab.meta.treeOpen/treeWidth`)。`editorExplorer`:关(默认)= 按 path 新开,无路径窗口 = 纯资源管理器;开 = 树点击/Enter 经 `updateTab` 原地切换(id/meta 不变),无路径窗口 = 带 chrome 空窗口。树右键「在新 Tab 中打开」「在侧边打开」(pane 右侧 split)。新会话 seed 空文件窗口(`title:'Files'`);旧 `explorer` tab 经 `sanitizeState` 迁移。树 listing 与文件名搜索应用 `explorerExclude` 排除模式(editor 卡片齿轮弹窗可编辑;host 侧过滤,匹配条目彻底移除且永不阻塞折叠);单目录链折叠为 `a/b/c` 面包屑行(POSIX 隐藏与排除条目不计入唯一子项判定) | | `git` | 20 | 是 | 是(本轮文件操作数) | 「Git」统一 tab(id 保留 `git` 以兼容持久化布局):**Git 视角**(Source Control:独立折叠的 Changes / Graph;Changes 内为多行 Message + 仅填草稿的 Generate、Merge Changes → Staged Changes → Changes(资源组也独立折叠,计数常驻标题最右,文件行整行 hover);Git 刷新仅留在 Changes 标题,外层本轮文件刷新保留;真实 parent hashes 泳道图;常驻底栏为可点击分支 + `*`/`+`/`!` 标记、upstream 的 behind↓ / ahead↑ 及安全 Pull-then-Push 同步;Push/Pull 与 worktree·子仓库选择移入 More Actions;提交只消费已暂存项,冲突/空消息/无暂存时禁止,草稿按会话·仓库·worktree 隔离)+ **本轮文件视角**(原 file-trace:模型读/写/编辑实时折叠,按操作时间倒序平铺、不按文件分组,每行「操作 → 文件路径 → 大小/时间」,支持类型筛选);会话事件经插件自有宿主路由 `changes.ops` 供给(live 日志优先、冷会话回放持久化记录,`afterSeq` 增量),primed 后用 `wait` 挂在宿主侧等下一个工具事件而不是重放整窗),badge 读 tab 轮询写入的同步缓存。页头使用宿主公开的 `@deepseek-ai/dsh-client-ui-primitives` 中的 `SegmentedControl`(36px 高度、主题 Token 样式;自动处理选中状态、ARIA 关系及左右方向键、Home/End 键盘导航)。两视角共用底部可拖拽预览面板(`tab.meta.lens/previewH` 持久化),diff 渲染统一走 `src/client/diff/`(`DiffRows`/`DiffFiles`:mod 配对 + 行内高亮 + 语法着色 + 上下文折叠);Git 目标可展开为独立 diff tab(落进工作台的 diff 分栏) | | `subagent` | 30 | 是 | 否 | 任务管理(工作流图/树、团队任务板、后台任务抽屉) | | `sidechat` | 35 | 否(`sidechat:`,按 `meta.threadId` 去重) | 否 | 侧边对话(每对话一 Tab):打开即建空线程(首条消息赢得标签并同步标题);线程 = 插件自建子会话(种子继承父会话上下文,进行中回合以 `interrupted` 闭合;种子带合法 `subagent/descriptor`,SubagentView 按 `Side: ` 前缀过滤),`origin:'subagent'` 隐藏于主列表;走 `/sidebar/api/sidechat.*` 路由;头部菜单切换/重开(`parkSidechatReopen` + 确定性 id),关 Tab 释放 live agent;重开经 `collectOwnEvents` 回源到种子边界;「保存为新会话」= `session.fork`(`this` 敏感)。[设计文档](plans/2026-08-20-sidechat-tab-design.md) | | `diff` | -1 | 否(按 id 去重) | 是 | 差异查看(changes tab 的预览面板「展开为独立页签」触发,同一渲染栈) | | `terminal-bottom` | 40 | 否(`createTab` 每次铸新 id) | 否 | 底部工作台的终端(#774)。**`bottomOnly`**(见 §4 与「原生承载面」表的说明):没有原生 tab 类型、不占 guide 条目,只在底部 `+` 菜单里。页面是插件自绘的 xterm(`lib/client-terminal.js` chunk),**进程由宿主的会话级终端服务 `ctx.webTerminals` 供给**(结构化探测,不写进 `dsh.client.inject`——缺服务只会让这一行置灰,不会把整行拖成 `pending`)。恢复靠宿主持久化的 `(sessionId, contentId) → 终端 id` 绑定(`contentId` 形如 `dsh-better-sidebar:terminal-bottom:`,**每个页签一个实例**,#926),关页签走 `onClose` → `webTerminals.close()` 结束进程(卸载**不**关:宿主的契约是「视图跨 DOM 卸载存活」,刷新要能认回同一个 shell),`tab.meta.terminalRun`(默认 0)是「新建终端」换代号用的代号位。[设计文档](plans/2026-10-05-bottom-terminal-tab.md) | 你的 `id` 不可与上述重复,否则 `registerTab` 抛 `"tab type \"X\" already registered"`。 **自 v0.20.0 开发线起删除的类型(迁移提示;0.20.0 从未发布,变更落在 v0.21.1)**: | 原 id | 现状 | 你该怎么做 | |---|---|---| | `terminal` | 宿主自带 `kind: 'terminal'`(`@deepseek-ai/dsh-client-ui-sidebar-terminal`) | 不要再注册同名 kind。插件侧 API(`api.ptyClose` / `agentPtyClose` / `agentSkipWait` / `terminalDeps` / `shellGet`)与 `SidebarTab.pin` / `SidebarState.nextTerminal` / `agentWaits` / `isAgentTabId` / `agentUuidOf` / `reconcileAgentTerminals` / `mirrorAgentWaits` / `setTabPin` 全部**已删除** | | `browser` | 宿主自带 `kind: 'browser'`(`@deepseek-ai/dsh-client-ui-sidebar-browser`),**但 DSH 0.1.7 起只在 desktop profile 挂载**(`web-app` 的 patch 里 `disabled: ctx.get('profileContext')?.name !== 'desktop'`)——Web profile 上**没有这个 kind**,`openTab('browser', …)` 必然失败 | 不要再注册同名 kind。要打开网页:desktop profile 走 `ctx.sidebarRight.openTab('browser', { params: { url } })`(务必 try/catch 兜底,宿主未装该包时 `openTab` 会抛);Web profile 只能自己提供页面,或把链接交回宿主(正文链接由宿主的 `linkOpening` 设置决定去向)。插件侧的 `api.browserProbe` / `SidebarPrefs.browserNoSandbox` / `browserAllowedLoopback` **以及三个「按协议分流」的外链接管偏好键全部已删除**;`urlTarget` 机制保留,但不再有接管总闸(见 §4.1) | **同时删除的还有**:轮尾产物行接管。DSH 0.1.6 把 `conversation.chat.turnTail` 从 `chain` 改成 `list`(list 槽要求 `options.id`,且只能**追加**,不能替换宿主的产物行),插件因此整体移除了 `registerTurnTailInterception` 与 `selectProducedFiles`。要往那个位置加东西,按 list 契约注册 `{ name: 'conversation.chat.turnTail', id: '<你的 id>', order }`;`openSidebarFile(ctx, sessionId, path, { line?, cwd? })` 作为通用打开工具留在 `dsh-better-sidebar/src/client/sidebar-file.ts`(`resolveSidebarPath` 移到 `src/client/paths.ts`)——`cwd` 是调用方手上的**活**工作区根(`useSessionRoot`),只在 `path` 为相对路径时生效,缺省回退到会话列表里的 header cwd。 --- ## 5. 文件预览器(FileViewer)注册 API ### 5.1 `FileViewerDescriptor` 完整字段 ```ts interface FileViewerDescriptor { /** 唯一 id:'image' / 'pdf' / 'my-plugin:csv' */ id: string /** 设置清单展示名(v0.4.1+,i18n 友好);缺省回退到 id */ title?: string | (() => string) /** 设置清单图标(v0.4.1+):ReactNode 或 (size: number) => ReactNode */ icon?: ReactNode | ((size: number) => ReactNode) /** 小写无点的扩展名数组:['png','jpg']。[] = catch-all(仅最低优先级盲命中有效) */ exts: readonly string[] /** 优先级(高优先);默认 0。内置:markdown/html=0,code=-100 */ priority?: number /** 字节获取策略 */ fetchStrategy: 'none' | 'fsRead' | 'mediaUrl' | 'custom' | 'binary-download' /** 内容嗅探(覆盖 exts):head 字节可用时,第一个 detect 返回 true 的 viewer 命中 */ detect?: (path: string, head: Uint8Array) => boolean /** fetchStrategy='custom' 时的加载函数;v0.12.0+ 第三参 signal 在 viewer * 卸载/重匹配时中止(忽略 signal 的 load 也照常工作) */ load?: (path: string, scope: SessionScope, signal?: AbortSignal) => Promise /** 声明式设置(v0.4.1+):形状同 TabDescriptor.settings(§4.1 的 * toggles/pluginToggles/render;v0.12.0 起 viewer 卡片也有齿轮按钮) */ settings?: SidebarSettingsDeclaration /** 渲染函数 */ component: (props: FileViewerProps) => ReactNode } ``` ### 5.2 `FileViewerProps` ```ts interface FileViewerProps { ctx: Context store: SidebarStore scope: SessionScope path: string title: string viewerId: string // 命中 viewer 的 id(如 'code' / 'my-plugin:csv') content?: string // fetchStrategy='fsRead' 时;超过编辑上限的文件不带它 size?: number // 本次读取量到的文件字节数 largeFile?: { size: number } // 见下方「超大文件」 mtimeMs?: number // fetchStrategy='fsRead' 时:本次读取的磁盘 mtime(保存冲突基线) mediaUrl?: string // fetchStrategy='mediaUrl' 时 customData?: unknown // fetchStrategy='custom' 时(load() 的返回值) // 引用带行号时才有(#826,见 §0「地址里的行号」):一次性导航,不是文件的属性 line?: number // 1 基行号(如 `a.c#L131` / `a.c:131`);定位用得上的 viewer 消费它 // 行段引用(v0.30.0+):与 line 同时到达,给出被引区间的末行。内置编辑器据此 // 闪一下整段;不消费它的 viewer 行为与只收到 line 相同。 lineEnd?: number onLineConsumed?: () => void // 已消费该 line 的回报;不调用则每次加载都会重新下发(默认不消费) // 以下为内置文本编辑器与 EditorHost 内部协作字段,外部 viewer 忽略: toolbar?: 'self' | 'host' visible?: boolean // 本 tab 是否在屏(原生 tab 停驻时仍挂载) onToolbarState?: (state: EditorToolbarState) => void onToolbarControls?: (controls: EditorToolbarControls | null) => void onReload?: () => void // 从磁盘重新加载(保存冲突横幅的按钮回调) } ``` > **超大文件(v0.26.0 起,`truncated` 已被取代)**:文件超过编辑上限(5 MiB,见 `src/editor-limits.ts` 的 `MAX_EDITABLE_BYTES`)时,`fs.read` 不再回一份被截断的文档,而是 `{ kind: 'too-large', size, mtimeMs, head }` —— **不带 `content`**。宿主把这个结果交给命中的 viewer 时,`content` 缺席、`largeFile: { size }` 到位(见 `planFsReadOutcome`)。内置的文本 viewer 据此渲染只读分页预览(`LargeFileView`),并提供下载出口;**你的 viewer 必须容忍 `content === undefined`**:要么自己处理 `largeFile`(分页预览 / 提示),要么把状态回给 `onToolbarState` 让宿主收起保存按钮。旧的 `truncated` 布尔字段与 `EditorToolbarState.truncated` 都已删除——「拿到半份文档但看起来能编辑」这个状态本身没有了,这正是它被删掉的原因(#732:保存半份文档会毁掉文件尾部)。 > > **分页读取**:`fs.readPage({ sessionId, cwd?, path, offset })` 按字节窗口返回一页文本(宿主按行对齐,并在同一个文件内锁定宽字符编码),返回 `{ kind:'text', text, offset, nextOffset, eof, size, mtimeMs, encoding }`。消费方式就是**只向前追加**:把每页的 `text` 接到一起,下一次请求用上一页的 `nextOffset` 作为 `offset`,直到 `eof`。因此**必须串行**(下一页的偏移就是上一页的响应),也**不能跳到末尾**——随机访问需要打开时先扫全文件建索引,而那正是分页要避免的开销。内置预览在这之上加了两个策略:滚到底自动追加下一页,以及一条按**渲染行数**设的上限(`LargeFileView` 的 `MAX_PREVIEW_ROWS`),到顶后停止追加并提示下载。 > **模型改完文件后的自动刷新(#855)**:`EditorHost` 订阅插件自有的会话事件增量流(`changes.ops`,**每会话一个共享轮询器**,2.5s 增量、无事件不落盘读),当**当前会话**的某次 `write` / `edit` 工具调用**结算成功**且路径命中本页文件时,自动重跑一次加载——即把页头的刷新按钮变成自动的(走同一条 `fetchStrategy` / `load()` 路径,`mtimeMs` 基线语义不变;命中判定用 `resolveSidebarPath` 把模型写的相对路径按会话 cwd 归一)。对第三方 viewer 有两条含义:① 你**什么都不用做**就会拿到新内容;② 若你的 viewer 持有**未保存的草稿**,必须在 `onToolbarState` 里如实上报 `dirty: true`——dirty 时自动刷新会跳过(脏态优先,绝不覆盖用户输入);未上报 dirty 的 viewer 会被重挂(页面内 state 丢失、`content` 换成新字节)。处于编辑态的 viewer 同理应上报 `mode: 'edit'`(那条也会跳过,免得光标跳走)。**跳过不等于死路**:**未 dirty** 时既有的「编辑 → 预览」边沿照旧重载并拿到新内容(实测 `fsRead` 1→2);**dirty 时这条边沿自身也被守卫抑制**(它的实现同样要求 `dirty !== true`,`EditorHost` 的边沿判定),此时用户的两条显式出口是**页头刷新按钮**(`window.confirm` 后重载)与**「保存 → `fs-conflict` 横幅里的 reload」**(保存带 `mtimeMs` 基线,模型写过就被拒成 `fs-conflict` 而不是静默覆盖模型字节)。一句话:**dirty 期间保持旧内容直到用户显式动作,不存在看不到新内容的死路**。 ### 5.3 `fetchStrategy` 对照 | 策略 | 字节来源 | 传给 component 的字段 | 适用 | |---|---|---|---| | `none` | 不需要字节 | (无) | 自渲染(如纯 UI) | | `fsRead` | `/sidebar/api` 的 `fs.read`(超大文件转 `fs.readPage` 分页) | `content`, `size`, `largeFile` | 文本类(CSV/JSON/XML) | | `mediaUrl` | `/sidebar/file` 媒体路由 URL | `mediaUrl` | 图片/PDF(viewer 自己 fetch 字节) | | `custom` | viewer 的 `load()` 函数 | `customData` | 自定义协议(如远程拉取) | | `binary-download` | 不预览,显示下载按钮 | (无) | 无客户端渲染器的二进制格式。**本插件现在没有任何内置 viewer 用这条策略**(`binary-download` viewer 已让给宿主);策略本身仍在契约里,你的 viewer 可以照用 | ### 5.4 匹配算法(`matchFileViewer`) `matchFileViewer(path, head?)` **单趟**按 priority 降序(稳定排序,相同 priority 按注册顺序)遍历每个 descriptor: 1. 若 `head` 字节可用且该 descriptor 有 `detect` → 调 `detect(path, head)`,true 则命中;**miss 且是 catch-all(`exts: []`)则本轮放弃**(纯嗅探型不得盲认领); 2. 否则匹配 `exts`(小写无点;`exts: []` 且无 `detect` 是盲 catch-all,直接命中)。 即:**priority 高的 descriptor 先获得裁决权**(其 detect 或 exts 任一命中即赢),低 priority 的 detect 不会越过高 priority 的 exts 匹配。`exts: []` + `detect` 的组合是"纯嗅探":无 head 时不认领任何文件(不会吞掉图片/PDF 等真实 viewer 的文件),有 head 时只认领 detect 命中的。全部 miss 返回 `undefined`(编辑器显示下载按钮)。 > **head 字节从哪来**:第一次匹配(纯扩展名)没有 head。`fsRead` 策略读取后若文件为二进制,host 的 `fs.read` 响应会带 `head` 字段(base64,前 4KB,`src/index.ts` 的 `READ_HEAD_LIMIT`),编辑器会用它对 `detect` viewer **重匹配一次**——所以 detect 型 viewer 的实际触发场景是"扩展名匹配落空/二进制文件"。文本文件的 detect 嗅探不在内置流程内(用 `exts` 或 `custom` 策略替代)。 > **内置 viewer**(不可重复注册,全部 3 个):markdown(0, fsRead;内嵌 HTML 支持:DOMPurify 白名单消毒、`
` 跨段嵌套、本地媒体 src 重写走 `/sidebar/file`;≥3 标题时浮动目录大纲。实现 `markdown-html.ts` / `MarkdownHtml.tsx` / `md-toc.tsx`,[设计文档](plans/2026-08-24-markdown-html-toc-design.md)) / html(0, fsRead, 沙箱 iframe 预览 + `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` 两个宿主没有的逃生门) / code(-100, catch-all, fsRead, **可编辑** CodeMirror + 保存 + 未提交变更提示(行号着色 / 左侧细色条 / hover blame,一个 `editorGitGutter` 开关,[设计文档](plans/2026-10-05-editor-git-gutter.md)))。 > code 是兜底 viewer:任何其他 viewer 未认领的文件都会落到 code(CodeMirror 文本编辑);二进制文件经 head 重匹配找不到 sniffer,落到编辑器的下载面板。外部 viewer 注册同扩展名 + 更高 priority 即可覆盖内置的那三个。 > > ⚠️ **让给宿主的格式(重要,影响你的 viewer 能不能被调用)**:DSH 0.1.7 的 `ui-sidebar-documentpreview` 自带 code / excel(xlsx,xls,csv,tsv) / office(宿主侧转 PDF)/ pdf / image / html / markdown / text 预览,并带缩放与**按目录自动刷新**——本插件原有的 image / pdf / binary-download 三个 viewer 描述符因此删除。更重要的是**路由**:插件的 `editor` 类型在 `canOpen` 里对下列扩展名一律返回 false(`src/client/native/index.ts` 的 `HOST_OWNED_EXTS`),把地址让给内置 `text` 档,**根本不会进入本插件的 `matchFileViewer`**: > > `xlsx xls csv tsv fods pdf png jpg jpeg gif webp svg bmp ico doc docx ppt pptx` > > **这份清单现在是「宿主渲染器实际覆盖的集合」,不多不少**:`v0.21.1` 收回了 9 个宿主其实**没有渲染器**的扩展名(`xlsb` / `xlt` / `xltx` / `xltm` / `ots` / `dot` / `dotx` / `avif` / `ods`——前两个落在宿主的 `document/unviewable.ts`「已知二进制、无渲染器」表里,其余落回 text 兜底后按二进制判定失败,一律只有「暂不支持预览」),它们重新由插件的 `code` catch-all 认领并落到下载面板。`fods` 保留让出:宿主会用纯文本显示那段扁平 XML,比下载面板有用。`tests/native-surface.spec.ts` 双向钉住这条边界(宿主能渲染的必须拒绝、宿主不能渲染的必须认领)。 > > 后果:**你注册这些扩展名的 viewer 仍然合法(注册表不冻结 id,`matchFileViewer` 也会命中),但通过聊天 / 文件树 / 编辑器的正常文件打开路径拿不到它们**——那是宿主的文档预览。要在这些格式上做文章,只能自己在页面里渲染(或像 [Office 预览插件](https://github.com/HuanLinOTO/dsh-plugin-better-sidebar-plugin-office) 那样在宿主接管前的旧版本上生效)。**可用的是 md/markdown / html/htm 与 code catch-all 三类**(外加任何不在上表里的扩展名)。未知二进制(`.zip` / `.wasm`)仍走 code 认领 → `fs.read` 判 binary → head 重匹配无 sniffer → 编辑器渲染下载面板,功能不回归。 ### 5.5 注册示例 **CSV 预览器**(自定义加载 + 渲染): ```ts ctx.effect(() => ctx.betterSidebar.registerFileViewer({ id: 'my-plugin:csv', exts: ['csv'], fetchStrategy: 'custom', load: async (path, scope) => { const text = await fetchText(scope, path) return parseCsv(text) }, component: ({ customData, path }) => , }) ) ``` **覆盖内置 markdown viewer**(如自家渲染管线的只读预览): ```ts ctx.effect(() => ctx.betterSidebar.registerFileViewer({ id: 'my-plugin:md-pro', exts: ['md', 'markdown'], priority: 10, // 高于内置 markdown 的 0 fetchStrategy: 'fsRead', component: ({ content }) => , }) ) ``` > ⚠️ 只有内置仍认领的格式能被这样覆盖(md/markdown、html/htm,以及 code catch-all 接的其它扩展名)。`.svg` / `.png` / `.pdf` / `.docx` 之类**不要**照这个例子注册——地址在 `canOpen` 就被让给宿主的文档预览了,你的 viewer 永远不会被调用(原因与完整清单见 §5.4)。 **内容嗅探**(按 magic bytes 路由,忽略扩展名): ```ts ctx.effect(() => ctx.betterSidebar.registerFileViewer({ id: 'my-plugin:magic-parquet', exts: [], // catch-all,但 priority 高 + detect 精确命中 priority: 100, fetchStrategy: 'custom', detect: (_path, head) => head.length >= 4 && head[0] === 0x50 && head[1] === 0x41 && head[2] === 0x52 && head[3] === 0x31, // 'PAR1' load: async (path, scope) => parseParquet(await fetchBytes(scope, path)), component: ({ customData }) => , }) ) ``` --- ## 6. 页面内如何访问数据(/sidebar API) 你的 tab / viewer 组件运行在浏览器里,与内置视图同源同权。访问文件/会话数据直接 `fetch` better-sidebar 的 JSON API(内置 `src/client/api.ts` 的封装就是干这个的,你可以在自己插件里复制这个 fetch 模式): ```ts // POST /sidebar/api/,body 带 sessionId + cwd(可选) // 路由经 ctx.betterSidebar.hostRouteUrl() 解析(v0.25.0+):不要写前导斜杠 const res = await fetch(ctx.betterSidebar.hostRouteUrl('sidebar/api/fs.read'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sessionId: scope.sessionId, path }), }) const { value } = await res.json() // 错误时 { ok: false, error: { code, message } } ``` > **绝不写前导斜杠**(v0.25.0+,issue #753):`fetch('/sidebar/api/…')` 与 `new URL('/sidebar/ws/…', base)` 会把请求钉在**源站根**。反代把 GUI 挂在子路径时(如 `https://host/dataops/proxy/3080/`),请求会甩掉前缀 → JSON API 404、懒加载 chunk 加载不出来、媒体/HTML 预览空白、WebSocket 1006;桌面壳下 `dsh-app://app/` 更是直接不可达。`hostRouteUrl()` 用宿主同款的传输基底(壳注入的 `__DSH_TRANSPORT__.streamBaseUrl` 优先,回落 `document.baseURI`)解析相对路径,并把前缀原样保留;`features` 含 `'hostRouteUrl'` 时可用,旧版本回退到自查 `document.baseURI`(但拿不到桌面壳的真实基底)。你自己的 HTTP/WS 路由同理:一律**相对路径 + 页面前缀**。 常用方法(完整清单见 `src/client/api.ts`): | 方法 | 说明 | |---|---| | `session.cwd` | 会话权威 cwd(`{ cwd, root, parent }`) | | `fs.tree` / `fs.trees` | 目录列表(`{ path, entries: FsEntry[], truncated }`;FsEntry 含 `isSymlink`/`broken`,目录软链接的 `isDir` 按目标类型;请求可带 `exclude` 模式串,行上可带 `compact` 标记——单目录链由 client 折叠为面包屑行,匹配条目从列表与折叠探测中彻底移除)。`fs.trees` 是批量版(文件树的真实入口,同样吃 `exclude`) | | `fs.trees` | **批量**目录列表(v0.23.0+):入参 `{ sessionId, cwd?, paths: string[] }`(最多 64 条,绝对或会话相对),一次请求返回 `{ levels: [{ path, entries, truncated, error? }] }`。每层与 `fs.tree` 同解析、同缓存;**某一层失败只在该层带 `error`**(`entries: []`),整个批次仍是成功响应——树的一次挂载/刷新因此是 1 个请求而不是 N 个 | | `fs.read` | 读文件(v0.26.0 起三态):文本 `{ kind: 'text', content, size, mtimeMs }`;二进制 `{ kind: 'binary', size, mtimeMs, head }`;**超过编辑上限**(5 MiB)`{ kind: 'too-large', size, mtimeMs, head }`(head = base64 前 4KB,用于 `detect` 重匹配;`mtimeMs` = 本次读取的磁盘 mtime,保存冲突基线)。**不再有 `truncated` 文本读**——截断文档既不可信也不能保存(#732),超大文件改走 `fs.readPage` 分页预览 | | `fs.readPage` | 大文本文件的分页读取(v0.26.0+):`{ sessionId, cwd?, path, offset }` → `{ kind:'text', text, offset, nextOffset, eof, size, mtimeMs, encoding }`。窗口按行对齐(调用方可以直接当作整行/整条记录处理),单个超长行原样返回而不是空页;`nextOffset` 必然前进,`eof` 精确 | | `fs.write`(**已移至 raw 路由**) | v0.26.0 起保存走 `POST /sidebar/write?sessionId=&cwd=&path=&expectedMtimeMs=`,**请求体就是 LF 文档**(不再走 JSON,因此不再受 `/sidebar/api` 的 1 MiB body 上限约束);响应仍是 `{ ok: true, value: { ok: true, mtimeMs? } }` 信封。语义不变:`expectedMtimeMs` 是乐观并发门,磁盘 mtime 与基线不符则拒绝(409,wire 错误码 `fs-conflict`),**目标已不存在(保存瞬间被删除或改名走)同样判 409**——数字基线只可能来自一次成功的读取,即该路径当时确实有文件,所以「读不到」是冲突而不是新建意图(新建走 `fs.createFile`、上传走上传路径,都不经过这条分支)。省略则不做门禁;宿主在写盘前按磁盘现状还原文件的行尾与字节编码(`restoreEol` + `encodeText`) | | `git.status` / `git.diff` / `git.log` 等 | 全套 Git 只读 + 写操作 | | `pty.close` / `agent-pty.close` | **已删除**(插件自带的 PTY 栈随终端一起移除;宿主 `ui-sidebar-terminal` 不通过本插件的路由暴露控制面) | | `settings.get` / `settings.update` | 侧边栏偏好读写(revision 守卫,冲突回 wire 错误码 `settings-conflict`);后端在 0.1.7 上就是宿主的 `SettingsForms`,见 §8.2 | > **文件路径语义(v0.23.0 起:无包含检查)**:`fs.tree`、`fs.trees`、`fs.read`、`fs.write`、`fs.rename`、`fs.remove`、`fs.mkdir`、`/sidebar/file`、`/sidebar/html`、`/sidebar/upload` 和 `archive.build` 仍以请求对应 session 的权威 `cwd` 作为**相对路径的基准**,但**不再把 `cwd` 当边界**:绝对路径原样使用,`..` 只做词法折叠,符号链接跟随,越界不再是 403。安全影响见 §8.1 的醒目声明。 ### 6.1 打包下载(`archive.build` / `archive.status` / `/sidebar/archive`,v0.22.0+) 把若干文件/目录打成 ZIP 下载,并且**有进度可看**:选择项在 `archive.build` 里一次性收集(与 `fs.tree` 同一套词法解析——**无包含检查**,目录递归、符号链接跳过、同名条目用父目录消歧),打包在后台进行,客户端轮询进度、完成后取字节。 ```ts // 路由助手同 §6:相对路径交给 hostRouteUrl,别写前导斜杠 const route = (path: string): string => ctx.betterSidebar.hostRouteUrl(path) // 1) 启动:paths 是会话命名空间里的绝对路径(与 fs.tree 的行 path 同形) const build = await fetch(route('sidebar/api/archive.build'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sessionId, cwd, paths: ['/w/src', '/w/notes.md'], name: '报告' }), }).then(r => r.json()) // → { ok: true, value: { id: 'ar-…', name: '报告.zip', entries: 2 } } // name 是**最终**下载名:调用方只给词干,扩展名由宿主实际可用的工具决定 // (只有 tar 的机器产出 .tar.gz) // 2) 轮询:state 为 building | ready | error;bytes 是临时文件已写入的字节数 // (v0.26.0 起没有 done/total:打包交给系统的 zip/tar 子进程,它不报逐条目进度) const status = await fetch(route('sidebar/api/archive.status'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: build.value.id, sessionId }), }).then(r => r.json()) // 3) 下载(仅 ready 时):同一 id 只能取一次,取完即释放 const url = route(`sidebar/archive?${new URLSearchParams({ sessionId, id: build.value.id })}`) ``` 约束与状态码:任务表最多 **4 个并发构建**(超出时 `archive.build` 回 `bad-request`,HTTP 409),完成/失败后保留 **5 分钟**(过期即消失);`id` 只对**创建它的 session** 可用(其他 session 读是 `forbidden` 403);`/sidebar/archive` 在构建中回 **409**、构建失败回 **410**(消息即打包工具的 stderr 尾部)、未知/过期/已下载回 **404**。响应头为 `content-type: application/zip`(只有 tar 时为 `application/gzip`)+ `content-disposition: attachment; filename=""; filename*=UTF-8''<百分号编码>`(非 latin1 文件名走 RFC 5987,ASCII 档位对旧客户端生效)。 **v0.26.0 起打包交给系统命令,不再由本插件自己写 ZIP**(`src/zip.ts` 已删除,随之消失的是 `ZIP_MAX_ENTRIES` / `ZIP_MAX_BYTES` 两道上限——因此打包不再有大小限制): - 工具按 `zip` → `tar` 顺序探测(`src/archive-tools.ts`,结果按进程缓存)。`zip` 恒带 `-y`:不加它会**跟随**符号链接,一个 `a -> .` 的自指环会一直递归;`-y` 把链接本身存进包,既不会成环也更忠实。Windows 10 1803+ 自带 `tar.exe` 而没有 `zip`。 - 入参是绝对路径,宿主取它们的**最深公共祖先**作为工作目录、把相对名交给工具——同一目录下的选择按 basename 入包,不同目录的同名文件自然落在各自父目录下(旧版「逐段长出父目录直到不重名」的算法因此不再需要)。选择本身等于公共祖先时祖先上移一级;选中文件系统根会被 `bad-request` 拒绝。 - 命令的 stdout 直接写进 OS 临时目录里的临时文件,**从不整包进内存**;下载时 `createReadStream` 流式下发,流结束后才删除临时文件。插件的 teardown 会杀掉仍在跑的构建并清掉临时文件。 - **两种工具都没有时**:`archive.build` 立刻回 wire 错误码 **`unavailable`**(HTTP 501),客户端把它显示成一句可操作的中文/英文提示,而不是把英文 wire 文本原样抛出。 媒体/下载字节走 `/sidebar/file` 路由(`?sessionId=&path=&cwd=&download=1`): ```ts // 媒体 URL(图片等直接 ):/sidebar/file?sessionId=...&path=... const url = ctx.betterSidebar.hostRouteUrl( `sidebar/file?${new URLSearchParams({ sessionId: scope.sessionId, path })}` ) ``` > 注:内置的 `api.ts` 是 better-sidebar 内部模块,外部插件 **不要** value-import 它(构建纯度门会挡);按上表模式自己 fetch 即可,路由一律经 `hostRouteUrl()`(或等价的「相对路径 + 页面前缀」解析)。所有 HTTP/WS 路由复用宿主 `ctx.connection.requestRejection(req)`:Host/Origin 与浏览器认证均由宿主裁决,只有返回 `undefined` 才准入;同源页面也必须先完成宿主登录。 ### 6.2 侧聊图片输入 `POST /sidebar/api/sidechat.prompt` 接受 `{childId,text,images?}`,图片条目为 `{mediaType,data,name?}`,`data` 是原始图片字节的 base64,允许空文本加图片。成功返回 `{accepted:true,messageId}`,消息身份由宿主创建。`sidechat.info` 的 `images` 字段报告附件服务可用性和实际数量、单张及合计字节限制;图片是否适用于所选模型在发送时校验。像素预算与边长上限由宿主在准入时校验,超限回 **413**(wire 错误码 `too-large`,保留宿主 message);存储失败回 **500** `sidechat-error`,不再冒充调用方的 400。 请求体只在可能携带图片(超过 1 MiB 元数据上限)时计入并发图片准入;纯文本 prompt 不受该限制影响,超限回 413 而不是可重试的 503。 转录与 pending 行携带图片出现位置和显示元数据,读取走经宿主鉴权的 `GET /sidebar/sidechat-image?childId=...&messageId=...&blockIndex=...`。路由核验侧聊归属与用户消息身份,禁止用附件 ID 或本地路径直接读取。图片请求只在页签可见时发出;冷历史读取不恢复 Agent。输入与读取边界详见[设计记录](plans/2026-11-02-sidechat-image-input.md)。 --- ## 7. 服务方法完整清单 ```ts interface BetterSidebarService { /** 注册 tab 类型;返回 disposer */ registerTab(descriptor: TabDescriptor): () => void /** 注册文件预览器;返回 disposer */ registerFileViewer(descriptor: FileViewerDescriptor): () => void /** 注册自定义文件树/文件 tab 图标(v0.19.0+,features 含 'fileIcons');返回 disposer */ registerFileIcon(descriptor: FileIconDescriptor): () => void /** 当前已注册的 tab 描述符快照(同步,供 useSyncExternalStore 用;含被设置页禁用的类型) */ getTabs(): readonly TabDescriptor[] /** 当前已注册的 file viewer 描述符快照(含被设置页禁用的 viewer) */ getFileViewers(): readonly FileViewerDescriptor[] /** 当前已注册的文件图标描述符快照(v0.19.0+) */ getFileIcons(): readonly FileIconDescriptor[] /** 按 path 匹配**具体**注册(priority 降序、注册序):先 names 文件名,再 * 具体扩展名;不查 catch-all 与 folder 保留值。消费方一般直接用 * fileIcon/folderIcon 全链解析器。 */ matchFileIcon(path: string): FileIconDescriptor | undefined /** 匹配目录行注册:先按 folderNames 匹配目录名(name 传 basename,可省), * 再按 'folder'/'folder-open' 保留扩展名;priority 降序、注册序; * 返回 undefined = 回退内置 VscFolder/VscFolderOpened */ matchFolderIcon(open: boolean, name?: string): FileIconDescriptor | undefined /** 文件图标权威解析器(v0.19.0+),完整回退链: * ① 具体 names/扩展名注册 → ② 内置 glyph(md/媒体/pdf/json/代码/配置/数据库/lock/压缩包) * → ③ 最优 catch-all 注册(exts: [],即全局默认)→ ④ 通用 VscFile。 * 任一注册工厂抛错都会被吞(console.error 后跳下一级),永远返回有效 ReactNode。 */ fileIcon(path: string, size: number): ReactNode /** 目录图标解析器:注册的 folderNames/'folder'(闭合)/'folder-open'(展开)图标 → * 内置 VscFolder/VscFolderOpened;path 为目录自身路径(主题可按目录变化), * open 会传给工厂,一条注册即可渲染开/合两态。 */ folderIcon(path: string, open: boolean, size: number): ReactNode /** 按 id 查 tab 描述符 */ getTab(id: string): TabDescriptor | undefined /** 某个 tab 类型是否在 Side card 设置中启用(v0.4.1+;缺省 = 启用; * hidden: true 的类型恒为 true——设置清单不提供它的开关,见 §4) */ isTabEnabled(id: string): boolean /** 某个 file viewer 是否在 Side card 设置中启用(v0.4.1+;缺省 = 启用) */ isViewerEnabled(id: string): boolean /** 按 path 匹配 file viewer(priority 降序单趟:detect → exts;跳过硬禁用 viewer) */ matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined /** * 打开一个 tab(+ 菜单和外部触发都用它;走 descriptor.dedupeKey 去重)。 * title 可选:给出时优先于 descriptor.title(editor 显示文件名); * 有 createTab 的 descriptor 分落点:底部工作台(target: 'bottom')由 createTab * 整体铸造 tab,忽略 seed 的 title/path/id(url 种子仍预填新建 tab 的 path); * 原生右侧栏只忽略 id(原生 tab id 由宿主铸造,seed.id 仅影响 onOpen 收到的 * 合成 tab),createTab 铸造的 title/meta 作缺省、seed 字段优先(v0.19.2+ 起 * path 也随导航 params 下发,见下)。 * path 可选:含义跟随类型——editor(唯一认领 dsh-resource://file/** 的 * 类型,但会拒绝宿主的文档预览格式,见 §5.4)把 path 转成资源地址打开(文件落在 * 编辑器);其余类型 path 是组件种子,随导航 params 落到 tab.path(v0.19.2+; * 0.19.0/0.19.1 把一切 path seed 都改道文件资源打开,组件型 tab 的组件不会挂载,#632)。 * url 可选:把**新建** tab 的 path 预填为 URL(网页类 tab 的导航种子); * 聚焦既有 tab 时 url 不会覆写其 path。 * 被设置禁用的类型是 no-op(console.warn 提示)。注意:available 不拦截 openTab。 * scope(v0.12.0+)定向到指定 session:给出且非当前 session 时,打开落在 * 该 session 的侧边栏状态里(没有则按 prefs 新建),不切换 UI 的激活 session; * 定向打开不自动展开目标 session 的面板;缺省或指向当前 session 时行为 * 与之前完全一致。 * 内容型打开(带 path/url seed)在落点面板折叠时自动展开,保证落点可见; * 类型型打开(+ 菜单等)不展开。 */ openTab(seed: OpenTabSeed, scope?: SessionScope): void /** 关闭一个 tab(未知 id 严格 no-op,无状态搅动);scope(v0.12.0+) * 随回调传递(含可选 cwd),缺省为 { sessionId: 当前 }。 * scope 指的是**不在屏**的会话时(v0.26.0+),那一行在**它自己的**布局里关闭 * ——走定向通道(不发布、不通知),与定向 `openTab(seed, scope)` 同一条路; * 这正是文件树删除调解能覆盖后台会话页面的机制。不传 scope 行为不变。 */ closeTab(tabId: string, scope?: SessionScope): void /** 订阅注册表变化(register/dispose 时触发) */ subscribe(listener: () => void): () => void // ── v0.12.0+ ────────────────────────────────────────────────────────── /** 插件版本(如 '0.17.1';与 package.json 同步,测试守护) */ readonly version: string /** 能力清单(只增不删,唯一例外:v0.19.0 删除了 'floatWindows'): * 'badge' | 'tabLifecycle' | 'updateTab' | 'openFile' | 'targetedOpen' | * 'stateSubscription' | 'tabMeta' | 'pluginSettings' | 'urlTarget' | * 'settingSelect' | 'fileIcons' | 'rightActions' | 'settingPatterns' | * 'hostRouteUrl' * ——用 `features.includes('xxx')` 按能力 gate。 */ readonly features: readonly string[] /** 当前快照:激活 sessionId + 其状态(面板几何/打开的 tabs/展开集)+ prefs。 * session 未激活时 state/sessionId 为 undefined。 */ getSnapshot(): SidebarSnapshot /** 订阅快照变化(会话切换/状态变更/prefs 写入);返回 disposer。 * listener 抛出的异常被逐个隔离并 `console.error`,既不中断其余监听器 * (含 React 自己的渲染订阅),也不会把整条侧栏换成错误条——所以 * listener 里读宿主 state 的字段仍请自己判空,别指望异常能当哨兵。 */ subscribeState(listener: () => void): () => void /** 更新一个已打开 tab 的显示字段(title/path/meta);tab 不存在时 no-op。 * **改 path 等于一次新的打开**:引用带的行号(#826)是那次导航的瞬态状态, * 会随之清掉——原地切换文件 / 重命名若留着它,下一个文件会跳到上一个文件 * 的行号(markdown 还会被拉进编辑态)。 * **写入在原生承载面上是「稳」的**(v0.26.0+):`path` / `meta` 与宿主自己 * 投递的导航共用一道闸门——只有该记录**尚未采纳过的导航修订**才允许覆盖 * 它们,而原生承载面每次渲染都会重投同一次导航;所以插件侧的改址不会被 * 下一次渲染当渲染垃圾抹掉,宿主**真正**发起的新导航(新修订)仍按地址 * 改写记录。文件树的重命名 / 删除调解会一并处理原生承载面上的页签(跟着 * 改址 / 按既有的未保存确认关闭;枚举由承载面内部交回服务,不是公开方法), * 消费插件不需要改任何代码。 * **指到不在屏的会话时写进它自己的布局**(v0.26.0+):这类写入走定向通道 * (不发布、不通知),所以文件树的重命名调解同样覆盖**后台会话**底部工作台里的 * 页面,而当前会话继续走发布通道、界面照常重渲染。 * **标题只在它本来就来自文件名时才跟着改名走**(v0.26.0+):页签调解把**派生 * 自基名**的标题换成新基名,而 `openFile(scope, path, title)` 或 `openTab` 里 * **作者自设的标题**(宿主的 agent-opens 推送就带一个)原样保留——名字是你标 * 的,就不该被一次文件改名换掉。 */ updateTab(tabId: string, patch: { title?: string; path?: string; meta?: unknown }, sessionId?: string): void /** 激活一个已打开的 tab(tab 栏点击路径;触发 descriptor.onActivate; * 未知 id 严格 no-op);scope(v0.12.0+)随回调传递,同 closeTab */ activateTab(tabId: string, scope?: SessionScope): void /** 消除某个 tab 类型在一个会话里的「有新页面」红点(读者刚看过那一页, * v0.25.0+)。红点由插件自己的后台激活路径点亮(配合 `OpenTabSeed.reveal: * false`),这里是「清除」那一半:承载面渲染了带标记的 tab,就该把它关掉。 * 未标记的类型是严格 no-op(不通知、不落盘),所以可以在每次可见性变化时 * 直接调用。底部工作台经 `activateTab` 清除(在那里点击本身就是激活), * 原生栏芯片是调用方;`sessionId` 是具名的(原生面同时挂着多个会话)。 */ clearUnread(type: TabType, sessionId: string): void /** 在 scope.sessionId 的侧边栏编辑器打开一个文件(title 缺省为文件名; * id 按路径派生(`editor:` + path),与内置 open-path 拦截一致,不同文件可并排打开)。 * 注意:path 派生 id 只对 openFile/openSidebarFile 成立;editorExplorer 合并模式的 * 原地切换经 updateTab 重写 path/title,tab id 保持稳定、不再对应 path。 * `options`(v0.30.0+):`{ line?, lineEnd? }` —— 打开后定位到该行;给了 * `lineEnd` 时内置编辑器会把**整段**闪一下(划选引用 chip 的回跳用它标出 * 「引的是哪几行」)。行号语义与聊天链接一致:只取起始行、列号丢弃, * markdown / html 先切到编辑态,`line` 是瞬态(不进持久化白名单)。 * `lineEnd` 走 `src/client/line-target.ts` 的一次性旁路(key = path + line, * 被 viewer 取走即删),**不进**宿主的 `navigation.params`——那张表只声明了 * `line`,塞额外键属于未声明契约。 */ openFile(scope: SessionScope, path: string, title?: string, options?: { line?: number; lineEnd?: number }): void /** 把插件自有 host 路由(如 `'sidebar/api/fs.read'`)解析成绝对 URL, * 保留反代子路径前缀(v0.25.0+,features 含 'hostRouteUrl')。前导斜杠会被 * 去掉——带着它就会甩掉前缀、钉到源站根;桌面壳的真实基底也由它给出。 * 详见 §6 的前缀安全说明。 */ hostRouteUrl(path: string): string } /** openTab 的 seed(v0.12.0 起导出命名类型)。 */ interface OpenTabSeed { type: string title?: string /** 文件路径:editor = 打开文件资源;其余类型 = 组件种子(落在 tab.path,v0.19.2+) */ path?: string diff?: SidebarTab['diff'] /** 文件引用带的行号(`a.c#L131` / `a.c:131`,1 基,#826)。只对 `editor` * 有意义:它是一次性导航——编辑器到达时定位一次(markdown/html 因此 * 离开预览),随后记录就忘了它。不传 = 普通打开。 */ line?: SidebarTab['line'] id?: string url?: string /** JSON 可序列化的自定义状态,随 tab 持久化(刷新后原样恢复); * undefined = 不改,null = 显式清除 */ meta?: unknown /** 落点。省略 / 'right' = 原生右侧栏当前停靠格(默认);'bottom' = 插件的 * 底部工作台;**'side' = 原生右侧栏的第二个格**——原生承载面提供 * `openResource(address, { preferNewPane: true, revealIfOpened: false })`: * 先按宿主的两格上限与空间规则尝试分栏,分不了才回退到当前格;允许与 * 已打开的同名资源并存,所以「在侧边打开同一个文件」真的会新开一格。 * path-less 的 editor seed 是文件页(files),'side' 只影响带 path 的打开。*/ target?: 'right' | 'bottom' | 'side' /** 这次打开是否允许**聚焦**(只对 `target: 'right'` 有意义:底部工作台的 * 打开本来就落在它自己的格子里)。缺省 true——面向消费者的打开都是「给我 * 看这个」。后台激活(新子代理 / 新后台任务)传 false:宿主只把页面放进去、 * 不抢焦点,读者正在看的那一页因此活下来,调用方改用未读红点提示(见 * `clearUnread`)。v0.25.0+ */ reveal?: boolean } /** 文件图标注册描述符(v0.19.0+,features 含 'fileIcons')。 */ interface FileIconDescriptor { /** 唯一 id(如 'my-plugin:icons') */ id: string /** 小写扩展名、不带前导点(如 ['csv','tsv'])。两个**保留值**认领目录行 * 而非文件扩展名:'folder'(闭合目录)、'folder-open'(展开目录)—— * 它们不会匹配真实文件(名为 x.folder 的文件不受影响)。 * [] = catch-all 全局默认:只兜内置 glyph 没认领的扩展名(注册的具体 * names/扩展名与内置 glyph 永远优先于它)。 * **省略** = 完全没有扩展名规则(只有 names 的注册不是 catch-all)。 */ exts?: readonly string[] /** 精确**文件名**(basename,大小写不敏感,如 ['package.json','Dockerfile'])—— * 图标主题的 fileNames 半边;命中优先于扩展名。省略/[] = 无文件名规则。 */ names?: readonly string[] /** 精确**目录名**(basename,大小写不敏感,如 ['node_modules','src'])—— * 图标主题的 folderNames 半边;命中优先于保留扩展名,且**只认领列出的 * 目录**(要接管所有目录请用 'folder'/'folder-open')。省略/[] = 无规则。 */ folderNames?: readonly string[] /** priority 高者胜,缺省 0(同级按注册先后) */ priority?: number /** 尺寸感知的图标工厂(文件树/文件 tab 当前以 size=14 渲染)。 * 与内置图标(currentColor 单色,遵循皮肤契约)不同,注册图标可以是 * 任意 ReactNode——包括彩色图标;颜色在皮肤间的表现由注册方自行负责。 * open:目录行的展开态(文件行为 undefined),一条注册即可渲染开/合两态。 */ icon: (path: string, size: number, open?: boolean) => ReactNode } ``` **图标注册示例**(v0.19.0+;一次注册可同时覆盖具体扩展名、目录与全局默认): ```ts if (ctx.betterSidebar.features.includes('fileIcons')) { ctx.effect(() => ctx.betterSidebar.registerFileIcon({ id: 'my-plugin:icons', exts: ['csv', 'tsv'], icon: (path, size) => , // 彩色也可以 }) ) ctx.effect(() => ctx.betterSidebar.registerFileIcon({ id: 'my-plugin:names', // 精确文件名:package.json 与别的 .json 区分开 names: ['package.json', 'Dockerfile'], icon: (path, size) => , }) ) ctx.effect(() => ctx.betterSidebar.registerFileIcon({ id: 'my-plugin:folders', // 只认领列出的目录名 folderNames: ['node_modules', 'src'], icon: (path, size, open) => open === true ? : , }) ) ctx.effect(() => ctx.betterSidebar.registerFileIcon({ id: 'my-plugin:all-folders', // 接管所有目录行(保留扩展名) exts: ['folder', 'folder-open'], icon: (path, size, open) => open === true ? : , }) ) ctx.effect(() => ctx.betterSidebar.registerFileIcon({ id: 'my-plugin:default', // 全局默认:只兜内置 glyph 没认领的文件 exts: [], icon: (path, size) => , }) ) } ``` **消费表面与回退链**(由本插件内置消费,插件无需自己接线): - 文件树文件行 / 编辑器文件 tab(每个文件独立窗口):`fileIcon(path, size)` ——具体 `names`/扩展名注册 → catch-all 全局默认(`exts: []`)→ **DSH 官方图标**。 - 文件树目录行(含根行):`folderIcon(path, open, size)`——`folderNames` 命中 → `'folder'`/`'folder-open'` 保留扩展名 → DSH 官方的文件夹图形。 注册/注销即时生效(文件树与 tab 栏订阅注册表变化自动重渲染);图标工厂抛错会被吞掉 (console.error 后跳到回退链下一级),不会空白行。 **内置图标 = DSH 官方图形**(v0.19.0+,插件不含任何图标数据): - 回退链末端是 `FileTypeIcon` / `CodeFileIcon`(`@deepseek-ai/dsh-client-ui-primitives`, DSH 0.1.5-rc.2+):48 个代码/配置类目的官方全彩图形 + markdown / 图片 / PDF / Word / Excel / PPT / 视频 / 文件夹 / 通用文档的类目色板图形,分类器是宿主的 `classifyFileType`(精确文件名 → 前缀/后缀 → 项目上下文 → 扩展名)。本插件因此**没有** 自己的扩展名表、**没有**图标 chunk、**没有**图标主题开关——彩色是唯一形态。 - **语义后果(重要)**:宿主分类器覆盖任意路径,所以「插件自己已经能画这个扩展名」不再是 拦住 catch-all 的理由——**注册了 `exts: []` 的插件会接管全部未具体命中的行**(优先级降序、 同优先级按注册序)。只想补几个扩展名就照常用 `exts`/`names`,别用 catch-all 兜底。 - 插件自己注册的图标颜色由注册方负责(见 §12):品牌色是内容标识而非 chrome。 **版本与能力探测**(v0.12.0+):消费插件先查能力再使用新 API,老版本(或旧 DSH)下优雅降级: ```ts if (ctx.betterSidebar.features.includes('badge')) { // 使用 TabDescriptor.badge } if (ctx.betterSidebar.version >= '0.12.0') { /* 字符串比较即可:minor 只增 */ } ``` **生命周期示例**(v0.12.0+):打开时启动资源、关闭时释放——组件卸载 ≠ tab 关闭(会话切换也会卸载),所以释放资源要用 `onClose`: ```ts ctx.effect(() => ctx.betterSidebar.registerTab({ id: 'my-plugin:db', title: 'Database', single: true, badge: (_ctx, _scope, state) => /* 比如打开的连接数,每次 tab 栏渲染调用,保持廉价 */, onOpen: (tab, scope) => { startWatcher(scope.sessionId) }, onClose: (tab, scope) => { stopWatcher(scope.sessionId) }, component: ({ scope }) => , }) ) ``` --- ### 7.1 侧边对话渲染适配器(新增提案,尚未发布) 这是 v0.29.0 新增、配合 GenUI 0.15.0 的窄范围接入能力,关联 [dsh-genui #354](https://github.com/omdsh-dev/dsh-genui/issues/354)。消费方必须先探测方法存在;未安装、旧版本或未注册适配器时,侧边对话继续使用原有 `MarkdownText` 和 `markdownTextProps()`,不改变普通 Markdown 与代码围栏。 ```ts interface SidechatSurface { readonly root: HTMLElement readonly source: 'dsh-better-sidebar/sidechat' readonly threadId: string readonly eventSeq: number readonly contentBlockIndex: number readonly text: string readonly sendPrompt: (text: string) => Promise } interface SidechatSurfaceAdapter { readonly id: string readonly mount: (surface: SidechatSurface) => (() => void) } // BetterSidebarService 的新增消费入口: registerSidechatSurfaceAdapter(adapter: SidechatSurfaceAdapter): () => void ``` 两个类型可从 `dsh-better-sidebar` 或 `dsh-better-sidebar/client/service` 做 type-only 导入。注册必须包在消费插件的 `ctx.effect()` 中;同一个 id 重复注册会抛错,返回的 disposer 可重复调用。 - 只提供当前可见、已结算、身份有效的 assistant 文本块。用户消息、推理、工具、流式增量、隐藏页签及身份缺失的行不接入。`eventSeq` 与 `contentBlockIndex` 必须是非负安全整数;块序号保留原始 `message.content` 数组位置,包括非文本和空块留下的空位,不按过滤后的行号重新编号。 - `root` 是该行真实的 Markdown 容器;`text` 是链接改写前的原始文本。侧边对话容器始终带 `data-genui-surface="dsh-better-sidebar/sidechat"`,包括流式与没有消费插件时,供主对话扫描器明确排除;**不伪造宿主的 `data-chat-*` 标记**。 - 消费插件自行决定是否增强 `dsh-ui` 围栏,复用它自己的解析器、渲染器和状态,不把渲染实现放到 BetterSidebar,也不扫描该 root 之外的内容。挂载返回的 cleanup 必须释放监听器、观察者、计时器和渲染实例,并还原原始 Markdown;挂载中途失败须先回滚自身改动再抛错。单个适配器的挂载或释放异常会记录,但不会中断其余适配器和侧边对话。 - 晚注册会接到当时仍可见的行。隐藏、行变更、线程切换或卸载会释放;再次可见时重新挂载。适配器卸载会释放它自己的所有行;BetterSidebar 卸载会释放所有行和注册。内部只保留当前活跃的 root,不积累隐藏页签的引用,也不新增轮询。 - `sendPrompt(text)` 只调用当前行捕获的子线程 `sidechat.prompt` 路由。它不读取主会话、`scope.sessionId` 或任何兜底目标。行、适配器、线程绑定或插件失效之后,旧回调拒绝执行;请求失败同时通过现有侧边对话错误区显示并 reject 给消费方。调用方应处理 rejection,销毁时取消延迟动作。 该提案仍需以真实 DSH `0.2.1-rc.2` profile 同时安装两个候选插件验收,不能把单元测试或模拟 DOM 结果写成已完成双插件验收。 ### 7.2 划选「添加到对话」的引用 chip(v0.30.0+) 在内置编辑器(`code` / `markdown` / `jsonl` 的 CodeMirror 面,以及 markdown 预览)里划选一段文字,弹窗「添加到对话」会往主会话 composer 落一枚 chip。此处记录它用到的契约与降级,第三方插件若想复刻同一套交互可以照抄。 **宿主服务与 chip 身份** - 落 chip 走宿主 `conversation` 服务的 `input.for(session).insertReference(ref, span)`(#1052 之后内置的通道;`ref: ReferenceInsert = { source, ref, label, appearance?, clipboardText }`,`span = { draftRev, start, end }`)。`insertDraftReference()` 负责把「插件投影坐标」折成编辑器检测投影坐标、补前导空格、并在 `snapshot.occurrences` 存在时走 span 通道而不是整框重写(#1002:整框重写会把已有 chip 拍平成纯文本)。 - **要可点击、要在发送时展开,chip 必须属于一个自有的 input-trigger source**:`ctx.inputTriggers.registerSource({ trigger: '@', name, candidates, onPick, openReference?, codec? })`。`name` 就是 chip 的 `ref.source`,宿主按它路由点击(`openReference(session, { ref, appearance })` 返回 `true` 表示已处理)与发送时的 `codec.serialize(ref, signal)`。没有 codec 的 source,宿主会**拒绝发送**而不是静默降级成剪贴板文本——所以「注册 source」必须先于「插 chip」。 - 本插件的 source 名是 `betterSidebarQuote`,`candidates` 恒为空(它不进 `@` 菜单,只做 chip 归属);chip 的 `ref` 是短 id,记录(路径 / 行段 / 正文 / 所属会话)存在 `localStorage`(`dsh-better-sidebar:quote-refs`,FIFO 200 条)——因为该模块同时进 core bundle 与 lazy editor chunk,两个模块实例只能靠 `localStorage` 互见记录。 - 发送时的展开结果与「没有 chip 的时代」字节一致:`<相对路径>[:起[-止]]` 表头 + ≤500 字符时附围栏正文,超限只留表头。 **降级链(每一级都是宿主真支持的,不是静默 no-op)** 1. 自有 source 已注册且 chip 插得进去 → 可点击 chip; 2. 否(宿主无 `ctx.inputTriggers`、注册被拒、composer 不在 plain/claimed 相位、`draftRev` 不匹配……)→ 退回 #1052 的内置 `source: 'reference'` chip(不可点击,但内容正确); 3. 再否(没有 chip 通道)→ 纯文本 `appendToDraft`。 失败的那次尝试会把刚写入的 quote 记录回滚(`localStorage` 里不留孤儿 id)。 **点击回跳** `openReference` 校验 chip 的 `sessionId` 与当前会话一致(否则拒绝:换了会话 cwd 可能不同,会打开错文件),然后 `ctx.betterSidebar.openFile(scope, path, undefined, { line, lineEnd })`;内置编辑器定位到 `line` 并把 `line`–`lineEnd` 整段闪 2.4 s(皮肤令牌 `--dsw-alias-interactive-bg-hover-accent`,`prefers-reduced-motion` 下减弱)。`lineEnd` 与「本次导航的 nonce」都走**插件的模块级一次性旁路**(`src/client/line-target.ts`,key = path + line;arm 时自增 nonce,编辑器按 nonce 判重、读后即消费),不写进宿主导航参数。nonce 是「同一个 chip 连点两次」仍能再次跳转/闪光的关键:宿主对没移动的 `line` 不重发导航,只比较 path + line 会把二次点击误判成已消费,残留的 arm 还可能让之后同 path + line 的普通打开误闪。**底部工作台没有该入口**:chip 只打开右侧栏,底部 `TabContent → EditorHost` 虽是同一组件、同样消费 `line`,但本功能未在底部真机验证。**行号漂移**:记录的是划选时的行号,文件之后被编辑,点击会落到同一行号(可能已偏移);不做锚点搜索或内容定位。 --- ## 8. 声明式设置(v0.4.1+) 每个注册的 tab / viewer **自动**出现在 DSH 设置页「侧边卡片」分区(`SideCardSection` 按注册表驱动渲染,无硬编码),**`hidden: true` 的 tab 类型除外**: - 展示:小卡片网格(图标 + 标题 + 类型 id),**高亮 = 启用**,勾选徽标钉在卡片最右端;viewer 卡片额外显示扩展名。`hidden` 类型没有 `+` 菜单行、没有原生 guide 条目,也就没有可切换的承载面,因此不进清单,且 `isTabEnabled()` 对它恒为 true(#1009)。 - 持久化:开关写入 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,**缺省 = 启用**,显式 `false` 才禁用;`hidden` 类型的键被忽略)。 - 关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开(`console.warn`)、派生流程(子代理自动展开、agent 终端自动补 tab)停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。 - `settings.toggles`(可选):在卡片行下追加**嵌套设置行**(仅父级启用时显示),绑定 `SidebarPrefs` 字段;通过卡片底部「功能设置」条在原生弹窗中编辑。行控件形状见 §4.1 的 `SettingRow`:`type: 'switch' | 'text' | 'number'`(v0.11.0+;text/number 行 blur/Enter 提交,number 行按 min/max 钳制,unit 渲染单位后缀)与 `type: 'select'`(v0.13.0+;`options` 支持 value/title/desc/icon,`multi` 多选存数组并按 options 顺序提交;任一项带 icon 时渲染大图标选项卡)与 `type: 'patterns'`(可编辑字符串数组——chips + 删除钮 + 常驻添加输入框,add/remove 均原子提交整表,占位符走 `patternsPlaceholder`,值必须存 `string[]`;能力 gate `settingPatterns`)。内置示例:subagent tab 的 `autoOpenSubagent`、editor tab 的 `editorExplorer` 图标化下拉(工作区路径围栏与它的开关已移除,见 §8.1)与 `explorerExclude` 排除模式行。 - `settings.pluginToggles`(可选,v0.12.0+):**插件自有设置行**,行控件与 toggles 相同,但 key 是插件局部的——持久化在 prefs 文档的 `pluginSettings[]`(开放 map,无需宿主 schema 字段)。tab 与 viewer 都可用(v0.12.0 起 viewer 卡片也有设置条)。 - `settings.render`(可选,v0.12.0+):**自定义设置面板**——追加渲染在行列表之后,可单独存在。props 含 store/service/prefs、本 descriptor 的 `pluginSettings` blob、`updatePluginSetting(key, value)` 与 `close()`;抛错会被吞掉并显示内联错误。 ```ts ctx.effect(() => ctx.betterSidebar.registerTab({ id: 'my-plugin:db', title: 'Database', order: 50, settings: { // 宿主 prefs 字段行:key 必须是宿主 PrefsSchema 的字段(仅此限制) toggles: [{ key: 'autoOpenSubagent', // 宿主内置键 title: 'Auto-open', desc: 'Open when a subagent appears', }], // 插件自有设置行:key 插件局部,持久化在 pluginSettings['my-plugin:db'] pluginToggles: [{ key: 'pageSize', title: 'Page size', type: 'number', min: 1, max: 100, unit: 'rows', }], // 或完全自定义面板(追加在行列表之后) render: ({ store, service, prefs, pluginSettings, updatePluginSetting, close }) => ( { updatePluginSetting(key, value) }} onDone={close} /> ), }, component: ({ scope }) => , }) ) ``` > ⚠️ **`toggles` 的 key 必须是本插件 `PrefsSchema` 的字段**(`PrefsSchema` 已并入本插件 Loader 行的 `Config`;内置键:`autoOpenSubagent` / `autoOpenJobs` / `tasksViewMode` / `tasksAutoFoldSettled` / `tasksAutoFoldBranches` / `tasksAutoFoldIdle` / `jobsAutoCollapse` / `mobileNoAutoOpen` / `mobileDefaultTree` / `agentOpenTools` / `editorExplorer` / `editorGitGutter` / `explorerExclude` / `customCss` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `tabsEnabled` / `viewersEnabled` / `pluginSettings`;**已删除**:`workspaceFence`(v0.23.0,围栏整体移除,见 §8.1)/ `titleBarScheme` / `titleBarPresetId` / `titleBarCompat` / `titleBarStripPx`(titleBar 让位机制整体移除,见 §12.1)/ `agentTerminalTools` / `terminalShell` / `terminalShellArgs` / `terminalFontFamily` / `terminalFontSize` / `bottomPanelAutoTerminal` / `browserNoSandbox` / `browserAllowedLoopback` / 三个按协议分流的旧外链接管键)。**v0.12.0 起设置 seam 已开放**:你自己的设置走 `pluginToggles`(声明式行)或 `render`(自定义面板),值持久化在 `pluginSettings[id]`——不再需要本插件的 schema 字段,也不再被 seam 丢弃。值须 JSON 可序列化(行控件只产出 string/number/boolean;自定义面板自行负责)。 任务管理卡片的原有齿轮还提供四个默认开启的布尔偏好:`tasksAutoFoldSettled` 仅自动折叠已结束且无子 Agent 的叶子任务;`tasksAutoFoldBranches` 仅在整条分支全部结束时聚合(含运行中后代的分支不自动折叠),此开关独立于叶子开关,关闭叶子折叠不影响全结束分支聚合;`tasksAutoFoldIdle` 在同一父级至少 3 个队友空闲时聚合(workflow 内沿用原有不设数量阈值的聚合规则,也受此开关控制);`jobsAutoCollapse` 在 Agent 总数达到 8 时自动收起后台任务抽屉,仍保留手动展开/收起。它们沿用 `PrefsSchema` → Loader `Config` 的 volatile 设置路径,修改实时生效、不重挂插件;旧文档缺字段或客户端读到非布尔值时回退 `true`,显式 `false` 保留。 ### 8.2 宿主设置表单(DSH 0.1.7+:`SettingsForms`) **0.1.7 把可注册的设置命名空间整体删除了。** `ctx.settings.register(ns, schema)` 与它返回的 `get` / `watch` / `update` / `replace` 都已不存在,取而代之的是 `SettingsForms`——一套**按 profile 条目寻址**的表单服务: ```ts interface SettingsForms { /** 只列出「导出了 Config 且 fiber 处于 ACTIVE」的条目;ns 就是 Loader 行的 entry id。 */ describe(options?: { redactSecrets?: boolean }): SettingsDescriptor[] /** 把 patch 合并进该条目的 config(revision 不符时抛 SettingsConflictError)。 */ update(ns: string, patch: object, expectedRevision?: number): Promise /** 重置全部 live 字段后再写入给定字段(普通 config 保留)。 */ replace(ns: string, section: object, expectedRevision?: number): Promise /** 按路径逐字段改(持不完整视图——例如被脱敏的秘密字段——时用它,别用 replace 重述整节)。 */ mutate(ns: string, ops: readonly SettingsPathOp[], expectedRevision?: number): Promise /** 声明本插件实例的页面策略(自带设置页的插件写 auto: false);返回 disposer。 */ configure(presentation: { auto?: boolean }, owner?: Fiber): () => void get writable(): boolean get documentPath(): string prepareDocument(): Promise } interface SettingsDescriptor { ns: string // profile 条目 id —— 不是插件自选的命名空间 autoGenerate: boolean schema: unknown value: unknown // 已解析(defaults → composition base → 用户层) revision: number base?: unknown user?: unknown // 只读的 profile 覆盖层;为空 = 该条目还没被用户写过 applies: 'live' secrets?: RedactedSecret[] } ``` 四条必须记住的新规则: 1. **命名空间 = 插件 Loader 行的 `entry.options.id`**,不是插件自选的字符串。聚合包会用别的 id 挂同一个包,所以**运行时自发现**(本插件 `src/index.ts` 的 `ownEntryId(ctx)`:有效 id 的行优先按 `entry.fiber === ctx.fiber` 匹配,支持别名与 `file:` 挂载;未找到自己的 fiber 才回退到第一个启用的同包名行),**不要硬编码** id。 2. **schema 来自插件模块导出的 `Config`**(`entry.fiber.runtime.Config`)。用户可见的字段必须**并进这个 `Config`**——本插件把 `PrefsSchema` 合并进 `Config`(`src/config.ts`),并把每个偏好字段标 `meta.volatile = true`;**这一个标记就是「改设置实时生效、不重挂插件」的全部机制**(Loader 的 `equalExceptVolatile` 跳过 volatile 字段,纯 volatile 变更提交进运行中的引用并发 `loader/volatile-update`,普通字段仍是重挂语义)。 3. **自带设置页的插件要主动退出自动表单**:`ctx.effect(() => ctx.settings.configure({ auto: false }, ctx.fiber))`,否则 Settings 会把同一批字段渲染两遍(策略不影响读写)。 4. **`settings/document-updated` 你监听不到,别写**:事件签名是 `'settings/document-updated'(ns, revision)`(语义是「该条目的表单值 / 可用性 / 页面策略变了,表单客户端重新读取 schema、解析值与 revision」),但它在 **settings 服务自己的 context 上 `emit`**,而 cordis 事件只向该 ctx 的**祖先**冒泡——插件 fiber 是兄弟,`ctx.on(...)` 永远不会触发。**替代做法:每次重新读取表单时重新求值**(本插件的 `settingsFace.get()` 在返回当前值前跑一遍门控同步;客户端侧另经 `remote` 服务的 `$on('settings/document-updated')` 镜像触发重新拉取)。**旧文档里的 `settings/updated` 事件在 0.1.7 已不存在**。 ### 8.1 工作区包含检查已移除(v0.23.0,权限放开) **`workspaceFence` 这个键已经不存在了,也没有替代开关。** 侧栏的文件系统路由(`fs.tree` / `fs.trees` / `fs.read` / `fs.write` / `fs.rename` / `fs.remove` / `fs.mkdir` / `/sidebar/file` 媒体 / `/sidebar/html` 预览 / `/sidebar/upload` / `archive.build`)**不再做任何工作区包含检查**:路径只做词法解析(会话相对路径拼到会话 cwd 下、`resolve()` 折叠 `..`),不再 realpath、不再比对前缀、**不再有 403 `forbidden` 分支**。符号链接被照常跟随。 > ## ⚠️ 安全声明(必读) > > 这是一次**权限放开**:插件侧边栏的 fs 路由现在能读写**宿主用户能访问的任意路径**,只受 OS 权限约束。也就是说,一个能访问到本插件 `/sidebar/*` 路由的同源页面/脚本(**包括你写的第三方消费插件**)可以借这些路由读取、改写、删除该用户权限内的任何文件——例如 `~/.ssh/`、`~/.dsh/`、其他项目的源码。 > > 缓解手段只有两块,别指望有第三块:① 每条 HTTP/WS 路由都过宿主的**浏览器信任与认证栅栏**(`ctx.connection.requestRejection(req)`,见 §6),所以前提是攻击面已有宿主认可的来源与认证;② 宿主用户的 OS 权限本身的边界。**不要**再把「工作区围栏」当成安全边界来设计你的插件——它已经不在了。 历史背景(`< v0.23.0`):旧的实现用 realpath 解析后比对工作区前缀,越界回 403,并有一个 `workspaceFence` 开关(默认开)可临时关闭;开启时编辑器/文件树错误面会显示原因 + 一键关闭按钮(`FenceErrorNotice`)。用户要求删除该检查,上述代码路径随之删除(`src/path-security.ts` 现在是纯词法解析)。旧 profile 里遗留的 `workspaceFence` 键只是**未知键**——schemastery 的 object schema 对未知键是容忍的,既不报错也不再有任何效果。 --- ## 9. 生命周期与 HMR - **disposer 必须返回并被 fiber 持有**:`registerTab` / `registerFileViewer` 返回 `() => void`,Cordis fiber 卸载时自动调用。**务必**用 `ctx.effect(() => register(...))` 包裹,否则 fiber 卸载(HMR / 插件禁用)时不会撤销注册,导致下次激活时 `"already registered"` 错误。 - **注册时机**:better-sidebar 在 `apply()` 开头 `ctx.provide('betterSidebar', service)`,你的 `inject = ['betterSidebar']` 保证你激活时服务已就绪;注册顺序无关紧要。 - **持久化降级**:localStorage 里持久化的 tab 若其 type 未注册(你的插件未加载),渲染为 `` 占位卡("插件未加载" + 关闭按钮);你的插件加载后下次渲染自动恢复(`sanitizeNode` 保留未注册类型而非丢弃)。 - **`visible` 语义**:面板折叠或非激活 tab 的 `visible` 为 false;你的页面应借此暂停轮询/订阅,激活时恢复。 --- ## 10. 平台约束与陷阱 | 陷阱 | 说明 | |---|---| | **构建纯度门** | client bundle 禁止 value-import `@dsh-external/*` 或非白名单的 `@deepseek-ai/*`(`tsdown.config.ts` 的 `dsh-client-bundle-purity` 插件);**类型 `import type {}` 会被擦除,不触发门禁**——类型可自由共享,运行时符号不行。所有跨插件交互走 `ctx.betterSidebar` 方法调用 | | **统一 cordis 分支(v0.15.2+)** | 类型基底与 augmentation 全部在 DSH 运行时正主 `@deepseek-ai/cordis` 上(`src/context-types.ts`:真实 cordis `Context` 与结构化服务面做**交集**,不再重述 `declare module 'cordis'`——host/client 包对同名成员声明不同会 TS2717)。消费者 `import type {} from 'dsh-better-sidebar'` 即拿到 `ctx.betterSidebar`,或直接 `import type { Context } from 'dsh-better-sidebar'`。公开版 `cordis` 不再被依赖 | | **ModuleLoader 不跨插件** | 运行时 `require()` 虽支持跨 bundle,但被构建门挡;所有交互走 `ctx.betterSidebar` 方法调用 | | **host 半无此服务** | `ctx.betterSidebar` 只在 client 侧存在;host 半需要 better-sidebar 数据走 `/sidebar/api/*` HTTP 路由 | | **portal 限制** | 整面板 slot 由 ui-layout 独占,外部 tab 只能进入 better-sidebar 的 portal 内部,无法全屏替换整个面板 | | **id 冲突** | `registerTab` / `registerFileViewer` 对重复 id 抛错;建议用包前缀(`my-plugin:xxx`) | | **不要 value-import `dsh-better-sidebar`** | 即使是 `./client/service` 子路径,运行时落点也是 client bundle;只做 type-only import | | **client 声明图零 Node 依赖(v0.12.0+)** | `dsh-better-sidebar/client/service` 的可达声明面(含 `Context`)不引用 `node:*` / `Buffer`——纯浏览器侧插件无需 `@types/node`,`skipLibCheck: false` 也能编译(`scripts/check-consumer-types.sh` 守护;主入口含宿主声明,宿主消费者本就处于 Node 环境) | | **家族右面板互斥(v0.13.0+)** | `aionui-panel` 设置的 `rightPanel` 解析为 `'aionui-panel'` 时整个侧边栏不挂载(`settings.get` 返回 `externalDisable: true`,挂载门 + 接管停用)。**DSH 0.1.7 起 `settings/updated` 事件已删除**,保留的 `settings/document-updated` 是在 **settings 服务自己的 context 上 emit 的、插件 fiber 监听不到**(见 §8.2 第 4 条)——所以实时性由「每次重新读取时重新求值 + 客户端经 `remote` 镜像重新拉取」提供,不要写成 `ctx.on('settings/document-updated')` | | **设置持久化位置(0.1.7 起)** | 偏好不再写 `~/.dsh/settings.yaml`,而是落在 **profile 的 cordis patch 文档**里,按插件 Loader 行的 entry id 组织(本插件默认 `better-sidebar`)。0.1.6 线的 `dsh-better-sidebar` 段由插件在**首次启动时一次性回迁**(只在该行用户层仍为空时执行,且只迁移当前 `Config` 仍声明的字段),所以用户设置不会丢。排障时先看 profile patch,而不是旧的 `settings.yaml`(服务已把它改名为 `settings.yaml.imported`) | | **i18n 跟随** | 文案跟随 DSH `ctx.locale`(词典在 `betterSidebar` 命名空间;Host-backed `locale.preference` 优先于浏览器语言并实时切换;缺失回退浏览器)。消费插件**不要**依赖内部 `t()`——标题传字符串或 `() => string` | | **第三语言覆盖(ja 等)** | 可选 peer `@huanlin/dsh-plugin-better-locale`(optional)提供 ja/ko 覆盖,**借用 DSH 英文槽位**(仅 DSH=en 时生效,zh 下惰性)。经 `ctx.get('betterLocale')` 注入 `t()`;未安装整段 no-op | | **懒加载 chunk** | 重依赖(CodeMirror / mermaid / 19 份第三语言词典)在独立 bundle(`lib/client-.js`,现存 `editor` / `mermaid` / `locale` 三个),经 `/sidebar/bundle` 按需下发;factory 赋到 `globalThis.__dshChunks__[]`,由 `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` 漏斗存在 | | **会话地址的基准是会话 header cwd(#947 / #950 的根因)** | `dsh-resource://file/session//` 里的路径总是相对**会话存储的 header cwd**:宿主构造端(`ui-chat` 的 `openFile` → `fileAddressFor(sessionId, sessions.list.byId[sid].cwd, path)`)与解析端(`api-workspace-files` 的 `resolve()` → `sessions.get(sid).header.cwd`)用的就是同一个值。本插件的 fs 路由自 #802 起跟随**活动 worktree 根**,两者在「会话 header cwd 是主检出、agent 在 linked worktree 里干活」时不同,于是地址里的相对路径被活动根**再拼一次**:路径翻倍 → ENOENT(#947),或在两个检出都存在同名文件时**静默**读到另一份(#950,可保存写回)。所以**构造端**(`native/surface.ts` 的 `fileAddress`、`sidebar-file.ts` 的 `openClaimedNativeFile`)与**消费端**(`native/index.ts` 的 `fileParamsOf(info, base)`)都经 `src/client/address-base.ts` 的 `addressBaseOf(ctx, sessionId)` 取基准,并在边界处把地址路径解析成**绝对路径**(绝对路径在任何基准下都一样,下游再无可重定基的余地);只有用户/插件**意图**的相对路径(文件树、编辑器路径输入框、文档内相对链接)才走活根(`useSessionRoot`)。你的插件若自己解析文件地址,请照同一条规则做 | | **原生 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%` | --- ## 11. 自由窗口(v0.16.0 – v0.18.x,**v0.19.0 已移除**) 自由窗口(tab 拖出侧边栏成为可移动/缩放的悬浮窗,`SidebarState.floats`、`floatTab`/`dockFloat`/`raiseFloat`、`features` 里的 `'floatWindows'`、右键「移动到自由窗口」、`src/client/FreeWindow.tsx`)在 **v0.19.0-alpha.0 整体删除**:右列交给 DSH 原生右侧栏后,插件只剩底部工作台,浮窗既没有语义归属也没有可用手势(拖出目标区域现在是原生栏的属地)。 - 升级到 v0.19.x 后请**不要**再 gate `features.includes('floatWindows')`,也不要引用 `floatTab` 等符号(类型声明里已不存在); - 旧文档里持久化的 `floats` 字段会被 `sanitizeState` 直接忽略,会话不会因此加载失败; - 需要「同一个 tab 在别处看」的场景,改用原生栏打开(`openTab` 默认落点;同一文件再开一格用 `target: 'side'`)或底部工作台分栏(`OpenTabSeed.target: 'bottom'`)。 --- ## 12. 皮肤兼容(令牌驱动) > better-sidebar 所有视觉值消费 DSH 的 `--dsw-alias-*` / `--dsw-font-*` / `--ds-*` 令牌(无硬编码颜色),**不做每皮肤适配**。已与 dsh-web-ui 皮肤中心兼容(10 款皮肤全覆盖 `--dsw-alias-*` 层;`tests/theme.spec.ts` 守护)。你的 tab/viewer 组件遵循同样的令牌规则即可自动兼容全部皮肤。 > > **没有任何豁免面**:文件与文件夹图标是 **DSH 官方的 `FileTypeIcon` 图形**(宿主自己 > 的调色板,插件不画像素也不存数据),插件画的每个 glyph(含内置 tab 的彩色图标)颜色 > 都来自 `--dsw-alias-*`——`tests/theme.spec.ts` 同时守护「图标模块零颜色字面量」与 > 「样式表里每条 `color` 都解析到令牌」。插件自己注册的图标(`registerFileIcon`)颜色由 > 注册方负责,不受本节令牌约束,但**不要**把彩色图标数据塞进核心 bundle 的常驻渲染路径 > (要按需加载就照 `src/client/chunk-loader.ts` 的懒加载 chunk 走)。 ### 12.1 规则 - **面板表面**:右/底面板背景 = `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; height:100vh 兜底 + 100dvh; z-25; pointer-events:none; overflow:hidden+clip`,v0.13.1+),面板/开关簇 absolute 定位,免疫中间层 transform 劫持;页面级 transform 触发 `data-dsh-panel-host-degraded` 降级。显式高度同样**契约**(`tests/panel-host-css.spec.ts` 守护):不得依赖包含块(`100%` / `auto`,issue #616);`dvh` 必须排在 `vh` 之后——`vh` 取的是大视口,手机地址栏展开时该层高于布局视口,会误触发挂载自检、把宿主降级成 `absolute` 并撑高文档(issue #1014)。`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]` 内。 - **布局变量**(`` 上,面板打开时有效):`--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` 断言)。 - **用户空间 CSS(v0.14.1+;titleBar 让位机制移除后只剩它)**:`SidebarPrefs.customCss`(设置页「自定义 CSS」分组)注入 `