跳到主要内容

dsh-session-tabbar

已验证

dsh-session-tabbar · v0.3.1 · MIT · Web 界面

DSH web plugin: a browser-style session tab bar pinned in the top overlay of DeepSeek Harness — switch, close and create sessions as tabs. · DSH 顶部会话标签栏插件。

安装

dsh plugin add dsh-session-tabbar

用 dsh --profile default --dump-config 确认 layer 已生效 —— 参见安装指南。

源码

标签

作者

说明文档

dsh-session-tabbar

为 DeepSeek Harness(DSH)Web 界面提供一条浏览器风格的会话标签栏(tab bar): 页面里打开过的每个会话成为一个可点击切换的标签,支持关闭与新建,并占用独立的一条顶部横条, 不遮挡任何随产品发布的 UI。

  • 插件名 / 包名:dsh-session-tabbar(npm 公开包;author = darren.ho,MIT)
  • 版本:0.3.1(npm 上最新发布版即 0.3.1;0.3.0 与 0.3.1 的代码逐字节相同,后者只补上本文档)
  • 平台:web(纯客户端插件,宿主半为空实现)
  • 依赖注入:slots、uiWorkspace
  • 挂载点:shell.overlay(槽位 id = session-tabs,order = -50)
  • 兼容版本:DSH ≥ 0.1.7-rc.1(peer 范围 >=0.1.7-rc.1)。该版本起提供 useSessionStatus 与 uiWorkspace.openSession/clearMain,同时移除了本插件 0.2.x 依赖的 useSessionPendingInteraction 与 sessions.open/clear
  • 回归测试:npm test(离线行为测试,104 项断言,见 第九节)

安装(在 DSH 的 Web profile 目录执行,详见第七节):

pnpm add dsh-session-tabbar     # 或 npm install dsh-session-tabbar

再把 dsh-session-tabbar 加进 profile 的 dsh.profile.bundles,重启 DSH 后硬刷新页面(Ctrl+F5)。


目录

  1. 这是什么
  2. 功能一览
  3. 代码结构与加载链
  4. 运行时架构
  5. 样式与主题
  6. 配置项
  7. 安装与部署
  8. 行为细节与已知限制
  9. 二次开发提示
  10. 常见问题(FAQ)
  11. 变更记录

一、这是什么

DSH Web 默认只显示一个当前会话(左侧栏在会话之间导航,主区域一次只渲染一个会话)。 本插件把"当前页面会话"这一维度补成多标签工作区:

  • 只要某个会话在本次页面生命周期内被打开过,它就会在顶部横条上留下一个标签;
  • 点击标签 = 切换当前会话(等价于调用 uiWorkspace.openSession(id));
  • 点标签上的 × = 关闭该标签(只是取消这个视图,不删除/不归档会话);
  • 点 + = 走 DSH 原生的"新建会话"流程。

插件不替换任何已发布组件,而是以叠加层条目的形式注册进 DSH 布局框架的 shell.overlay 列表槽位,再通过给布局中栏(centerCol)加 padding-top 为横条预留高度,因此 原有侧栏、会话区、详情栏的布局与交互全部保持不变。

横条是一个"整帧浮层"条目,不参与布局,因此必须自己让位:它靠实测中栏矩形 + 给中栏注入 内联 padding 实现避让,细节见 4.3。


二、功能一览

能力 实现方式 代码位置
顶部固定标签栏 position: fixed 横条,宽高与位置由中栏实测矩形驱动 lib/client.js .dsh-tabs-bar、SessionTabs 的 useLayoutEffect
打开过的会话自动成为标签 本地 order 状态,监听当前会话变化后追加(追加式,不重排) React.useState/useEffect
点击切换会话 uiWorkspace.openSession(id);点击当前标签为 no-op switchTab
关闭标签 切到右侧邻居(无则左侧),都没有才 uiWorkspace.clearMain() closeTab / neighbourOf
三种关闭入口 × 按钮、鼠标中键(onAuxClick)、键盘 Delete/Backspace 标签的 onAuxClick / onKeyDown
新建会话 uiWorkspace.startSession()(不传 workspaceId,沿用当前/最近的工作区) newTab
状态指示点 琥珀点(等待交互)、蓝点 + 脉冲(running)、绿点(completionUnread) useSessionStatus 的 ReadonlyMap<SessionId, {running, pendingInteraction, completionUnread}>
标签标题 summary.displayTitle(无则回退到会话 id),title 属性提供完整文本悬停提示 渲染循环
掉出列表的会话 标签保留(变暗、data-missing)并可关闭,不会变成"看不见也关不掉" 渲染循环
键盘操作 role="tablist"/role="tab" + roving tabindex;←/→/↑/↓、Home/End 移动,Enter/Space 激活 onTabKeyDown / moveTo / focusTab
主题适配 全部颜色走 DSH 主题令牌 --dsw-alias-*,自动跟随明暗主题 CSS 字符串
无障碍 标签可聚焦、aria-selected 正确、焦点环 + prefers-reduced-motion 支持 CSS + 渲染
布局避让 测量 [class*="centerCol"] 的矩形,写入 --dsh-tabs-top/left/width,并给该元素加 padding-top: 28px;首帧同步测量,后续通知合并到每帧最多一次、几何未变则不写;匹配失败时 console.warn 并逐帧有限重试 useLayoutEffect

三、代码结构与加载链

3.1 文件清单

dsh-session-tabbar/
├── package.json        # 包清单:npm 元数据 + dsh.client/dsh.bundle 声明 + test 脚本
├── LICENSE             # MIT(Copyright (c) 2026 darren.ho)
├── README.md           # 本文档
├── cordis.patch.yml    # bundle 补丁:向 profile 组合中插入本插件行
├── test/
│   └── behavior.mjs    # 离线行为回归测试(假 DOM + 迷你 React hook 运行时)
└── lib/
    ├── index.js        # 宿主(Node)半:空实现 `export function apply() {}`
    ├── client.js       # 浏览器半:真正的插件实现(预构建的部署产物)
    └── client.d.ts     # 客户端入口类型声明(apply + 全局契约说明)
文件 作用
lib/index.js 包的 main。宿主侧是空插件:本插件没有任何服务端行为,但 cordis 组合里需要有一行的"实体"来承载 dsh.client 声明,所以保留一个 no-op apply()。
lib/client.js 全部功能所在:一段以 window.__ModuleLoader__.load({ id, factory }) 形式发布的浏览器模块。工厂内部注入 <style>、定义 React 组件 SessionTabs、导出 { apply, inject }。
lib/client.d.ts 仅类型:声明客户端入口为 apply(ctx: Context): void,并在注释里列出浏览器半依赖的全局契约(useSessions/useSessionStatus 两个标准钩子,uiWorkspace 服务)。
test/behavior.mjs 在 Node 里用假 DOM + 迷你 hook 运行时真实挂载 SessionTabs,覆盖注册、布局避让(含合并测量/重绑/重试/失配告警/卸载还原)、标签顺序/关闭/键盘/中键、状态点(含被外壳隐藏的交互类型)、当前会话的 mainView 保留推导、掉线会话、降级路径等 104 项断言。随包发布,使用者可自行复核行为。
cordis.patch.yml 插件自带的组合补丁,内容是"向 profile 插入一条 id/name 均为 dsh-session-tabbar 的行"。
LICENSE MIT 许可证全文。

注意:lib/client.js 头部注释提到"动态插件变体位于 plugin/client.js(cordis 动态定义路径)", 但该文件当前不在本目录中。这个目录里只有上面 7 个文件,也就是说本仓库目前维护的是 **部署产物(deployment bundle)**一种形态,而没有随附可热定义的动态插件版本。

3.2 双面包(dual-face package)

DSH 的客户端插件是"一个包、两个半":

  • 宿主半(Node):由 cordis Loader 加载,负责在宿主上"存在",并提供 dsh.client 元数据;
  • 浏览器半:打包产物,由浏览器端的 __ModuleLoader__ 机制加载并执行。

本插件的宿主半是空的,所有行为都在浏览器半。

3.3 从磁盘到浏览器的加载链

profile 的 cordis.patch.yml / dsh.profile.bundles
        │  (本包 cordis.patch.yml 提供 insert 行)
        ▼
cordis Loader 加载 dsh-session-tabbar(lib/index.js,no-op)
        │  宿主扫描该行并读到 package.json 里的 dsh.client 声明
        ▼
@deepseek-ai/dsh-client-modules 组合出 window.__DSH_BOOT__ 行
        │  platform: web、exports["./client"]、inject 依赖顺序
        ▼
浏览器请求 /plugins 组合 URL 并执行 lib/client.js
        │  window.__ModuleLoader__.load({ id:'dsh-session-tabbar', factory })
        ▼
工厂被 materialize:require('react') 从平台基线表解析
        │  注入 <style>、定义组件
        ▼
cordis 以 { apply, inject } 形式装载插件 → 注册进 shell.overlay

几个关键约定(均来自 dsh-client-modules 的实现):

  • 每个包的浏览器半必须通过 window.__ModuleLoader__.load(...) 注册工厂,执行 bundle 只注册工厂, 所有副作用(含 CSS 注入)都发生在工厂闭包内、被 materialize 时。
  • dsh.client.external 用于声明"需要 require 的非基线模块";本包只 require('react'), 而 React 属于平台种子表(PLATFORM_MODULES),所以没有声明任何 external。
  • dsh.client.inject 用于声明"必须先于本包到达的包行"(只影响加载顺序,不建立 require 边)。 本包声明了 @deepseek-ai/dsh-client-ui-session 与 @deepseek-ai/dsh-client-ui-workspace, 因为运行时要消费它们提供的标准钩子与服务。

四、运行时架构

4.1 挂载点:shell.overlay

const inject = ['slots', 'sessions', 'uiWorkspace']

function apply(ctx) {
  const slots = ctx.get('slots')
  if (slots === undefined) return
  ctx.effect(() => slots.inject('shell.overlay', () => slots.register(
    { name: 'shell.overlay', id: 'session-tabs', order: -50 },
    (props) => React.createElement('div', { className: 'dsh-tabs-anchor' },
      React.createElement(SessionTabs, {
        useSessions: props.useSessions,
        useSessionStatus: props.useSessionStatus,
        uiWorkspace: ctx.get('uiWorkspace'),
      })),
  )))
}

要点:

  • shell.overlay 是布局框架声明的一个 list 槽位(scope: 'root',kind: 'list'), 语义是"整帧浮层、位于所有栏目之上、在所有列的滚动容器之外",专门给徽标/提示/状态药丸这类 全局表面使用。它是可叠加的:新 id 是并列新增而不是替换已有条目。
  • 浮层容器本身是 pointer-events: none,框架 CSS 只对其直接子元素恢复 pointer-events: auto; 因此本插件的最外层只是一个 0 高度锚点(.dsh-tabs-anchor),命中面积为零、不拦截点击, 真正的横条由内部组件 position: fixed 定位,既不会挤压浮层里的其他条目,也不会抢走下方应用的点击。
  • order 是"条目间的位置,升序,默认 0"。这里取 -50,使标签栏排在其他默认浮层条目之前。
  • slots.inject('shell.overlay', cb) 会等到该槽位被声明之后再执行回调——这是与 ui-layout(shell.overlay 的声明者)之间的顺序解耦,插件不需要关心谁先启动; 回调返回的清理函数被 ctx.effect(...) 包住,插件卸载时会自动注销条目并还原 DOM 改动。
  • props.useSessions 与 props.useSessionStatus 来自根数据源提供的全局标准钩子 (ui-session 用同一次 provideRoot 同时提供"会话列表快照 store"与"会话 UI 状态表")。 这是读取会话状态、也是读取"哪个会话在等用户操作"的官方入口。

4.2 数据来源

来源 取用方式 用途
slots 服务 ctx.get('slots') 注册/注销浮层条目;缺失则静默返回(不报错、不渲染)
uiWorkspace 服务 ctx.get('uiWorkspace') openSession(id) 切换当前会话;clearMain() 清空主视图;startSession() 启动新建会话流程
useSessions 标准钩子 props.useSessions((s) => s) 订阅会话列表快照(ids + byId + phase);当前会话由 byId[id].retainedBy.mainView > 0 推导
useSessionStatus 标准钩子 props.useSessionStatus((m) => m ?? null) 订阅会话 UI 状态表(ReadonlyMap<SessionId, {running, pendingInteraction, completionUnread}>)

两个钩子都用身份选择器。 bindSnapshotSelector 的文档写明"equality defaults to Object.is", 而官方代码里凡是返回新对象的 selector 都会额外传 shallowEqual。返回 (s) => ({ current, byId }) 这种新对象会违反 useSyncExternalStore 契约(React 会警告 getSnapshot 结果未被缓存并反复重渲染), 所以这里直接选快照本身(引用在两次变更之间稳定)。这样也避免了为 shallowEqual 再引入一条 dsh.client.external 依赖。

组件订阅并实际使用的会话行字段:

字段 类型 本插件用途
displayTitle string 标签标题("持久标题 → 目录名 → 会话 id"三者回退后的可读标签);为空则回退到 id
retainedBy.mainView number? 该会话在"主视图"上的本地保留计数;> 0 即当前会话(0.1.7 起列表快照不再有 current 字段)
—(来自 useSessionStatus) running 渲染蓝色脉冲点;无状态条目时回退到行的 running
—(来自 useSessionStatus) completionUnread 渲染绿色点("已完成但尚未查看"的提醒位)
—(来自 useSessionStatus) pendingInteraction 等待交互的琥珀点;只有 approval / plan-review / question 三类对用户可见

inject = ['slots', 'uiWorkspace'] 作为工厂的导出交给 cordis,意味着这两个服务 全部就绪后插件的 apply 才会执行。

4.3 布局避让(为什么不会遮挡原生 UI)

横条是浮层条目,浮层不参与布局,所以必须自己"让位"。插件采用实测 + CSS 变量的方案:

  1. document.querySelector('[class*="centerCol"]') 找到布局的中栏(布局的 CSS Module 类名形如 pI_x6G_centerCol,哈希前缀会随构建变化,但可读后缀 centerCol 稳定,故用子串匹配);
  2. 读取它的 getBoundingClientRect(),把 top / left / width 写入文档根元素的 插件私有变量 --dsh-tabs-top、--dsh-tabs-left、--dsh-tabs-width;
  3. 给该元素设置 padding-top: 28px(BAR_HEIGHT)与 box-sizing: border-box, 让会话内容从中栏顶部下移一个横条的高度;
  4. 用 ResizeObserver + window.resize 监听几何变化(侧栏折叠、详情栏拖拽、窗口缩放都会触发); 两个来源都只做调度:同一帧内的多次通知合并成一次测量(requestAnimationFrame); 环境里没有 requestAnimationFrame 时退化为同步测量;
  5. 写入前先比对几何串 top|left|width,没变就完全不碰样式——拖拽期间不再反复读布局、写样式;
  6. 中栏在挂载瞬间还没渲染时不放弃:console.warn 一次,然后逐帧有限重试(约 2 秒 / 120 帧), 一旦出现立即绑定;若中栏被上游重建(元素脱离文档,isConnected === false),下一次通知会 重新绑定:先还原旧元素的内联样式,再改观察新元素并重新测位;
  7. 卸载时还原当前绑定元素的原始 paddingTop/boxSizing、取消待执行帧、删除三个 CSS 变量——不留副作用。

于是横条 top/left/width 完全跟随中栏,侧栏折叠或详情栏展开时它不会盖住侧栏或详情栏。

第 4、5 点针对的是"拖拽侧栏 / 详情栏"这条高频路径:每帧最多一次 getBoundingClientRect(), 几何不变时一次 DOM 写都不会发生(旧实现是每个通知都同步测一次并写 5 处样式,一帧内可能重复多次)。 第 1 步的首次测量则刻意保持同步:若等一帧,横条会先按 CSS 兜底位置(top:0/left:0/width:100vw) 画出来一次,产生可见跳动。

4.4 组件内部状态与交互

SessionTabs
├── order: SessionId[]            ← 标签顺序(组件本地状态,非持久化)
├── useSessions((s) => s)         → ids、byId(身份选择器,引用稳定)
├── useSessionStatus((m) => m ?? null) → 会话 UI 状态 Map
├── current                       ← byId 中 retainedBy.mainView > 0 的那个 id
├── switchTab(id)  → uiWorkspace.openSession(id) (id === current 时不做任何事)
├── neighbourOf(id)→ order[index+1] ?? order[index-1]   (右侧优先,其次左侧)
├── closeTab(id)   → 从 order 移除
│                    ├─ 若关的是当前标签且有邻居:uiWorkspace.openSession(邻居)
│                    └─ 否则(关的是当前标签且已无标签):uiWorkspace.clearMain()
├── newTab()       → uiWorkspace.startSession()
├── moveTo(id)     → switchTab(id) + focusTab(id)      (键盘左右/Home/End)
└── onTabKeyDown   → Enter/Space 激活;Delete/Backspace 关闭并聚焦邻居;方向键/Home/End 移动

渲染顺序:order 中每个标签 → 一个标签节点(byId 里查不到时按"掉出列表"处理); 最后恒定追加一个 + 按钮。

标签由三部分组成:状态点(可空)+ 标题 span(CSS 省略号)+ 关闭按钮(×,带 stopPropagation 以免触发切换)。状态点的优先级是等待交互 > 运行中 > 已完成:等待交互是唯一需要用户动作的状态, 不应被 running 掩盖。

+ 按钮放在 role="tablist" 之外(它是动作而不是标签),因此无障碍树里 tablist 只含标签。 布局上用 flex:0 1 auto 让 tablist 宽度贴合标签,+ 就紧跟在最后一个标签之后(不会贴到横条最右端); 只有标签总宽超出可用宽度时 tablist 才收缩并内部滚动,此时 + 仍固定在滚动区之外可见。


五、样式与主题

CSS 由插件在工厂执行时注入一次,并用数据属性做幂等守卫:

if (document.querySelector('style[data-plugin-css="dsh-session-tabbar"]') === null) { ... }
// <style data-plugin="dsh-session-tabbar" data-plugin-css="dsh-session-tabbar">

类名命名空间统一为 dsh-tabs-*:

选择器 说明
.dsh-tabs-bar 固定横条:高 28px、z-index:1000、font-size:13px
.dsh-tabs-list role="tablist" 容器:横向排列、宽度贴合标签(flex:0 1 auto,不撑满横条)、仅当标签溢出时才收缩并横向滚动(隐藏滚动条);+ 按钮在它外面且紧跟其后
.dsh-tabs-tab 单个标签:高 24px、最大宽 220px、圆角、悬停/激活态过渡
.dsh-tabs-tab[data-active] 当前标签:层级底色 + 主文本色 + 中等字重 + 轻微投影
.dsh-tabs-tab:focus-visible 键盘焦点环(品牌色描边)
.dsh-tabs-tab[data-missing] 已不在会话列表中的会话:降低不透明度,但仍可关闭
.dsh-tabs-title 标题文本(overflow:hidden + 省略号)
.dsh-tabs-dot 状态点,底色即"等待交互"的琥珀色
.dsh-tabs-dot[data-pending] 等待交互:显式琥珀色(基础色相同,规则显式化以免被后续改动带偏)
.dsh-tabs-dot[data-running] 运行中:品牌蓝 + 脉冲
.dsh-tabs-dot[data-completed] 已完成:成功绿
.dsh-tabs-close 关闭按钮(18×18,tabIndex=-1 不占 Tab 序列)
.dsh-tabs-new 新建按钮(26×26)
.dsh-tabs-anchor 0 高度浮层锚点
@keyframes dsh-tabs-pulse 运行中状态点的呼吸动画;在 prefers-reduced-motion: reduce 下关闭

用到的 DSH 主题令牌(全部带兜底色值,缺令牌时仍可显示):

--dsw-alias-bg-base、--dsw-alias-bg-layer-1、--dsw-alias-border-l1、 --dsw-alias-label-primary、--dsw-alias-label-secondary、--dsw-alias-label-tertiary、 --dsw-alias-interactive-bg-hover、--dsw-alias-brand-primary、 --dsw-alias-state-success-primary、--dsw-alias-state-warn-primary。

插件私有变量(由组件在运行时写入 <html>):

变量 含义
--dsh-tabs-top 横条相对视口的 top(中栏顶部)
--dsh-tabs-left 横条 left(中栏左边界)
--dsh-tabs-width 横条宽度(中栏宽度)

六、配置项

package.json

{
  "name": "dsh-session-tabbar",
  "version": "0.3.1",
  "description": "DSH web plugin: a browser-style session tab bar pinned in the top overlay of DeepSeek Harness — switch, close and create sessions as tabs. · DSH 顶部会话标签栏插件。",
  "type": "module",
  "main": "lib/index.js",
  "exports": {
    ".": "./lib/index.js",
    "./client": "./lib/client.js",
    "./package.json": "./package.json"
  },
  "files": [
    "lib",
    "cordis.patch.yml",
    "README.md",
    "LICENSE",
    "test"
  ],
  "scripts": {
    "test": "node test/behavior.mjs lib/client.js",
    "prepublishOnly": "npm test"
  },
  "keywords": [
    "deepseek", "deepseek-harness", "dsh", "dsh-plugin",
    "cordis", "cordis-plugin", "plugin",
    "tabs", "tabbar", "session", "session-tabs", "ui"
  ],
  "author": "darren.ho",
  "license": "MIT",
  "repository": {
    "type": "git",
    "url": "git+https://gitee.com/darren_ho/dsh-plugins.git",
    "directory": "dsh-session-tabbar"
  },
  "homepage": "https://gitee.com/darren_ho/dsh-plugins/tree/master/dsh-session-tabbar",
  "bugs": {
    "url": "https://gitee.com/darren_ho/dsh-plugins/issues"
  },
  "engines": {
    "node": ">=20"
  },
  "publishConfig": {
    "access": "public"
  },
  "peerDependencies": {
    "@deepseek-ai/cordis": "^4.0.2",
    "@deepseek-ai/dsh-client-ui-session": ">=0.1.7-rc.1",
    "@deepseek-ai/dsh-client-ui-workspace": ">=0.1.7-rc.1",
    "react": "^18.2.0"
  },
  "peerDependenciesMeta": {
    "@deepseek-ai/dsh-client-ui-session": { "optional": true },
    "@deepseek-ai/dsh-client-ui-workspace": { "optional": true },
    "react": { "optional": true }
  },
  "dsh": {
    "client": {
      "platform": "web",
      "inject": [
        "@deepseek-ai/dsh-client-ui-session",
        "@deepseek-ai/dsh-client-ui-workspace"
      ]
    },
    "bundle": {
      "patch": "./cordis.patch.yml"
    }
  }
}
字段 含义
description / keywords npm 检索元数据;keywords 用社区通用词(dsh、dsh-plugin、deepseek-harness 等)
author / license / repository / homepage / bugs 署名 darren.ho、MIT、Gitee 仓库(repository.directory 指向本子目录)
engines.node >=20
publishConfig.access public。非 scoped 包本就是 public,显式声明以免被 registry 配置误判
main / exports["."] 宿主半入口(lib/index.js)
exports["./client"] 浏览器半入口(lib/client.js),客户端模块系统据此定位 bundle
peerDependencies 运行时契约:宿主侧需要 @deepseek-ai/cordis;dsh-client-ui-session / -workspace / react 由 DSH 运行时提供
peerDependenciesMeta 把后三项标为 optional:它们来自 DSH 的引导图与客户端模块表,不该由使用者的 npm 安装去满足(避免自动装出第二份副本)
dsh.client.platform: "web" 声明这是一个 Web 客户端包
dsh.client.inject 需要先于本包加载的包行(顺序约束,非 require 依赖)
dsh.client.external 未声明:本包只依赖平台基线中的 react,不需要任何非基线模块
dsh.client.immediately 未声明(该可选字段用于把引导图中的包行标记为首屏立即加载)
dsh.bundle.patch 本包自带的 cordis 补丁文件,供 profile 作为 bundle 层引用
dsh.compatibility.dshReleases 已移除。0.1.7 起 DSH 全树没有任何读取方(evaluatePluginCompatibility 只看 peerDependencies),留着不会起版本闸门作用,反而误导:真实下限写在 peerDependencies 的 >=0.1.7-rc.1 里
scripts.test 离线行为回归测试入口
scripts.prepublishOnly npm publish 前自动跑测试,不通过则中止发布
files 发布白名单:lib、cordis.patch.yml、test、README.md、LICENSE(package.json 恒定包含)

cordis.patch.yml

- insert:
    - id: dsh-session-tabbar
      name: dsh-session-tabbar

向 profile 的组合树插入一行:id 与 name 同名(name 是模块解析名,即包名)。


七、安装与部署

本插件按**部署级(deployment level)**安装:装进某个 profile(默认 $DSH_HOME/profiles/web)后, 重启 DSH 依然生效。两种装法,按你的角色选一种。

方式一:从 npm 安装(使用者)

1)在 profile 目录安装依赖:

cd "$DSH_HOME/profiles/web"          # Windows: C:\Users\<你>\.dsh\profiles\web
pnpm add dsh-session-tabbar          # 或 npm install dsh-session-tabbar

2)确认包名已列入 dsh.profile.bundles(同目录的 package.json):

{
  "dependencies": {
    "dsh-session-tabbar": "^0.3.1"
  },
  "dsh": {
    "profile": {
      "bundles": [
        "@deepseek-ai/dsh-base",
        "@deepseek-ai/dsh-web-app",
        "dsh-session-tabbar"
      ]
    }
  }
}

3)重启 DSH,然后硬刷新页面(Ctrl+F5)。

⚠️ 只装依赖、不登记 dsh.profile.bundles 是不会生效的。 profile 只会为列出的 bundle 应用其 cordis.patch.yml,而插件那一行组合(id/name = dsh-session-tabbar)正是由该补丁插入的。

另需 DSH ≥ 0.1.7-rc.1:本插件用到的 useSessionStatus 标准钩子与 uiWorkspace.openSession/clearMain 从该版本才提供(peer 范围 >=0.1.7-rc.1 即由此而来)。

方式二:本地 link 安装(开发者,改代码即时生效)

把 profile 的依赖写成 link: 指向本仓库目录:

{
  "dependencies": {
    "dsh-session-tabbar": "link:E:/04_AI/Workspace/dsh-plugins/dsh-session-tabbar"
  }
}

然后同样执行 pnpm install / npm install、登记 dsh.profile.bundles、重启 DSH。 link: 的好处是改完 lib/client.js 无需重新发布;客户端 bundle 的 URL 带内容哈希 rev, 文件内容一变 rev 就变,硬刷新即可拿到新代码。

本机当前状态:C:\Users\Lenovo\.dsh\profiles\web\package.json 用的是方式一 (依赖写 "dsh-session-tabbar": "^0.2.1",来自 npm registry,且已列入 dsh.profile.bundles)。 想让本地改动即时生效,把这一行换成上面的 link: 再 pnpm install;改回版本号即切回线上版。

该 profile 用的是 pnpm nodeLinker: hoisted,两种装法在 node_modules/dsh-session-tabbar 下都是 真实目录,别靠"是不是符号链接"判断当前生效的是哪一份——pnpm list dsh-session-tabbar 或看 lockfile 里的 specifier / version 才可靠。

验证是否生效(浏览器 DevTools 控制台):

document.querySelector('style[data-plugin-css="dsh-session-tabbar"]')  // 应为 <style>,否则样式未注入
document.querySelector('.dsh-tabs-bar')                               // 应为横条元素,否则组件未挂载
getComputedStyle(document.documentElement).getPropertyValue('--dsh-tabs-width')  // 非空表示布局测量已生效

若样式在、组件不在:多为 slots / sessions / uiWorkspace 任一服务未就绪,或 shell.overlay 未被声明(ui-layout 未加载)——插件在这种情况下静默不渲染,不抛错。

改动后先跑离线回归测试(不需要浏览器、不需要重启 DSH):

cd dsh-session-tabbar
npm test        # = node test/behavior.mjs lib/client.js

测试在 Node 里用假 DOM + 迷你 hook 运行时真实挂载 SessionTabs,覆盖 104 项断言(含布局测量的合并调度、 几何幂等、元素重建重绑、挂载期重试、同帧双关闭,以及 0.3.0 新增的隐藏交互类型与 mainView 保留推导等回归项); 全绿再硬刷新页面确认视觉效果。

发布到 npm(维护者)

cd dsh-session-tabbar
npm test            # 可选;prepublishOnly 在 publish 时也会自动跑
npm pack --dry-run  # 只看 tarball 会包含哪些文件,不真正打包
npm publish         # → registry.npmjs.org(publishConfig.access = public)
  • prepublishOnly 已配置为 npm test:测试不过则发布被中止,不会把坏包推上去;
  • 当前发布账号为 darren.ho,registry 必须是官方源(npm config get registry → https://registry.npmjs.org/)。国内镜像(如 npmmirror)只读,无法发布;
  • 发布后 72 小时内可用 npm unpublish dsh-session-tabbar@<version> 撤回,之后只能发新版本,因此 建议先 npm pack --dry-run 核对清单、再决定版本号。

八、行为细节与已知限制

这些是阅读实现后确认的行为特征与限制,改动前建议先看这一节。带 ✅ 的条目是 0.2.0 / 0.2.2 中修复过的项 (见文末两张修复表),保留在此是为了说明现在的行为是什么。

标签集合的语义

  • 标签 = "本次页面生命周期内打开过的会话",不是完整会话列表。侧栏里存在的会话,只要没被打开过就不会出现在标签栏。
  • 顺序是追加式:新打开的会话追加到末尾,切回旧标签不会改变顺序;不支持拖拽重排。
  • 不持久化:order 只是组件本地 useState,刷新页面后只剩下当前会话一个标签。
  • ✅ 掉出列表的会话不再"隐身":若某标签对应的会话已不在会话行表里(例如被归档/删除), 标签会变暗并保留(data-missing,悬停提示 id(已不在会话列表中)),仍然可以点 × 关掉。 之所以是"保留"而不是"自动删除",是因为会话列表在重连重新拉取时可能出现瞬时缺口, 自动删除会把用户的标签误删。

关闭与切换

  • × 不会删除或归档会话,只是移出标签栏;关掉当前标签时如果已无任何标签,会调用 uiWorkspace.clearMain(),界面回到"无会话"空状态(这是工作区服务的设计行为,持久化的选中项也会被清掉)。
  • ✅ 关闭当前标签后的落点改为真正的邻居:优先右侧(它是滑入被关标签位置的那个), 没有右侧则取左侧;只有一个标签时才 clearMain()。曾经的实现是 next[next.length - 1](跳到最后一张标签), 与注释里写的 "neighbor switch" 不符。
  • 点击已是当前标签的标签是 no-op(有 id !== current 守卫)。
  • ✅ 支持鼠标中键关闭(onAuxClick,仅 button === 1);× 会 stopPropagation,不会误触发切换。
  • ✅ 相邻两次关闭即使落在同一批次里也不会互相覆盖:setOrder 改为函数式更新 (prev => prev.filter(...))。真实点击是两个独立任务、React 会在中间刷新状态,所以这条更偏向 "消除潜在竞态",而不是已复现的故障。

状态指示

  • running → 品牌蓝脉冲点;completionUnread → 成功绿点;都不满足则不渲染点。
  • ✅ "等待用户交互"用真实数据源判定:useSessionStatus 返回的 ReadonlyMap<SessionId, SessionStatus>,其中 status.pendingInteraction 非空即为等待交互,渲染琥珀点。 旧实现读的是 summary.pendingInteraction,而真实的会话行投影(SessionSummary)没有这个字段。
  • ✅ 0.1.7 起还要看交互类型:pendingInteraction 现在是带 kind 的对象,而外壳只把 approval / plan-review / question 三类呈献给用户(ui-workspace 的 visiblePendingKind)。 本插件用 isVisiblePending() 对齐同一集合——隐藏类型不再点亮琥珀点,否则会出现"标签在等人、 但页面上根本没有任何可回答的东西"。
  • ✅ 优先级为 等待交互 > 运行中 > 已完成:等待交互是唯一需要用户动作的状态,不应被 running 掩盖。
  • ✅ 会话不在状态表里时(例如状态源尚未建立基线)回退到会话行的 running 字段;已掉出列表的会话 一律不渲染状态点。

钩子与重渲染

  • ✅ 两个标准钩子都改用身份选择器((s) => s / (m) => m ?? null)。bindSnapshotSelector 的约定是 "equality defaults to Object.is",旧写法 useSessions((s) => ({ current, byId })) 每次返回 新对象,违反 useSyncExternalStore 契约(React 会警告 getSnapshot 结果未被缓存,并反复重渲染)。 官方代码对返回复合值的 selector 一律额外传 shallowEqual;这里选择身份选择器,避免为此再增加一条 dsh.client.external 依赖。

布局耦合

  • 通过 [class*="centerCol"] 子串匹配中栏,并用 document.querySelector 取第一个匹配元素。 布局的类名后缀是稳定约定,但若上游改名(或页面中出现第二个同后缀元素),避让逻辑会失效—— 典型表现是横条盖住会话顶部内容,或 --dsh-tabs-* 为空。
  • ✅ 匹配失败不再静默:会在控制台输出 [dsh-session-tabbar] layout column [class*="centerCol"] not found; …,便于立刻定位是上游改名还是插件没挂上。
  • 插件直接给框架元素写内联 paddingTop / boxSizing 并注册 ResizeObserver; 多个同类插件同时改中栏 padding 会互相覆盖。卸载时会恢复这两个内联属性的原值。
  • ✅ 测量写入按帧合并且几何幂等:拖拽侧栏/详情栏时每帧最多一次 getBoundingClientRect(), 几何没变则一次样式都不写,不再出现"一帧内多次读写交错"的布局抖动。
  • ✅ 中栏元素被上游重建(isConnected === false)时自动重新绑定:还原旧元素的内联样式、 ResizeObserver 改观察新元素。触发时机是"下一次 resize / RO 通知",因此若上游在完全不产生任何 尺寸变化的情况下换掉中栏,横条要等到下一次窗口或分栏变化才会归位。
  • ✅ 挂载瞬间中栏尚未渲染时不再永久失效:告警一次并逐帧有限重试(约 2 秒 / 120 帧),中栏一出现即绑定。
  • 横条位置是视口坐标,依赖中栏顶边为 0;若 DSH 日后在页面顶部加入自己的全局横条, 需同步调整 --dsh-tabs-top 的来源。

无障碍与可用性

  • ✅ 标签可聚焦并带正确的 ARIA:容器 role="tablist" + aria-label + aria-orientation, 标签 role="tab" + aria-selected + roving tabindex(当前标签 0,其余 -1), 键盘支持 ←/→/↑/↓、Home/End 移动,Enter/Space 激活,Delete/Backspace 关闭并聚焦邻居, 并有 :focus-visible 焦点环。旧实现只是带 onClick 的 div:不可聚焦、无 ARIA、无键盘操作。
  • 关闭按钮带 aria-label="关闭标签页:<标题>",且 tabIndex={-1}——它仍然是可点击、可被 AT 访问的按钮, 但不占 Tab 序列(否则 N 个标签会额外产生 N 个 Tab 停靠点)。
  • 标签超宽时在 .dsh-tabs-list 内横向滚动(隐藏滚动条),没有溢出下拉菜单, 标签数量多时只能用触控板/Shift+滚轮到达;+ 按钮固定在滚动区之外,始终可见。
  • 标题依赖 title 属性提供完整文本提示;视觉上单行省略。

其他

  • 会话 id 就是标签的 React key;会话标题变化会即时反映在标签上。
  • 子代理(subagent)会话若被打开,同样会成为一个普通标签。
  • lib/index.js 是空实现:本插件没有任何宿主侧能力、没有 RPC、没有配置项、没有持久化状态。
  • 依赖 ui-session 同时提供 useSessions 与 useSessionStatus 两个全局标准数据源 (两者来自同一次 provideRoot 调用)。两者都是 0.1.7-rc.1 起的稳定接缝;缺少它们的更早版本 不在本版本的 peer 范围内。

已修复项(0.3.0)

本版是为 DSH 0.1.7 的破坏性变更而做的适配,同时修掉两处当时被掩盖的漂移:

问题 旧行为(0.2.2) 现行为(0.3.0)
等待交互钩子被移除 读 useSessionPendingInteraction,0.1.7 已无此导出 → 渲染期抛错,被槽位错误边界摘除,整条标签栏消失 改读 useSessionStatus(ReadonlyMap<SessionId, {running, pendingInteraction, completionUnread}>)
切换/清空会话的服务方法被移除 调 sessions.open(id) / sessions.clear(),0.1.7 的 sessions 服务已无这两个方法 改调 uiWorkspace.openSession(id) / uiWorkspace.clearMain()
列表快照不再有 current 读 list.current → 恒为 undefined,标签栏认为"没有当前会话",全部标签失去激活态 由 byId[id].retainedBy.mainView > 0 推导当前会话
摘要不再有 completed 读 summary.completed → 绿点永不出现 改读 status.completionUnread
隐藏的交互类型会误报 只看"有没有 pending" 用 isVisiblePending() 只认 approval/plan-review/question,与外壳一致
失效的版本元数据 声明 dsh.compatibility.dshReleases(0.1.7 全树无读取方)并据此宣称兼容 0.1.5-rc.1 删除该字段,真实下限写入 peerDependencies(>=0.1.7-rc.1)

已修复项(0.2.2)

问题 旧行为 现行为
拖拽分栏时的重复测量 每个 ResizeObserver / resize 通知都同步测一次并写 5 处样式,一帧内可能重复多次(读写交错引发布局抖动) 通知合并为每帧最多一次测量;几何未变则一次样式都不写
中栏被重建后避让失效 只持有首次 querySelector 的结果,元素被重建后一直观察废弃节点,横条位置不再更新 发现绑定元素脱离文档即重新查询并重绑(还原旧元素、改观察新元素)
挂载瞬间中栏缺失会永久失效 找不到就 return,之后只能靠用户手动触发 resize 才可能恢复 告警一次并逐帧有限重试(约 2 秒),中栏挂载即生效
同批次两次关闭丢更新 setOrder(order.filter(…)),两次都基于渲染期的同一个 order 数组 函数式 setOrder((prev) => …),两次删除都保留
等待交互判定的冗余条件 pending !== null && pending !== undefined && …(?? null 已保证非 undefined) 简化为 pending !== null && …

已修复项(0.2.0)

问题 旧行为 现行为
等待交互状态点不可达 读 summary.pendingInteraction(该字段不存在) 走 useSessionPendingInteraction 的 Map 查表,并补上 [data-pending] 样式
关闭落点错误 跳到最后一张剩余标签 跳到右侧邻居(无则左侧),仅在无标签时 clear()
掉出列表的会话卡死 标签不渲染但仍占 order,看不见也关不掉 标签保留为 data-missing 状态,可关闭
选择器返回值引用不稳定 (s) => ({ current, byId }) 每次新对象,违反 uSES 契约 身份选择器,引用稳定
布局匹配失败静默 无任何提示,横条直接盖住会话内容 console.warn 明确指出选择器失配
中键不能关闭 未处理 onAuxClick 中键关闭(仅 button === 1)
键盘完全不可用 标签是纯 div + onClick tablist/tab + roving tabindex + 方向键/Home/End/Enter/Space/Delete
box-sizing 归还不精确 卸载时 removeProperty('box-sizing'),丢失原有内联值 记录并恢复原值
头部注释过时 声称用 #root { padding-top } 预留空间 注释改为如实描述测量中栏的做法
+ 被挤到横条最右端(0.2.1 修) 新加的 .dsh-tabs-list 写了 flex:1 1 auto,flex-grow 吃满剩余空间 改为 flex:0 1 auto:宽度贴合标签,+ 紧跟最后一个标签;溢出时仍收缩滚动

九、二次开发提示

  • 没有构建步骤:仓库里没有 src/、没有打包脚本。lib/client.js 就是手写的、符合 __ModuleLoader__.load 工厂格式的部署产物,直接改它即可生效。
  • 改完先跑 npm test。test/behavior.mjs 用假 DOM + 迷你 hook 运行时在 Node 里真实挂载组件, 断言注册、布局避让(含合并调度/几何幂等/元素重绑/挂载期重试/失配告警/卸载还原)、标签顺序、 三种关闭方式(含同批次双关闭)、状态点优先级、掉线标签、键盘导航与降级路径。加新行为时在同一文件里 加一个 section(...) + 若干 check(...) 即可。 注意它自带一个精简 React hook 运行时:useState 立即生效、事件派发后需要走 h.act(...) 触发重渲染, 断言时 props.children 恒为数组(真实 React 对单个子节点会解包)。
  • 改布局逻辑时会用到测试环境里的几个专用钩子:env.flushFrames() 执行已排队的动画帧 (window.requestAnimationFrame 是队列桩)、env.rectCalls / env.varWrites 统计测量次数与样式写入次数、 env.makeColumn() 造一个新的中栏元素、把 env.centerCol.isConnected 置 false 模拟元素被重建、 createEnvironment({ centerColumnMissing: true }) 模拟中栏未挂载、{ noRaf: true } 走无 rAF 回退路径。
  • 发布新版本:改 package.json 的 version → npm test → npm publish(prepublishOnly 会自动再跑一次 测试)。记得同步 README 顶部的版本号与本文档的「变更记录」两处。
  • 常量与关键变量集中在顶部:BAR_HEIGHT = 28(横条高度,同时决定 padding-top)、 样式字符串 css、注册用的 id: 'session-tabs' 与 order: -50。
  • 改样式时只需改 css 字符串;注入有 data-plugin-css 守卫,重复 materialize 不会叠加 <style>。
  • 改动后用 DevTools 确认:<style> 内容已更新(若 dsh-client-hmr 在运行,客户端 bundle 重建会 通过 /plugins/events 触发重载;否则需要硬刷新页面)。
  • 改动核心逻辑时留意两条框架约定:标准钩子必须用稳定引用的选择器; shell.overlay 条目之间的顺序由 order 升序决定(默认 0,本插件 -50)。
  • 若要做成"运行时可定义、无需重启"的形态,需要另行提供动态插件变体(文件头注释提到的 plugin/client.js,走 cordis 动态定义路径)——当前目录中没有这个文件。
  • 仍然开放的方向(按实现成本从低到高):
    1. order 持久化(按工作区分组),或直接投影 ids 而不是记录"打开历史";
    2. 拖拽重排;
    3. 标签溢出下拉菜单;
    4. 标签右键菜单(归档、重命名、关闭其他/右侧全部)。

十、常见问题(FAQ)

装好了但页面上没有标签栏? 按顺序排查:① 包名是否已列入 profile 的 dsh.profile.bundles(只装依赖不生效,见第七节); ② 是否重启了 DSH 并硬刷新页面;③ DSH 是否 ≥ 0.1.7-rc.1;④ 控制台执行 document.querySelector('.dsh-tabs-bar')——返回 null 说明组件没挂载,通常是 slots / uiWorkspace 里有服务未就绪,或 shell.overlay 尚未声明(此时插件静默不渲染,不抛错)。

横条在、但位置不对,或盖住了会话顶部内容? 控制台执行 getComputedStyle(document.documentElement).getPropertyValue('--dsh-tabs-width'):

  • 为空 → 没测到中栏。看是否打印了 [dsh-session-tabbar] layout column [class*="centerCol"] not found…:这条告警说明上游布局类名改了 或中栏当时还没挂载(插件会自己重试约 2 秒);若布局彻底改名,需要同步改插件里的选择器。
  • 有值但仍遮挡 → 上游布局结构发生变化,见第八节「布局耦合」。

刷新页面后只剩一个标签? 预期行为:order 是组件内存状态、不持久化(见第八节)。它属于 第九节列出的开放方向。

侧栏里明明有很多会话,为什么只有几个标签? 标签 = "本次页面生命周期内被打开过的会话",不是完整会话列表(见第一节)。

+ 为什么紧跟最后一个标签,而不是贴在横条最右端? 刻意的:.dsh-tabs-list 用 flex:0 1 auto 让标签容器贴合内容宽度,只有标签溢出时才收缩并内部滚动, + 始终位于滚动区之外、可见可点。

能装到别的 profile,或同时在多份 DSH 里用吗? 可以,装法一样:把包装进目标 profile 并登记进该 profile 的 dsh.profile.bundles。插件没有全局状态、 没有宿主侧行为(lib/index.js 是空实现),各 profile 各装一份互不影响。

标签栏挡住了别的浮层条目? 浮层里条目按 order 升序排列,本插件用 -50(较小,靠前)。若与另一个浮层插件冲突,改 lib/client.js 顶部注册处的 order 即可(见 4.1)。


变更记录

0.3.1

  • 纯文档发布,无代码改动(lib/ 与 test/ 相对 0.3.0 逐字节相同)。
  • 0.3.0 是在本文档重写之前发布的,所以 npm 上 0.3.0 页面里的说明仍指向旧的 API (useSessionPendingInteraction、sessions.open/clear、summary.completed、0.1.5-rc.1 下限等)。 本版只把文档更新到与代码一致,装 ^0.3.0 或 ^0.3.1 得到的运行代码完全相同。

0.3.0

  • 适配 DSH 0.1.7 的破坏性变更(这是本版唯一的动机,也是主版本号进位的原因):
    • useSessionPendingInteraction 在 0.1.7 已被移除 → 改读 useSessionStatus;
    • sessions 服务的 open() / clear() 已被移除 → 改用 uiWorkspace.openSession() / clearMain();
    • 会话列表快照不再有 current → 由 byId[id].retainedBy.mainView > 0 推导当前会话;
    • 会话摘要不再有 completed → 绿点改读 status.completionUnread。
  • 对齐外壳的"可见交互"集合:pendingInteraction 现在是带 kind 的对象,只有 approval / plan-review / question 会对用户呈现;本插件用 isVisiblePending() 对齐,隐藏类型不再点亮琥珀点。
  • 删除失效的 dsh.compatibility.dshReleases:0.1.7 全树已无任何读取方,真实下限写入 peerDependencies(>=0.1.7-rc.1)。
  • 回归测试 99 → 104 项:新增"外壳隐藏的交互类型不点亮"、"无状态条目回退到行 running"、 "当前会话来自 mainView 保留而非快照字段"三组断言;假环境改为只提供 slots + uiWorkspace。
  • ⚠️ 不再兼容 0.1.5 / 0.1.6:那些版本没有 useSessionStatus,装上去会和 0.2.2 在 0.1.7 上一样失效。

0.2.2

  • 布局测量改为按帧合并 + 幂等写入:ResizeObserver / window.resize 通知先合并到 requestAnimationFrame,每帧最多测量一次;几何串(top|left|width)未变时不再写任何样式。 旧实现是每个通知都同步测量并写 5 处样式,一帧内可能重复多次、读写交错引起布局抖动。 首次测量仍保持同步,避免首帧先按 CSS 兜底位置闪一下。
  • 中栏重建自愈:绑定的中栏元素脱离文档(isConnected === false)时自动重新查询、还原旧元素的内联 样式、把 ResizeObserver 改观察到新元素。
  • 挂载期有限重试:以前中栏未就绪就 return 且永久失效;现在 console.warn 一次并逐帧重试约 2 秒 (120 帧),中栏一出现即绑定。环境无 requestAnimationFrame 时退化为同步测量。
  • 同批次连续关闭不再丢更新:setOrder 改为函数式更新(prev => prev.filter(...))。
  • lib/client.d.ts 补充浏览器半依赖的全局契约(两个标准钩子 + 两个服务)。
  • 可读性整理:pending 判定的冗余 !== undefined、inject 数组缺分号、文件头安装说明改为 "registry 或 link: 皆可"。
  • 回归测试 78 → 99 项:新增合并调度、几何幂等、元素重建重绑、挂载期重试及其有界性、无 rAF 回退、 同批次双关闭等断言;假环境新增 requestAnimationFrame 队列与测量/写入计数器。

0.2.1

  • 修复 0.2.0 引入的布局回归:.dsh-tabs-list 曾写成 flex:1 1 auto,flex-grow 让标签容器吃满横条剩余空间, 把 + 按钮挤到了最右端。改为 flex:0 1 auto——宽度贴合标签,+ 紧跟最后一个标签; 标签溢出时仍会收缩并内部滚动。
  • 回归测试新增 3 项断言锁定该布局(+ 是横条最后一个子节点、tablist 为 flex:0 1 auto 且保留 min-width:0 + overflow-x:auto)。共 78 项。
  • 补齐 npm 发布所需的包元数据:移除 private,新增 description/keywords/author/repository/ homepage/bugs/engines/publishConfig/peerDependencies(+Meta)/dsh.compatibility, 以及 prepublishOnly 测试门禁;files 白名单调整为随包发布 test/。
  • 新增 LICENSE(MIT,Copyright (c) 2026 darren.ho)。

0.2.0

  • 修复"等待交互"状态点不可达,并把优先级定为 等待交互 > 运行中 > 已完成。
  • 修复关闭标签的落点(改为真正的邻居,右侧优先),并新增中键关闭。
  • 掉出会话列表的会话不再产生"看不见也关不掉"的标签。
  • 两个标准钩子改用身份选择器,消除 useSyncExternalStore 契约违规导致的重渲染风险。
  • 补齐键盘与 ARIA 支持(tablist/tab、roving tabindex、方向键/Home/End/Enter/Space/Delete、焦点环)。
  • 布局测量失败改为显式告警;卸载时精确还原内联样式。
  • 新增 test/behavior.mjs 与 npm test(当时 75 项断言)。

0.1.0

  • 首个版本:shell.overlay 浮层条目形式的顶部会话标签栏。

许可

MIT(见 package.json 的 license 字段)。