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)。
目录
一、这是什么
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 变量的方案:
document.querySelector('[class*="centerCol"]')找到布局的中栏(布局的 CSS Module 类名形如pI_x6G_centerCol,哈希前缀会随构建变化,但可读后缀centerCol稳定,故用子串匹配);- 读取它的
getBoundingClientRect(),把top / left / width写入文档根元素的 插件私有变量--dsh-tabs-top、--dsh-tabs-left、--dsh-tabs-width; - 给该元素设置
padding-top: 28px(BAR_HEIGHT)与box-sizing: border-box, 让会话内容从中栏顶部下移一个横条的高度; - 用
ResizeObserver+window.resize监听几何变化(侧栏折叠、详情栏拖拽、窗口缩放都会触发); 两个来源都只做调度:同一帧内的多次通知合并成一次测量(requestAnimationFrame); 环境里没有requestAnimationFrame时退化为同步测量; - 写入前先比对几何串
top|left|width,没变就完全不碰样式——拖拽期间不再反复读布局、写样式; - 中栏在挂载瞬间还没渲染时不放弃:
console.warn一次,然后逐帧有限重试(约 2 秒 / 120 帧), 一旦出现立即绑定;若中栏被上游重建(元素脱离文档,isConnected === false),下一次通知会 重新绑定:先还原旧元素的内联样式,再改观察新元素并重新测位; - 卸载时还原当前绑定元素的原始
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 动态定义路径)——当前目录中没有这个文件。 - 仍然开放的方向(按实现成本从低到高):
order持久化(按工作区分组),或直接投影ids而不是记录"打开历史";- 拖拽重排;
- 标签溢出下拉菜单;
- 标签右键菜单(归档、重命名、关闭其他/右侧全部)。
十、常见问题(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 字段)。