WangXuexin24/dsh-session-topics0

dsh-session-topics

会话话题分组:侧边栏按话题把会话归堆(纯客户端,零 host 服务依赖,不删会话、不执行脚本)

包名
dsh-session-topics
版本
0.1.0
许可证
MIT
最近更新
2026年9月12日

安装

$npx -p @deepseek-ai/dsh dsh plugin --profile web add github:WangXuexin24/dsh-session-topics

dsh-session-topics

给 DSH Web 侧边栏加一层按话题分组会话的能力。

只想先会用? 跳到 用户手册(装 / 开 / 关 / 恢复 / 卸载 / 升级)想接手改进代码?docs/HANDOFF.md —— 面向下一个 agent 的交接文档 (代码地图、必须遵守的契约、已知问题与改进路线)。

解决什么问题

DSH 官方的侧边栏只能按工作区分组,而工作区的成员资格是从每个会话记录的 cwd 推导出来的 —— 一个工作区只拥有 cwd 等于它自己路径的会话。所以同一个 目录下开出来的会话全挤在一组里,没法按主题拆开

本插件加一条纯展示层的轴:话题文件夹。

核心设计:话题归属于工作区

话题是工作区作用域的 —— 一个话题只属于一个工作区,会话绝不会被挪到不属于 它的工作区里。这不是随手定的,有两条理由:

  1. 它跟 DSH 自己的模型一致。 workspace 记账硬性保证"一个会话只属于一个工作区", insertSessionBefore 的会话排序也是按工作区的。跨工作区的话题会同时破坏这两点。
  2. 它让工作区保持可读。 如果会话能自由迁移进全局话题,工作区小节就会空掉, 用户再也答不上"这个会话是哪个项目来的"。

因此:跨工作区的拖拽会被直接拒绝(不建话题、不改归属)。这是有意为之。

功能

  • 侧边栏渲染成 工作区 → [话题 → 子话题 → 会话] + 未归话题
  • 每个工作区行右侧有 +,在那个工作区里新建话题
  • 会话行右侧显示相对时间(「刚刚 / 5 分钟前 / 2 小时前 / 3 天前 / N 个月前」),每分钟自动刷新
  • 拖会话到另一个会话的上/下半 → 调整顺序(插入指示线在上或在下),顺序由 host 持久化
  • 拖到话题标题上(含子话题)→ 归入该话题
  • 拖到「未归话题」标题上 → 移出话题回到未归类(归错了能拖回来)
  • 话题行悬停出现 + / / ×:新建子话题 / 重命名 / 删除
  • 话题嵌套一层(话题 → 子话题)。数据模型是递归的,只有 UI 限层,见 MAX_TOPIC_DEPTH,改一个常数即可放开
  • 会话右键菜单:重命名会话(内联编辑)/ 分叉会话 / 归档会话 / 移出话题
  • 工作区右键菜单:新建话题 / 重命名工作区 / 删除工作区(删除前确认)
  • 展开/折叠、话题、归属关系全部持久化,刷新和重启都保留
  • 点会话行照常打开会话

「拖拽排序」和「拖成文件夹」是两件事

官方的拖拽手势是排序(拖到行的上/下半,host 持久化)。第一版把它挪用成 「拖到会话上就建话题」,等于抢走了用户本来就有的手势,还让排序和分组两个概念 再也分不清。

现在的边界是明确的:

你想干什么怎么操作
调整顺序拖到另一会话的上/下半(显示插入线)
归入某个话题拖到话题标题
移出话题拖到**「未归话题」标题**上
新建话题工作区行右侧 +(或右键工作区)
新建子话题话题行悬停的 +

接管官方槽位,就必须继承官方职责

本插件用 priority: -1 顶替官方 ui-workspace 注册的浏览器 —— 会话行从此由本插件 渲染。这意味着官方行上原有的每一个操作都属于本插件的责任

第一版只实现了"打开会话",于是重命名 / 分叉 / 归档 / 工作区重命名删除全部静默消失。 保留会话是因为用户只看得见"少了个功能",看不见"是谁拿走的"。任何接管槽位的插件 都必须先盘点被替换方的全部能力,再逐条实现

设计取舍(为什么它比同类插件"干净")

本插件社区同类插件
host 半边空壳,不 inject 任何服务有的 inject storageDomain/workspaceRegistry
持久化浏览器本地(客户端 store)有的落 ~/.dsh 文件
启动风险结构性为零(不依赖任何 host 服务)有挂起启动的先例
删除会话不做有的会写 .ps1ExecutionPolicy Bypass 执行
官方数据只读,从不写有的改写/替换官方渲染器
官方 store key从不触碰(自有 dsh.session.topics.v2

明确不做的事:不删除会话、不调子进程、不生成脚本、不写任何 host 文件、不注册 任何面向模型的工具或提示词(零 token 成本)。

两条来自踩坑的硬约束:

  • 垃圾回收器只能删它能证明已死的东西。 清理"死引用"的那一趟,在刷新后的 前几帧会用空的 workspaces/byId 去比对已经加载好的话题表 —— 于是每个话题 都被判定"所属工作区已不存在",每次刷新都把用户的分组整个删光。 修法是两层:数据没到齐(phase !== 'ready')就不跑;并且 store 层的 retain() 在比较集为空时直接返回(空的比较集意味着"还没加载",不是"全都没了")。
  • 用户手搭的东西必须有第二份。 分组无法再生成,所以每次非空变更都会把快照写进 dsh.session.topics.v2.bak;当主存储为空而备份有内容时,侧边栏顶部会出现一条 恢复条,一键还原。
  • 不依赖 store action 的返回值。 需要原子性的写入都做成单个 action (如 createTopicWithSessions),因为 store 实现可以不返回值(Redux 风格), 靠读返回值拿新 id 会静默失效。
  • 必须接住 shell 的滚动契约。 官方 ui-workspace 的根节点是 flex:1; min-height:0; flex-direction:column,内层列表 flex:1; overflow-y:auto。 不这么做,内容会被 overflow:hidden 的区域裁掉且无法滚动 —— 看起来像 "前端卡死",但 JS 线程其实完全健康。
  • 捕获阶段的监听器会抢在一切之前。 右键菜单用捕获阶段的 mousedown 做 "点外面关闭",结果连菜单内部的按下也被它先看到 —— 菜单在 click 触发前就被 卸载,所有菜单项变成摆设stopPropagation 救不了(捕获早于冒泡), 必须显式判断 ref.current.contains(event.target)
  • parentId 是分叉血缘,不是子代理。 用它过滤会把每一个分叉出来的会话 藏起来 —— 用户点完"分叉"就找不到子会话了。唯一正确的过滤条件是 origin === 'subagent'
  • fixed 定位的菜单必须钳制在视口内。 在贴近屏幕底部右键时,按光标坐标摆放 会把末尾几项推出视口 —— 菜单看着正常,但最后几项物理上点不到。 要在挂载后量尺寸再钳制,而不是渲染前估算。

安装(本地目录)

dsh plugin --profile web add D:/DSH/projects/dsh-session-topics

或按本 profile 已有的 link: 惯例,在 ~/.dsh/profiles/web/package.json 里加:

"dsh-session-topics": "link:D:/DSH/projects/dsh-session-topics"

并把它加进同一文件的 dsh.profile.bundles 数组。

改代码后只需刷新浏览器页面(客户端 bundle 每次加载都重新取),不必重启 dsh web

验证与回滚

node "/node_modules/@deepseek-ai/dsh/lib/bin.js" --profile web --dump-config
node "/node_modules/@deepseek-ai/dsh/lib/bin.js" plugin --profile web remove dsh-session-topics

本机 dsh.cmd / dsh.ps1 在受限 shell 里会被执行策略拦掉(返回空输出,容易被误判成 "命令没生效"),直接 node .../lib/bin.js 更稳。

离线自测

不需要浏览器,不需要装依赖:

node test/smoke.mjs

测试桩是故意做成迷你 React 的:函数组件会被真正调用、hook 状态跨渲染持久、 effect 尊重依赖数组,并且 store 的 action 故意不返回值。这三点都是踩过的坑 —— 早期版本的桩太松,把真 bug 放过去了。

覆盖:模块加载、slot 注册与 shadow 优先级、会话过滤(隐藏 subagent / 已归档 / 非当前空白会话)、按工作区建话题、空话题可见、真实 input→commit 重命名路径、 同工作区拖拽建话题、跨工作区拖拽被拒绝(含拖到标题上)、嵌套与层数上限、 删除父话题连带子树、点击打开会话、滚动契约 CSS、官方同款 14px 字号。

以及一组**"接管不能丢功能"**的回归:会话右键菜单存在且含重命名/分叉/归档、 重命名走内联编辑并提交到 host RPC、分叉与归档路由正确、工作区菜单含 新建话题/重命名/删除、拖到「未归话题」能把会话移出话题、跨工作区移出被拒绝、 retain() 会清掉已死话题/工作区的展开态 key。

还有一条菜单可点击性回归:菜单内部按下不能把它关掉(否则 click 永远不触发), 菜单项点击必须真的执行动作,菜单外部按下必须关闭。

结构

package.json        "." → 空壳 host;"./client" → 浏览器半边
cordis.patch.yml    插入 host 行(id: session-topics)
lib/index.js        host 空壳,故意不 inject 任何服务
lib/client.js       全部功能(__ModuleLoader__ 包装的浏览器产物)
test/smoke.mjs      离线冒烟测试

已知限制

  • 两级话题,无更深嵌套(改 MAX_TOPIC_DEPTH 可放开,但侧边栏宽度会先撑不住)。
  • 话题归属存在浏览器本地:换浏览器或清缓存会丢,也不跨设备同步。 这是为了换取"零 host 依赖、零启动风险"而做的刻意取舍。
  • 接管了 sidebar.workspaces 槽位(priority: -1 压过官方的 0)。这是加会话级 分组的前提 —— 会话行本来就是官方那个渲染器画的。官方将来更新该槽位时需人工跟进。
  • 未实现官方的「添加工作区…」目录流子 slot(官方 sidebar.workspaces.directoryFlow 已由官方条目声明,本插件故意不重复声明)。需要新增工作区时用官方入口或 dsh CLI。

用户手册(装 / 开 / 关 / 恢复 / 卸载 / 升级)

我想…怎么做
插件市场 → 安装;或 dsh plugin --profile web add D:/DSH/projects/dsh-session-topics
临时关掉插件市场 → 已安装 → 本插件卡片的「启用中」开关关掉 → 按提示重启 DeepSeek Harness
再打开同一开关打开 → 重启
恢复出厂(重置分组)卡片上的「恢复」按钮
卸载卡片上的「卸载」;或 dsh plugin --profile web remove dsh-session-topics
升级本地代码直接改 D:/DSH/projects/dsh-session-topics 里的文件 → 刷新浏览器页面即可(客户端 bundle 每次加载都重新取)。只有改了 package.json / cordis.patch.yml 才需要重启 dsh web
确认它在跑侧边栏是「话题」分组而不是官方的纯工作区分组 = 它在跑;node test/smoke.mjs 应输出 RESULT: all checks passed

开关与「恢复 / 卸载」是插件市场dshmarket)的能力:它改的是 profile 的 patch 层, 所以开关之后要按提示重启才生效。本插件自身不实现开关。

疑难排错

症状原因处理
侧边栏变回官方按工作区分组插件被关掉 / 没加载(priority: -1 没生效)看插件市场里是否「启用中」;重启 dsh web
分组整个不见了浏览器本地存储被清(换浏览器、清缓存)顶部若有恢复条点还原;否则查 localStorage["dsh.session.topics.v2.bak"]
列表滚不动、像前端卡死滚动契约被破坏(根节点的 flex 与列表 overflow)docs/HANDOFF.md §3.3
侧边栏冒出一批空对话不是本插件的问题见下方「已知问题」
右键菜单点不动捕获阶段的监听器把菜单提前关掉了docs/HANDOFF.md §3.7
冒烟测试不过改了 UI / 过滤逻辑node test/smoke.mjs 看具体断言,改完必须全绿

已知问题

  • 空对话:DSH 的冷会话摘要把 blank 默认成 falsedsh-api-session-controllersummarizeCold),于是"开起来就死"的空壳会话 在官方侧边栏和本插件里都会显示。根因、识别特征与三条缓解路径见 docs/HANDOFF.md §5.1。
  • 官方 sidebar.workspaces 槽位演进时需要人工跟进(本插件顶替了官方浏览器)。
  • 分组数据存在浏览器本地:不跨设备、清缓存即丢(有 .bak + 恢复条兜底)。

发布状态

  • 已公开发布:(PUBLIC,main
    • 他人安装:dsh plugin --profile web add github:WangXuexin24/dsh-session-topics
    • 本机仍以 link: 安装,出现在插件市场「已安装」列表(带开关)
    • lib/ 已随仓库提交(dsh 从 git 安装不跑构建)
  • npm:未发布 —— 可选。不发也能从 GitHub 安装;发了市场才会显示下载量。
  • 社区目录收录(出现在市场「发现 / 主题」):可选,尚未提交。 条目已备好在 docs/market-submission.yml,随时可用; 另有「仓库创建满 1 天」的 CI 门槛。不收录不影响安装,只影响被搜到的概率。

License

MIT