DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

BPTumbleweed /

BPTumbleweed/dsh-pin-session

Verified

DSH「置顶对话」插件:会话头部与会话行内一键 📌,把重要对话钉在侧栏分组顶部。宿主端只存事实(原子写 JSON + 信任栅栏内的 HTTP 接口 + 配套 CLI),排序由客户端在 DOM 层完成——因为侧栏顺序由浏览器本地排序表优先决定,宿主端挪顺序在界面上无效。零运行时依赖、能力探测与熔断,面向跨版本升级设计。

★ 0 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHub
READMESource: main@f9073486

dsh-pin-session —— DSH「置顶对话」插件

把重要的对话钉在 DSH 侧栏的最上面。会话头部一个 📌,侧栏会话行 hover 也一个 📌,点一下就行。

侧栏                                    会话头部
┌──────────────────────────┐            ┌───────────────────────────────┐
│ ▼ my-project             │            │ 关于笔记发布流程   📌  ⋯      │
│   📌 部署排查(置顶)      │            └───────────────────────────────┘
│   📌 课表整理(置顶)      │                    ↑ 点它置顶/取消置顶
│   nginx 配置查看          │
│   随手一问               │            侧栏会话行 hover
└──────────────────────────┘            ┌───────────────────────────────┐
                                        │ 随手一问       刚刚   📌  ⋯   │
                                        └───────────────────────────────┘
  • 零运行时依赖:宿主端只用 node:fs/node:path,客户端只 require("react")。
  • 不加模型上下文:置顶是人的界面行为,不产生 token、不影响 prompt cache。
  • 坏了不影响 DSH:任何一步失败只丢本插件自己的效果,侧栏本体不受影响。

为什么是"宿主存事实 + 客户端排序"这个结构

动手前把 DSH 前端翻了一遍,两个事实决定了整个设计:

  1. 宿主端挪顺序在界面上看不出来。 侧栏渲染顺序 = reconciledSessionOrder(宿主 sessionIds, 浏览器本地排序表), 本地表优先;而且默认 orderBy="updated" 还会按活跃度把刚动过的会话提到前面。 也就是说,就算在宿主端老老实实调 insertSessionBefore 把会话挪到第一, 界面上它还是待在原地。→ 排序只能放在客户端。
  2. 侧栏会话行上没有 session id。 行是 div[role="treeitem"][aria-selected],里面只有 状态点 / 标题 / 时间 / 菜单, 没有任何 id 属性。→ id 得自己想办法拿(见下)。

所以本插件的分工是:

层 负责 失效后果
宿主端 lib/index.js、store.js、routes.js 存"哪些会话被置顶、顺序如何" + 自己的 HTTP 接口 置顶数据没了(有原子写与备份),界面照常
客户端 lib/client.js 头部按钮(官方槽位)、行内按钮、把置顶行搬到分组顶部 + 📌 徽标 只是"钉不住",侧栏本体完全不受影响

session id 怎么拿(两级,都拿不到就什么都不做,绝不猜):

  1. 读 React fiber:从行这个 DOM 节点上的 __reactFiber$… 往上走几层,取 memoizedProps.node.id。精确、无副作用。
  2. 回退到标题反查:用 /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。

  1. 硬刷新页面。
  2. 侧栏会话行 hover → 出现 📌 → 点击 → 该行跳到所在分组顶部,标题前出现 📌 徽标。
  3. 进入该会话 → 头部出现 📌,且是"已置顶"配色 → 点一下 → 取消置顶,行回到原来位置附近。
  4. 再钉 2 个,确认"后钉的在最上面"。
  5. 刷新页面(F5)→ 置顶仍然生效。
  6. 随便发一条消息让另一个会话活跃起来 → 置顶行没有被挤下去(默认 orderBy=updated 会提升活跃会话)。
  7. 在会话里正常打字(composer 有焦点)→ 置顶行依然在顶部,没有被"卡住不排序"。
  8. 在侧栏里改一个会话名(改名输入框有焦点时)→ 置顶行暂时不动是正常的,焦点离开后会自动归位。

出问题时看 /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.mjs 36 项 + dom-engine.test.mjs 13 项(假 DOM 跑真客户端代码)
    • contract-check.mjs 11 项(对照 DSH 0.1.5-rc.1 真实包)。

许可

MIT © 2026 Kan Zheng(202309068@uibe.edu.cn)

—/ 5

No ratings yet

Verified DSH bundle

Commit f9073486f5f2

Community comments

No comments yet. Be the first to write one.

DSH HUB

A community index for DSH plugins. Not an official GitHub or DeepSeek AI product.

CommunityResourcesAPIAbout