xlxs123/dsh-session-multiselect

dsh-session-multiselect

为 DSH 对话加入可搜索、按工作区分组的多选面板,支持 Shift 范围选择与 Ctrl/Cmd 追加选择,然后批量删除、归档或置顶。

Uncategorizedbatch-operationsbrowserdeepseek-harness

dsh-session-multiselect

DSH Web 客户端的对话多选插件:一次勾选多个对话,批量删除、恢复、归档或置顶。 点会话列表搜索框左侧的入口按钮弹出面板 —— 可搜索、按工作区/日期分组、快捷筛选、Shift 范围选择、 整组勾选、键盘操作;删掉的对话先进回收站(7 天内可恢复),也能永久删除或清空。

Multi-select for DSH conversations. One click on the button beside the session list's
search icon opens a searchable, workspace/date-grouped panel; batch actions are delete (into a
7-day restorable trash), restore, archive and pin. Deleting runs through this plugin's own Node
half (GET /session-multiselect/delete), because the Web client exposes no session-delete API on
this build. Install as a standard DSH bundle: dsh plugin --profile desktop add . (see
安装). No build step, MIT licensed.

已上架 DSH 插件市场(dshmarket 里搜索 dsh-session-multiselect): 市场页 · 99 秒完整功能演示:B 站视频

入口与做法:DSH 原生侧边栏的会话列表由 @deepseek-ai/dsh-client-ui-workspace 以 single slot 独占渲染, 没有提供逐行的扩展 slot,所以入口是 DOM 注入:

  • 在会话列表工具栏那一行、搜索放大镜左侧注入一个 28px 的入口按钮,点它打开面板
  • 面板、状态、批量动作全部走官方扩展面:sidebar.footer.action slot(注入失败时的兜底入口)、

sessions 服务、workspaces 服务(归档/置顶)、locale 字典、客户端 store —— 不改动 DSH 原生代码

  • 注入失败也不会失去功能:定位不到目标时入口自动退回侧边栏底部
0.4.0 起移除了内联勾选模式(圆圈画进会话行 + 底部操作栏):用户要求只保留原来的面板模式。
需要找回它的话,git revert 0.4.0 那一个提交即可。

兼容性:在 DSH Desktop 0.9.0 / 0.10.0(Harness 0.1.x / 0.1.7-rc.2)与 DeepSeek Harness nightly(Harness 0.2.0-rc.2)的 Web 客户端上开发并实测(Windows)。插件只依赖官方已有的 slots / locale / sessions 三个服务并可选地使用 workspaces; 对会随版本变化的装饰性契约(图标导出名、Button 的 variant、带哈希的类名)一律做 「多候选名 + 自带兜底」,见下文「跨版本兼容」。

插件**不声明任何 @deepseek-ai/dsh* 的 peerDependency**,所以 0.2 起的安装期/启动期兼容性闸门 (只校验这类 peer)不会拦它 —— 换版本时不需要 allow-version 豁免。0.2.0-rc.2 上逐项核对过的契约: Modal / Tooltip / Button / Input、五个图标导出名(IconSearchOutline* / IconChecklistOutline* / IconLoadingOutline* / IconPinOutline* / IconCheckOutline*)、sidebar.footer.action 这一个 slot、 workspaces 的 pinSession / unpinSession / archiveSession / unarchiveSession 与 pinnedSessionIds / archivedSessionIds 快照、以及会话行的 data-row-key="session:"。 (0.2 把搜索按钮的中文 aria-label 挪进了语言包,所以按无障碍名匹配可能落空 —— 兜底的 button[class*="_searchButton"] 那一级在本版上仍然命中。)

功能

面板多选

  • 复选框列表(只列出真实对话,自动隐藏空白会话与子智能体会话)
  • 删除进回收站,可恢复:删除不是 rm -rf,而是把会话目录(连同投影缓存)移进

$DSH_HOME/trash/session-multiselect/,保留 7 天;面板工具栏的「回收站」列出最近删除的对话 (标题/工作区/删除时间),一键「恢复」放回原工作区。超过 7 天的条目在下次删除/查看/恢复时自动清理。 回收站里每条还显示「还剩 N 天」,可永久删除单条或清空回收站(都会二次确认); 回收站内可以多选(点行勾选 / 全选),然后一次「恢复选中」或「永久删除选中」; 工具栏还有搜索框(按标题或解码后的工作区路径)和工作区下拉筛选,「全选」只作用于当前筛选结果; 删除确认框会列出最多 3 个将被删除的对话标题,而不是只给一个数字。

  • 打开中的对话分两种处理:正在运行的对话拒绝删除(文件正在写,插件无法卸载它,状态行会说明);

只是被 DSH 打开过、当前空闲的对话会照常移进回收站,状态行会提示"其中 N 个仍被打开: 列表里的行会在重启 DSH 后消失"——文件已经安全躺在回收站,可恢复。

  • 三种分组:按工作区 / 按日期(今天 / 昨天 / 最近 7 天 / 更早)/ 不分组,选择会记住
  • 快捷筛选:全部 / 运行中 / 置顶(与「显示归档」开关叠加)
  • 复制清单:把当前可见对话复制成 Markdown 表格(标题 / 工作区 / 更新时间 / 状态标签),方便贴进 issue 或交接
  • 按工作区分组:每个工作区一个标题(显示完整路径,路径过长时省略号出现在左侧、保留尾部目录名),

标题上带 已选/总数;点标题一次即可整组勾选/取消。分组顺序按该组内最新对话的时间排, 没记录工作目录的会话单独归到最后一组。工具栏的「按工作区分组」可以关掉(关掉后回到单纯按时间排序的 平铺列表),这个选择会记住

  • 全选 / 清空 / 反选
  • 键盘操作:↑/↓ 在行间移动焦点(Home/End 跳首尾),空格/回车 勾选当前行,

Ctrl/Cmd+A 全选、Delete 直接打开删除确认;回收站视图同样支持行间移动与空格勾选

  • 搜索:按标题、工作目录、会话 ID 过滤
  • Shift + 点击按范围选择(范围按当前显示顺序,也就是分组后的顺序);Ctrl/Cmd + 点击追加选择
  • 已选计数实时显示;已置顶的行排在最前(和原生列表的置顶分组一致),并带「已置顶」标签

入口位置

入口按钮注入在会话列表工具栏那一行、搜索控件左侧,与原生控件同一行,间距与原生按钮一致。

这是整个插件在原生工具栏上的唯一注入点:那个工具栏由 dsh-client-ui-workspace 渲染,官方没有声明任何 header slot, 「放在放大镜左边」在官方扩展面上无法实现。做法是把按钮绝对定位在搜索控件左侧、脱离 flex 流:

  • 必须脱离 flex 流,这是两次踩坑换来的结论。该行是 display:flex; gap:4px; justify-content:flex-end,

其中搜索槽是 flex:1; max-width:28px; overflow:hidden —— 只为搜索按钮预算了 28px。 按钮一旦参与这一行的 flex 排布(无论插进控件内部,还是插成控件的兄弟节点), 都会把这 28px 挤爆,表现就是「放大镜被挤走 / 消失」。 现在 host 用 position:absolute 挂在控件左缘,不占这一行任何宽度,放大镜原地不动

  • 该行是带 overflow:hidden 的 flex item,会把绝对定位(露在盒外)的按钮裁掉,所以它的 overflow

用内联样式临时放开;卸载时连同 position:relative 和标记属性一起还原,不在别人的 DOM 里留痕迹

  • 按钮 28px,和原生 _searchButton 同尺寸,四个按钮共用一套节奏;间距不是硬编码的 4px,而是量出来的:

先测出这一行里「放大镜 → 下一个原生图标」的实际距离,再用「百分比定位 + 测量校正」的闭环把按钮放到同样距离。 transform 祖先会劫持绝对定位的包含块(纯百分比会算错),主题改了这一行的 gap 也能自动跟上

  • 用无障碍名称(搜索会话 / Search sessions)定位,而不是带构建哈希的类名

(另加 *_searchButton 与输入框 placeholder 两级兜底)

  • 点击有两路触发:React 的 onClick,外加一个挂在 document 上的捕获阶段 click 监听

(只认落在自己 host 里的点击)。按钮长在别人的 DOM、由本插件自己的 React root 渲染, 万一合成事件链路被别的 root 或外层处理器影响,捕获阶段的监听仍能拿到手势;重复打开是幂等的。 按钮与 host 还带 -webkit-app-region:no-drag:桌面壳若把窗口顶部做成拖拽区,拖拽区会吞掉点击

  • 面板开合状态放在插件自己的 store 里(不在某个组件的 state 里),所以会话列表重渲染那一行、

甚至重建 host,弹窗都不会跟着消失

  • MutationObserver 在会话列表重渲染后重新定位,复用同一个 host,不会重复插入
  • 入口只有头部按钮一个:侧边栏底部那个入口会显得多余(影响美观),因此它只在注入失败或定位不到目标时

才作为兜底出现;头部按钮可用时它不渲染。两者共用同一个面板;面板通过 portal 渲染到 body, 所以按钮位置与弹窗互不影响

批量操作

三个动作都逐 id 执行并逐个报告失败原因:

操作 · 说明 · 可靠性 删除 · 永久删除选中的多个对话及其全部记录。点「删除」后按钮自身会亮起红框、面板里出现确认框,二次确认后才调用服务 · 官方 session.delete 契约 归档 / 取消归档 · 一个按钮两个方向:选中项里全是已归档时显示「取消归档」,否则显示「归档」。走工作区控制器的 archiveSession / unarchiveSession,所以原生列表的归档分组也会跟着变;正在运行的对话会被服务拒绝,那一行会以失败原因列出来 · 官方 workspaces 服务(该服务不存在时退回插件本地标记,状态行会写明「仅本插件内隐藏」) 置顶 / 取消置顶 · 同样一个按钮两个方向,走 pinSession / unpinSession:置顶后原生列表把该行移进置顶分组,面板里也排到最前 · 官方 workspaces 服务;服务不存在时明确报错说没有置顶接口,不会假装成功

0.2.0 移除了 Fork / 导出 MD / 导出 JSON / 汇总给 AI / 置顶 / 标为未读(git revert 对应提交可整块找回);
0.3.0 把置顶以真实服务调用的形式加回来,并把归档从「插件本地标记」升级成工作区控制器的命令。

两个真实的教训(都来自被移除的圆圈模式)

模式类名不能复用按钮类名。 当年给圆圈模式在 body 上加的类名复用了入口按钮自己的 .dsh-msel-inline:那个类把元素样式化成 28px 的 inline-flex 按钮,body 一旦戴上它,整个窗口就按一个 按钮来排版(无头浏览器里表现为整页宽 112px、所有坐标变成负数)。现在 tests/browser 会断言 body 上 没有那个按钮类 —— 这类"只在一个选择器里出现"的 bug,值得有一条断言守着。

缺失的 token 不能兜底成浅色。 0.2 不提供 --dsw-alias-bg-elevated,而当时浮起的操作栏写的是 var(--dsw-alias-bg-elevated,#f7f7f8) —— 深色主题下文字是 --dsw-alias-label-primary(浅色), 底色却落到浅灰兜底,于是整条操作栏的字看不见(选中才看得见)。现在面板/自带对话框的浮层底色走 bg-elevated → bg-layer-2 → bg-base 这条链(0.2 有后面两个),文字与描边一律 ,inherit / ,currentColor 兜底。

关于「归档」与「置顶」

两者都是工作区控制器的命令,不是会话服务上的方法:ctx.get("workspaces") 的 archiveSession / unarchiveSession / pinSession / unpinSession,以及它的快照 { pinnedSessionIds, archivedSessionIds }(面板的「已置顶/已归档」状态、按钮的双向文字都读它)。

  • 这个服务不进 `inject`:老版本可能没有它,而 inject 里挂一个永远不来的服务会让整个插件不激活

(入口按钮都不会出现)。所以每次都 ctx.get("workspaces") 惰性取一次,取不到就走兜底

  • 归档在服务缺失时仍可用(插件本地持久化标记),状态行会写清「仅本插件内隐藏」;

置顶没有本地兜底 —— 一个只在本插件里生效的「置顶」是假象,所以那种情况下直接报错说明

  • 置顶与归档互斥(host 侧规则):把一个已归档的对话置顶会被拒绝,失败原因会逐行列出

会话列表数据的形状

ctx.sessions.list.getSnapshot() 给的是归一化快照:{ ids, byId, current, phase, … } (工作区插件、dsh-better-sidebar 等都是在读 byId[sessionId])。同一服务上还存在一个拍平成显示顺序的 { items, … } 投影。本插件两种都认(snapshotItems()):只读 items 会让侧边栏满屏对话、 而面板里一条都列不出来。

`byId` 的值里没有 `sessionId`:控制器把 id 存成 id,标题存成派生出来的 displayTitle (title → cwd → id 依次兜底)。面板在 normalizeSummary() 里补齐这两个字段,并且跳过没有可用 id 的行 —— 否则每一行都会落在同一个 undefined 键上,表现就是「选一行 = 全选」,批量动作也会指向空 id。 测试夹具直接使用控制器的真实形状,就是为了让这类 bug 无法回流。

子智能体会话不是对话

DSH 的会话树里还有一类 subagent 会话:日志头 origin: "subagent"、parentSession 指向父对话, 目录名与 id 都是裸 UUID(没有 session- 前缀)。原生侧边栏从不把它们渲染成行,只在父行上显示 「N 个子智能体运行中」。0.4.1 起面板同样跳过它们(origin === "subagent")——在此之前它们会出现在面板里, 而「全选」或点工作区标题都会把裸 UUID 带进删除批次,host 路由按 session-… 校验 id, 于是整批返回 HTTP 400: invalid session id,一条都删不掉。删除路由现在两种 id 拼写都接受 (session- 与裸 ``),但子智能体会话依然不在面板里列出:要清理它们的日志,属于 文件系统层面的维护,不是这个面板的职责。

自己的翻译器

面板文案不依赖 slot 传进来的 t:那个 t 只在 footer 那个 slot 渲染之后才会存在,而应用可能根本不渲染它 (本机上就没有)。等它的结果就是满屏 panel.title、action.delete 这样的原始键。因此 bundle 自带 makeTranslator(),用 ctx.locale 的当前语言在自己的 zh/en 字典里取词,{name} 占位符自己插值。

面板崩了会说出来,而不是静默消失

面板包在一个 error boundary 里。没有它,React 在渲染报错时会卸载整棵树 —— 包括打开弹窗的那个按钮, 用户看到的就是「点了没反应」。现在弹窗会留着,并把失败原因显示成一行红字。

按工作区分组(以及它为什么改动了「顺序」)

分组不是单纯加个标题:它重排了行的显示顺序,而 Shift+点击的范围选择、全选/反选走的都是 「显示顺序」。所以 visibleRows() 先按时间排出基础顺序,groupRows() 只做稳定归组 (组内保持基础顺序,组按组内最新时间排,无工作目录的组排最后),面板再把 flattenGroups() 的结果当作唯一的顺序来源 —— 否则 Shift+点击会扫过用户在两击之间根本看不到的行。 行的 cwd 默认不再重复显示(标题已经写了),关掉分组后恢复显示。

诊断环(排查用)

插件在 sessionStorage 的 dsh.session.multiselect.diag 里维护一个最多 60 条的事件环: apply build=N chrome=… marks=… / marks service subscribed / placed / not placed / click document / click react(或 click react skipped,说明捕获阶段那一路已经处理过这个手势)/ panel open / panel rendered rows=N / panel error …,以及每个批量动作的结果: delete service … / delete start n=… / delete done ok=… failed=… / archive … service=ok|missing / pin … service=ok|missing / mark failed / action empty / action error …。 它只写入本标签页的会话存储、写失败也绝不影响功能,用来回答「点击到底有没有到达按钮、面板有没有渲染、 删除/归档到底报的什么错」这类只能靠现场才能判断的问题。 (npm run diag 可以把这个环读出来:它会按 UTF-16LE 手工解码 leveldb 文件,原因见脚本注释。)

跨版本兼容

DSH 升级会改三类东西,本插件对它们的处理方式不同:

会变的东西 · 例子 · 本插件的做法 结构性契约 · slots / sessions / locale 服务、dsh.client 清单、/plugins//client.js 路由 · 直接依赖,缺失就明确报错(面板会显示原因,不会静默消失) 装饰性契约 · 图标导出名(0.10 把 IconSearchOutline16 改成 IconSearchOutlineRegular 并删掉旧名)、Button 的 variant(0.10 的 solid 变成 primary)、带哈希的类名 · 多候选名 + 自带兜底:图标按候选名找,找不到就用插件内联的同一份 16px path 自己画;删除按钮的红色用插件自己的 class,不看 variant 平台包是否可 require · 0.10 的客户端模块表预置了 dsh-client-store / dsh-client-ui-primitives 等;0.2 只剩 React、Cordis 与静态库,官方编写指南直接写着「不要 require 任何 Harness 客户端包」 · 一律 optional:每个平台包都 try/catch 取,取不到就用插件自带的同签名实现(对话框 / 按钮 / 输入框 / 提示 / 快照 store + localStorage 持久化)

0.10 那次的真实教训:旧版本把图标写进「必需导出」清单,于是一个图标改名 = 插件挂不上 (apply 抛错,没有按钮、没有面板、也没有提示)。

0.2 那次更彻底:客户端半边整个没跑起来(会话存储里连一条 apply 都没有),因为 bundle 在 require("@deepseek-ai/dsh-client-ui-primitives") 那一行就抛了。现在这些 require 都包在 optionalRequire() 里,并且每一处用法都有本地实现兜底 —— 代价是少了原生的控件外观, 换来的是"任何版本的模块表都不影响功能"。诊断环的 apply build=N chrome=primitives|local 就是答案。

顺带修掉的两个坑(都是被测试逼出来的,值得记住):

  • `React.forwardRef` 返回的是对象,不是函数:原先用 typeof primitives.Button === "function"