dsh-pin-session —— DSH「置顶对话」插件
把重要的对话钉在 DSH 侧栏的最上面。会话头部一个 📌,侧栏会话行 hover 也一个 📌,点一下就行。
侧栏 会话头部
┌──────────────────────────┐ ┌───────────────────────────────┐
│ ▼ my-project │ │ 关于笔记发布流程 📌 ⋯ │
│ 📌 部署排查(置顶) │ └───────────────────────────────┘
│ 📌 课表整理(置顶) │ ↑ 点它置顶/取消置顶
│ nginx 配置查看 │
│ 随手一问 │ 侧栏会话行 hover
└──────────────────────────┘ ┌───────────────────────────────┐
│ 随手一问 刚刚 📌 ⋯ │
└───────────────────────────────┘
- 零运行时依赖:宿主端只用
node:fs/node:path,客户端只require("react")。 - 不加模型上下文:置顶是人的界面行为,不产生 token、不影响 prompt cache。
- 坏了不影响 DSH:任何一步失败只丢本插件自己的效果,侧栏本体不受影响。
为什么是"宿主存事实 + 客户端排序"这个结构
动手前把 DSH 前端翻了一遍,两个事实决定了整个设计:
- 宿主端挪顺序在界面上看不出来。
侧栏渲染顺序 =
reconciledSessionOrder(宿主 sessionIds, 浏览器本地排序表), 本地表优先;而且默认orderBy="updated"还会按活跃度把刚动过的会话提到前面。 也就是说,就算在宿主端老老实实调insertSessionBefore把会话挪到第一, 界面上它还是待在原地。→ 排序只能放在客户端。 - 侧栏会话行上没有 session id。
行是
div[role="treeitem"][aria-selected],里面只有 状态点 / 标题 / 时间 / 菜单, 没有任何 id 属性。→ id 得自己想办法拿(见下)。
所以本插件的分工是:
| 层 | 负责 | 失效后果 |
|---|---|---|
宿主端 lib/index.js、store.js、routes.js |
存"哪些会话被置顶、顺序如何" + 自己的 HTTP 接口 | 置顶数据没了(有原子写与备份),界面照常 |
客户端 lib/client.js |
头部按钮(官方槽位)、行内按钮、把置顶行搬到分组顶部 + 📌 徽标 | 只是"钉不住",侧栏本体完全不受影响 |
session id 怎么拿(两级,都拿不到就什么都不做,绝不猜):
- 读 React fiber:从行这个 DOM 节点上的
__reactFiber$…往上走几层,取memoizedProps.node.id。精确、无副作用。 - 回退到标题反查:用
/api/list返回的标题建索引,只在标题唯一时才认。
安装
前提:pnpm 在 PATH 上(DSH 自带的那个即可),核心版本 0.1.5-rc.1。
# 本地开发装(link):把 <插件目录> 换成你 clone/开发的位置
dsh plugin --profile web add link:/path/to/dsh-pin-session
# 或从 github / npm 装
dsh plugin --profile web add github:BPTumbleweed/dsh-pin-session
dsh plugin --profile web add dsh-pin-session
装完要重启对应的 dsh-web-* 服务才生效。注意:如果你(Agent)自己就跑在那个服务里,
直接 systemctl restart 会杀掉当前回合 —— 用它之外的方式重启(例如脱离会话 cgroup 的
systemd 瞬时单元)。重启前先 free -m 确认 available 够用。
改客户端代码不用重启:DSH 的宿主端 HMR 会监听
link:插件的文件并重新哈希客户端 bundle, 刷新页面即可拿到新版本(实测:改完lib/client.js后 index.html 上该模块的rev立刻变了)。 只有动宿主端(lib/index.js等)才需要重启。
停用 / 回滚
# 临时停用:给 profile patch 里这一项加 disabled: true,重启
# 彻底卸载:
dsh plugin --profile web remove dsh-pin-session
浏览器侧还有两个免重启的开关(写在 localStorage 里,刷新页面生效):
| 键 | 值 | 作用 |
|---|---|---|
dsh-pin:disable |
"1" |
整个客户端效果关掉(含按钮与徽标) |
dsh-pin:prefix |
"/dsh-pin" |
改接口前缀(宿主端改了 routePrefix 时用) |
配置(profile patch 覆盖)
- id: dsh-pin-session
config:
storeRoot: /path/to/pin-data # 默认 $DSH_HOME/dsh-pin-session
routePrefix: /dsh-pin # 接口与面板前缀
maxPins: 100 # 置顶上限
breakerThreshold: 5 # 同一能力连续失败几次后熔断
allowUnfenced: false # 栅栏不可用时是否放行(默认 false = fail-closed)
数据文件
<storeRoot>/pins.json,形如:
{
"schema": 1,
"revision": 5,
"pins": [
{ "sessionId": "session-…", "title": "部署排查", "workspaceId": "", "pinnedAt": 1789527147689 }
]
}
- 数组顺序就是显示顺序,
pins[0]在最上面;新置顶插到最前。 - 原子写:先写
pins.json.tmp-<pid>再rename,断电最多丢最后一次改动。 - 损坏自愈:读不动就把坏文件改名成
pins.json.corrupt-<时间戳>留证,然后以空集合继续。
HTTP 接口
全部走 DSH 的浏览器信任栅栏 connection.requestRejection,栅栏缺失时默认 fail-closed
(置顶数据带会话标题,不能裸奔)。
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /dsh-pin/api/list |
置顶集合(客户端排序引擎的数据源) |
| GET | /dsh-pin/api/status |
自检状态(能力、计数、最近错误) |
| POST | /dsh-pin/api/pin |
{"sessionId":"…","title":"…"} |
| POST | /dsh-pin/api/unpin |
{"sessionId":"…"} |
| POST | /dsh-pin/api/toggle |
{"sessionId":"…","title":"…"} |
| POST | /dsh-pin/api/reorder |
{"order":["id1","id2"]} |
| GET | /dsh-pin/panel |
人看的管理页(纯服务端渲染,无前端依赖) |
命令行
不走 HTTP,直接读写同一份 pins.json;给没有浏览器的场合用(SSH、脚本、人工救援)。
node bin/pin.mjs list [--json]
node bin/pin.mjs pin <sessionId> [title]
node bin/pin.mjs unpin <sessionId>
node bin/pin.mjs toggle <sessionId> [title]
node bin/pin.mjs move <sessionId> <top|bottom|<beforeSessionId>>
node bin/pin.mjs clear
node bin/pin.mjs path
# 目录解析顺序:--store/--file > $DSH_PIN_STORE > $DSH_HOME/dsh-pin-session > ~/.dsh/dsh-pin-session
自测
npm test # = node test/selftest.mjs && node test/dom-engine.test.mjs
npm run contract # 对着真实安装的 DSH 包核对本插件依赖的结构
test/selftest.mjs(36 项):语法、存储语义(幂等/上限/控制字符清洗)、持久化与坏文件自愈、 路由(含 fail-closed 栅栏)、真node:http端到端、客户端静态检查。test/dom-engine.test.mjs(16 项):用一套极小假 DOM 把lib/client.js真跑起来。假 DOM 刻意 复刻真实结构(每个会话行外面还有 HoverCard 的一层<span>包装、分组 section 里还有 project 行), 验排序算法、幂等(防 MutationObserver 自激循环)、行内按钮点击、焦点策略、多棵role=tree时不认错树、 id 取不到时不动 DOM。test/contract-check.mjs(12 项):DSH 升级后先跑这个。核对槽位声明、行结构、rowActions/title容器、role="tree"、HoverCard 包装层、以及"本地排序表优先 + 默认 orderBy=updated" 这个设计前提是否还成立;红了就说明客户端那几处非公开依赖需要重新适配。
假 DOM 测试抓到过两个真 bug: ① 重排循环原本拿"行快照"当锚点,搬动之后锚点错位,顺序会排错(
[s3,s1,s2,s4]→[s2,s3,s1,s4])。 现在用 live 数组模拟insertBefore语义。 ② 每行外面还有一层 HoverCard 包装,按"父容器里 ≥2 行"分组时行行都只有 1 个 → 整轮跳过, 表现就是"徽标出来了但位置不动"。现在按"可搬运单元"(自动识别包装层)来排。
兼容性与失效姿态
DSH 升级后可能被打断的地方,按"最先坏"排序:
| 环节 | 依赖的东西 | 坏了会怎样 |
|---|---|---|
| 头部按钮 | 官方 slot conversation.session.header.actions |
按钮消失,侧栏置顶照常 |
| 行内按钮 | 行内 class 含 rowActions 的容器 |
按钮消失,头部按钮照常 |
| id 识别 | React fiber 的 __reactFiber$ + memoizedProps.node.id |
退到标题反查;仍不行就整体不动作 |
| 行识别 | div[role="tree"] + [role="treeitem"][aria-selected] |
找不到列表 → 什么都不做 |
| 搬运单元 | mountUnitOf:从行往上走到"装着 ≥2 行或含分组表头"的那层 |
判据不合身时退化成搬行本身(多半仍可用),最坏是不动作 |
| 排序 | 只改 DOM 顺序,不碰 React 状态 | 下一轮扫描会补回来(React 重排后最多 ~140ms 归位) |
熔断:DOM 引擎连续出错 5 次就整体拆掉(移除按钮、徽标、样式)并在 console 留一条警告, 绝不半死不活地反复报错。接口连续失败 5 次就停轮询,手动点击仍可再试。
已知限制
- 只在浏览器里生效:置顶是客户端 DOM 排序,不动宿主端的会话顺序。 所以换个浏览器/清掉本地状态后,置顶集合还在(服务端),效果照旧 —— 但用别的 DSH 客户端 (比如移动端)看不到置顶效果。
- 只在同一个工作区分组内置顶:DSH 侧栏按工作区分组,置顶行被提到"它自己那个分组"的顶部。 未归入任何工作区的会话(Ungrouped 桶)同样适用。
- 搜索态不干预:搜索结果的顺序由查询决定,此时不动 DOM。
- 重命名时不动 DOM:焦点在会话行内时跳过这一轮(搬节点会让输入框失焦),焦点离开后自动补上。
- 标题可能缺失:从 CLI 置顶的会话若没带 title,列表里只显示 sessionId(不影响排序, 排序靠 fiber 拿的 id)。
装完后的手工验收清单
自动化测试覆盖不到真实浏览器,装好后按这 7 条过一遍(约 2 分钟)。
注意第 0 条:客户端文件改动是宿主端 HMR 直接生效的,但页面必须 Ctrl+Shift+R 硬刷新
(普通 F5 常常还在用缓存的旧模块),重启过服务的话也顺带换掉旧的 gate token。
- 硬刷新页面。
- 侧栏会话行 hover → 出现 📌 → 点击 → 该行跳到所在分组顶部,标题前出现 📌 徽标。
- 进入该会话 → 头部出现 📌,且是"已置顶"配色 → 点一下 → 取消置顶,行回到原来位置附近。
- 再钉 2 个,确认"后钉的在最上面"。
- 刷新页面(F5)→ 置顶仍然生效。
- 随便发一条消息让另一个会话活跃起来 → 置顶行没有被挤下去(默认
orderBy=updated会提升活跃会话)。 - 在会话里正常打字(composer 有焦点)→ 置顶行依然在顶部,没有被"卡住不排序"。
- 在侧栏里改一个会话名(改名输入框有焦点时)→ 置顶行暂时不动是正常的,焦点离开后会自动归位。
出问题时看 /dsh-pin/panel 与 /dsh-pin/api/status,浏览器 console 搜 dsh-pin-session。
变更日志
0.1.1 — 2026-09-16
- 修:置顶徽标出来了但行的位置不动。根因是 DSH 用
HoverCard给每个会话行套了一层<span>包装(行是该 span 的唯一子元素),于是"按父容器分组、容器内 ≥2 行才重排"的判据 永远只有 1 行 → 整轮跳过。现在改为先算出每行的可搬运单元(从行往上走到"装着 ≥2 行或 含分组表头"的那一层),搬包装层而不是行本身。徽标/按钮本来就走另一条路径,所以之前只坏在排序。 - 修:页面上有多棵
role="tree"时(分组列表 / 扁平列表 / 搜索结果树,别的插件也可能有树), 改为优先挑真的装着会话行的那棵(先认class*=sessionRow,再退到[role=treeitem][aria-selected])。 - 假 DOM 测试夹具改成复刻真实结构(HoverCard 包装层 + project 行),并加了"包装层必须还在、 行不能被拽出来""无包装层时也要能排""多棵树不认错" 3 组回归。
- 契约检查新增"会话行仍然经由 HoverCard 渲染"一条。
- 客户端改动由宿主端 HMR 直接生效,不需要重启服务。
0.1.0 — 2026-09-16
- 首个版本。
- 宿主端:置顶集合持久化(原子写 + 损坏自愈 + 数量上限)、
/dsh-pin/api/*全套接口、 信任栅栏内面板页、能力探测与失败熔断。 - 客户端:会话头部 📌(官方 slot)、会话行 hover 📌(注入行内
rowActions)、 置顶行提到分组顶部 + 标题 📌 徽标、React fiber 取 id(带回退)、焦点策略、整体熔断。 - CLI
dsh-pin:list/pin/unpin/toggle/move/clear/path。 - 测试:
selftest.mjs36 项 +dom-engine.test.mjs13 项(假 DOM 跑真客户端代码)contract-check.mjs11 项(对照 DSH 0.1.5-rc.1 真实包)。
许可
MIT © 2026 Kan Zheng(202309068@uibe.edu.cn)
No comments yet. Be the first to write one.