DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

qgx1992 /

qgx1992/dsh-ui-tools

Verified

DSH web 插件:模型选择双按钮(供应商+模型两级联动,含推理等级调节)+ 侧边栏工作区折叠/展开

★ 1 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHub
READMESource: main@340b01b6

dsh-ui-tools

DSH Web 插件:七个 UI 工具合并成一个包,只动对应控件,不改 DSH 任何一行源码。

功能一:模型选择双按钮(原 dsh-model-select-style)

把输入框官方的「模型选择」控件替换为两个独立按钮,两级联动:

  1. 「供应商」按钮 —— 点击弹出供应商列表(如 DeepSeek / SiliconFlow / OpenRouter);
  2. 选中某供应商后,「模型」按钮亮起;
  3. 「模型」按钮 —— 点击只列出当前所选供应商的模型,点选即切换;
  4. 模型支持推理时,模型按钮显示 模型名 · 推理等级(如 deepseek-chat · 高),面板内带「推理等级」区可调节思考强度。

选择逻辑完全复用官方组件(同一份模型目录、同一套选择提交、错误 Toast),只是把入口从单个按钮拆成两个。

功能二:侧边栏工作区折叠/展开(原 dsh-workspace-collapse)

在侧边栏工作区列表下方渲染「折叠全部 / 展开全部」工具条,一键折叠/展开所有工作区分组(纯 slot 渲染 + CSS 对齐,不搬动 DOM)。

功能三:会话标题旁工作区徽章(v0.3.0 新增)

在会话页头部标题右侧显示一个圆角徽章,标出当前会话所属工作区名(与侧边栏分组标题同名),一眼看出当前对话跑在哪个工作区:

  • 数据:标准 kit 自带 useWorkspaces,从 workspaces.items 按 sessionIds 反查当前会话所属工作区,取 title;
  • 未归入任何工作区(未分组 / 空白会话)时徽章不渲染,不占位;
  • 徽章显示工作区显示名(改名后同步),title/aria-label 带可读文案。

实现原理:本功能注册进官方纯增量 list 槽 conversation.session.header.actions(session 作用域)——它是头部标题 cluster 内的动作行,位于面包屑标题右侧,正好是「标题旁」。list 槽是加性的(绝不替换标题、无占用冲突,当前该槽无其他插件占用),我们用负数 order: -100 让徽章排在其它交互动作之前、紧贴标题。纯 slot 渲染,不搬 DOM、不改源码。

功能四:插件设置页「DSH UI 工具」(v0.4.0 新增;v0.4.1 移除快捷命令条)

在官方设置中心新增一个「DSH UI 工具」页面,集中控制本包功能的偏好:

  • 布局偏好:工作区徽章开关、启动时默认折叠所有工作区分组、显示输出速度计(tok/s)(v0.4.29 起该速度计为可拖动胶囊悬浮窗,位置自由、双击复位);
  • 偏好经 localStorage 持久化(dsh-ui-tools:prefs:v1),刷新/重启后保持;
  • 能力感知(v0.4.2):速度计依赖的内核服务不可用时(0.1.1-rc.x),那一行开关灰显并在标签下提示「需内核 0.1.2-alpha.1+」,不会出现一个点了没用的开关。可用性由 alphaFeatures 子 fiber 实际激活来置位(比在 apply 期一次性探测更可靠:官方 provider 可能比本插件晚激活)。

存储说明:官方 settings 命名空间需要 host 侧 ctx.settings.register(ns, schema) 声明 schema 才能持久化(参考 ui-theme/src/index.ts);本插件刻意保持纯浏览器(lib/index.js 空入口、不引入 host 依赖与 schema 校验风险),故沿用浏览器插件社区惯例(同款 dsh-better-sidebar)的 localStorage。偏好仅当前浏览器生效;如需跨端/跨浏览器同步,可后续增加 host 半部迁移到 settings 文档。

实现原理:注册进官方 settings.section(root 作用域 list 槽),id: "dsh-ui-tools"、order: 100、label 走双语文案,设置导航自动出现该页。页面组件订阅同一个 localStorage 偏好仓库(useSyncExternalStore),所有开关写入即落盘;既有功能在 apply() 里注入同一偏好仓库:徽章/速度计组件渲染时读开关,侧边栏折叠条在插件加载时按「默认折叠」偏好执行一次。

功能五:输出速度计 tok/s(v0.4.6 新增;v0.4.25 迁到输入框下方统计行;v0.4.27 修结算后消失;v0.4.29 改为可拖动胶囊悬浮窗)

在可随意拖动的胶囊悬浮窗里显示输出速度读数(v0.4.29 起;此前 v0.4.25–v0.4.28 是输入框下方统计行里的固定 pill):

  • 回合结束后(精确值):显示 ⚡42 tok/s —— 最近一回合的输出速度,无需点开「本轮用时和速度」弹窗即可看到;
  • 生成中(估算值):显示 ⚡≈ 25 tok/s · 生成中估算,随流式增量刷新。

与官方底部统计的关系:官方统计行里的 TPS 是全会话口径(全程 decodeTokens ÷ decodeMs),本胶囊是本回合 / 最近一回合口径 —— 两者互补。胶囊是新增控件(注册 id: "ui-tools-token-speed"、order: 2,排在官方 activity(0) / usage(1) 之后,不替换、不覆盖官方任何控件),但 v0.4.29 起它的显示位置由用户自己拖动决定(position:fixed 浮层,挂在 document.body 上,默认右下角),不再绑死在统计行右侧。

拖动与位置记忆:在胶囊上按住拖动即可移动到任意位置;位置写入 localStorage(dsh-ui-tools:token-speed:pos:v1),刷新/重启后保持;拖出视口会自动夹回可视区(留边距);双击复位回默认右下角位置。

数值口径(精确值)与内核「本轮用时和速度」弹窗里的 TPS 同源:先定位回合(生成中以外取最大回合,tspLastTurnSpeed;按 messageId 的 tspTurnSpeed 仍保留以兼容既有断言),再折叠该回合全部已固化 assistant 节点。折叠是两级口径(v0.4.27 起):

  1. 解码口径(首选,与内核 deriveTurnMetrics / assistantStepReading 完全一致):只累计同时带 timing.firstTokenTime 与 usage.outputTokens 的 step,按 ΣoutputTokens ÷ Σ(completedTime − firstTokenTime) 求值;只要该回合存在这类 step,就只用这批算,不让墙钟 step 污染分母。
  2. 整步墙钟口径(回退):真机实测发现内核 legacy 快照里的 assistant 节点 timing.firstTokenTime 恒为 null(连刚流式生成完的回合也是 —— 内核只在 durable log / sessionStats projection 里保留首字时间,客户端窗口的 settled 节点拿不到;官方 ActivityPill 因此走 useProjection("sessionStats") 而非 deriveStats(nodes))。故所有 step 都缺 firstTokenTime 时,回退到 ΣoutputTokens ÷ Σ(completedTime − stepStartTime)(含首字延迟,数值略低于纯解码速度,tooltip 已注明)。这是历史 / 已固化回合唯一能算的速度,保证结算后读数不消失。

两种口径都算不出(无合法 step、时长为 0)时不显示,绝不猜一个假值。

为什么生成中只能给估算值:内核在流式期间不上报 token 数 —— assistant/live-chunk 里的 usage chunk 被 publication: "none" 抑制,不推给客户端。所以生成中改为读 chat 快照里的 legacy.partial(内核在 assistant-step 运行期持续发布的 {turn, step, blocks}),按已输出字符折算(CJK 1 字符 ≈ 1 token,其余 4 字符 ≈ 1 token;tspBlocksEffort 统计 text / reasoning 正文与 tool-call 的 name+argsRaw),分母只计相邻增量之间的真实出字时间(间隔超过 TSP_LIVE_GAP_CAP_MS=1000ms 的停顿视为等工具/等请求、不计入),并始终带「≈」与「生成中估算」标注,与精确值明确区分;回合一旦结束(partial 消失)即让位给精确值,不重复显示。有效出字时间 < 250ms 或折算 token 为 0 时不显示估算,避免爆表读数。

实现原理(单一通道,不改源码):注册进官方 list 槽 conversation.composer.dock(session 作用域)—— 保留这个注册点是因为它是拿到 session 作用域标准 props(useChat)的唯一可靠通道(内核槽框架会把 sessionId 连同 useChat 等作为标准 props 注入该槽组件;官方 ActivityPill 就是用 useChat(s => s.legacy.nodes))。组件 TokenSpeedDockPill 因此直接读标准 props 的 useChat 拿 chat 快照(legacy.nodes 算精确值、legacy.partial 算生成中估算),再用 require("react-dom").createPortal 把 [data-tsp-capsule] 渲染到 document.body 的 position:fixed 浮层里 —— 不再占用统计行的版面,而是可自由拖动的悬浮胶囊。为什么必须 portal 到 body:slot 祖先链常带 overflow:hidden / transform,会裁剪浮层,只有挂到 body 才能真自由。⚠️ 不能写成 inject: (sessionId) => … —— 槽的 inject 回调不接收 sessionId(v0.4.25 踩过此坑:拿到 undefined → 空快照 → 永远不渲染),只能返回 { prefs } 这类注入面。react-dom 属插件模块表允许的依赖(本机已装插件普遍 require("react-dom")),拿不到时组件退化为内联 fixed 元素(仍可拖动)。

v0.4.29 迁移说明:v0.4.25–v0.4.28 的形态是「统计行里与官方 activity / usage 同行的固定 pill」,位置由内核布局决定、用户改不了。v0.4.29 改为可拖动胶囊悬浮窗([data-tsp-capsule] 外壳 + [data-tsp-pill] 读数,createPortal 到 document.body),新增 tspReadPos / tspWritePos / tspClearPos / tspClampPos / tspDefaultPos 几何工具与 onPointerDown/Move/Up + onDoubleClick 拖动交互;compat-check 相应补「portal 到 body」「fixed 浮层」「拖动写 localStorage」「夹取/默认位/坏数据回退」断言,并把 require 桩放行 "react-dom";live-smoke 2c 从「与统计行同行且在右侧」改为「脱离统计行、body 下的 fixed 浮层、真实鼠标拖拽后坐标变化并落盘」。

v0.4.25 迁移说明:此前(v0.4.6–v0.4.24)精确 pill 挂在 conversation.chat.assistant-actions(order: -10,紧贴「用时」左侧),生成中估算条走 Definition 通道自建 token-speed-live chat 节点 + conversation.chat.node 渲染器。后者因内核「已物化节点被撤回即抛错、整条 chat 快照链停更」(v0.4.7 事故)而成为长期风险面,且 Definition 节点不进 legacy 快照、dock 组件本就读不到。v0.4.25 把两处合并为 dock 里的单条 pill:删除 Definition 通道、conversation.chat.node 渲染器与旧 assistant-actions 注册,生成中改由 legacy.partial 折算 —— 风险面归零、位置也正落在用户要的「几轮几步旁边」。compat-check 相应新增「源码不得再出现 tspLiveDefinition / token-speed-live / tspChunkEffort / data-tsp-live」四条反向钉。

开关:设置页「显示输出速度计(tok/s)——可拖动的胶囊悬浮窗,双击复位」,默认开;偏好字段 tokenSpeedEnabled(同一个 localStorage key)。旧内核上该开关灰显。

历史回合的读数口径(v0.4.27 修正):早期版本认为「firstTokenTime 不进持久化日志、重载后历史回合没有读数」。真机实测否定了这一假设:legacy 快照里的 firstTokenTime 在真机上恒为 null(与是否重载无关),所以只靠解码口径会让每个回合结算后都失去读数(生成中能看到、一结束就消失)。v0.4.27 起改为「解码优先、墙钟回退」两级口径:历史回合显示的是整步墙钟速度(含首字延迟,通常略低于纯解码速度),而不是不显示。

功能六:会话 / 工作区列表右键菜单(v0.4.13 新增)

在侧边栏的会话行与工作区行上按右键,弹出上下文操作菜单。实现参考社区插件 @baihejiangnan/dsh-session-context-menu 的能力面,但代码完全自写——不 import、不 require、不引用对方的包名与全局注册表,也不依赖对方的事件名(已由断言钉死)。

会话行右键(7 项,v0.4.20 新增「删除会话…」):

菜单项 行为
重命名 走官方重命名入口(点开官方行内编辑,不自造提交逻辑)
归档会话 workspaces.archiveSession(id)
创建会话分支 sessions.fork({ sessionId, increaseTitle: true })
删除会话… 二次确认(官方 Modal 形态弹窗)→ 宿主半边删除落盘日志目录 → stopSessionActivity + clearMain + sessions.refresh()(见下节)
复制会话 ID 写剪贴板
复制工作目录 写 session.cwd
刷新页面 ctxReload()(location.reload() + 兜底)

工作区行右键(5 项):新建会话(uiWorkspace.startSession(id))、复制工作区路径(workspace.path → 剪贴板)、归档工作区全部会话(逐个 archiveSession)、删除工作区全部会话(二次确认后真删磁盘,见下节)、刷新页面。

⚠ 服务契约(v0.4.13 首发踩过的坑):新建会话 只能走官方 uiWorkspace.startSession(id) —— sessions 上没有 startSession;切会话同理只能走 uiWorkspace.openSession(id),sessions 上那个 open 不收会话 id。首发版误写成 workspaces.startSession / sessions.open,异常被 ctxToast 吞掉,用户看到的就是「点了没反应」。现在由 tools/check-context-menu.mjs 第 14 节的真实方法表逐条钉死。

选中文本时右键(2 项):复制所选文本、发送所选文本到输入框(填入输入框,不自动发送)。

删除会话(v0.4.20 新增;v0.4.21 改走 connection.fetch)

「归档会话」只是把 id 记进 archivedSessionIds,磁盘上的日志一个字节都不动;要真正腾空间、把会话从磁盘上抹掉,需要「删除会话」。

内核没有删除会话的 RPC。 逐个核过 session/*(20 个方法)与 workspace/*(12 个方法),既无 delete/remove,也没有任何渲染进程可用的文件操作通道;workspace/delete 的描述是「移除一个工作区登记,保留文件与会话」。社区插件 dsh-plugin-session-delete 的 README 结论相同:"Core has no deleteSession RPC, so this plugin adds a small host-side HTTP route that performs the durable deletion"。

本插件的做法:宿主半边 + 客户端半边,走内核 connection 服务提供的 Fetch 路由。

半边 文件 做什么
宿主 lib/index.js 入口 inject = [];ctx.inject(["connection"], scope => scope.connection.fetch.register({ path: "/api/ui-tools/sessionDelete", methods: ["POST"], requestBody: "buffered", fetch })):核 owner 身份 → 尽力停掉 agent 与工作区活动 → 从工作区登记里摘除 → sessionPersistence.locate({id, cwd}).path 定位日志 → 递归删除该会话目录
客户端 lib/client.js 菜单项 → ctxConfirmDialog() 二次确认(官方 Modal 形态弹窗)→ fetch(ctxDeleteUrl(), {method:"POST", credentials:"same-origin", body: JSON.stringify({sessionId, cwd})}) → ctxReleaseSession() 收尾

几个必须说清的设计取舍:

  • 不用裸 webServer.register() 路由。 社区插件那条 POST /api/session-delete 完全不鉴权(webServer 的 exact 路由在分发时直接命中 handler,不经过 frontend-static 也不经过 authorizeIndex),任意网页都能对它发一条跨站表单请求把用户磁盘上的会话删掉。本插件改走 connection.fetch.register():请求先经过 connection 的 /api 共享处理器 —— Host/Origin 栅栏(403)→ 浏览器 cookie 鉴权(401)→ 得到 principal.kind === "owner" 的 peer,handler 里再显式核一遍 peer.principal.kind。安全边界在内核里,不在插件里。
  • connection 只加在宿主半边的子 fiber,客户端仍走 ctx.get() 可选获取。 入口 inject 一个字节没改(仍是 slots / modelDirectories / sessions / locale / workspaces),所以旧内核上功能六照常可用,只是「删除会话」会给出明确原因「当前内核未提供插件通道(connection),无法删除会话;请升级内核,或改用『归档会话』」——不做功能级静默隐藏,也不拖垮整条 fiber(R3)。
  • sessionPersistence / sessionQuery / agents / workspaceRegistry 全是可选服务,逐个 ctx.get() + 能力探测;缺席时只让这条端点报错,不影响插件启动。
  • 删完不调 workspaces.archiveSession()。 archiveSession 第一步是 await this.sessionKnown(id),文件已删 → 查不到 → 抛 WorkspaceUnknownSessionError。删完之后的收尾是三步:workspaces.stopSessionActivity(id)(停宿主侧活动)、若是当前会话则 uiWorkspace.clearMain()(释放主视图引用)、sessions.refresh()(重拉列表基线,行才会消失)。
  • 二次确认是硬门槛。 菜单项点下去不会立刻发请求,而是弹出一个与客户端同款的 Modal 弹窗(v0.4.24 起:遮罩 + 居中卡片 + 右下角横排按钮,见下节),点名要删哪个会话、写明不可撤销,危险按钮在最右、默认焦点落在「取消」,「取消」/ Esc / 关闭键 / 点遮罩都能取消。断言钉死「确认前发出的删除请求数 = 0」。
  • 失败不装作成功。 服务端拒绝(not found / forbidden / 落盘定位失败)时把真实原因显示给用户,且不摘掉列表行——避免「看起来删了其实还在」。通道缺席时同样给可读提示,不泄漏内部 TypeError。
  • 宿主路由不存在时说「请重启 DSH」,不说 HTTP 状态码。 宿主进程只在启动时装配一次 profile(dsh-desktop-host 的 main() 里跑一遍 runProfile),没有热重载。装完插件不重启,/api/ui-tools/sessionDelete 这条路由根本不存在:/api 前缀路由在、精确子路由不在,于是内核回 404(若整条 /api 都没挂则掉进 frontend-static 兜底的 405)。所以客户端把这两种非业务状态码统一识别成 no-host,提示「插件宿主半边未加载(宿主进程只在启动时装配插件)……请重启 DSH 后重试」。否则用户看到的是一句没头没脑的 删除失败:405 method not allowed,根本不知道该去重启。

⚠ 已知边界:正在生成中的会话被删后,宿主 agent 可能在收尾时重建日志(插件已尽力先 cancel,但这是尽力而为);工作区里残留的 id 由内核下次对账时自愈(groupByWorkspace 对 list.byId[id] === undefined 直接跳过,不会渲染死账)。

✅ v0.4.23 起会级联删除子会话分支(见下节「子会话级联」),并且删除过程有逐条进度。

⚠ 装完必须重启 DSH(不是刷新页面)。宿主半边是新写进 lib/index.js 的,只同步文件不重启,菜单会出现但点了只会提示「宿主半边未加载」。

删除工作区全部会话(v0.4.22 新增)

右键工作区行 → 「删除工作区全部会话…」。它是「归档工作区全部会话」的彻底版:归档只记账,这里真删磁盘。

  • 一次请求删一批,不循环调单删端点。 宿主新增精确路由 /api/ui-tools/sessionsDelete,与单会话端点共用同一个 registerDeleteRoute(path) 工厂(一次删一个 = 只含一项的批量,两条路径的行为不可能分家)。之所以不在客户端 for 循环调单删端点:几十个会话就是几十个 HTTP 往返,而且中途失败时说不清「停在哪一个」,用户只能看到列表莫名其妙少了一半。
  • 串行删,不并发。 每个会话的删除都要先停 agent、再动工作区记账、最后递归删目录,并发会互相踩;串行才能给出可解释的逐条结果。
  • 失败不吞。 返回 {ok:false, error:{code:"partial", details:{deleted, failed}}},客户端拼成「已删除 N 个,M 个失败:<真实原因>」;全失败时则直接透出第一条失败的业务码与原因。绝不因为「一条失败」就把整批显示成「删除失败」而让人以为一个都没删掉。
  • 载荷同时兼容两种形态。 新的批量端点和旧的单会话端点都调 readDeleteBatchPayload():先看 sessions 数组,没有才退回顶层 {sessionId, cwd}。这样只重启了一半(包更新了但进程还是旧的)也不会打通了一个、坏了一个。条目上限 200 条,超了直接 400 而不是让宿主跑十分钟。
  • 确认弹窗点名数量。 「删除「项目一」的全部 5 个会话?」+ 「这些会话记录会从磁盘上永久删除,无法撤销。工作目录里的文件不受影响。」——明确区分「删会话记录」与「删工作目录文件」,避免用户以为会把代码目录一起端掉。id 列表为空时点下去只给一句提示,不弹确认弹窗。

⚠ 与单会话删除同一条已知边界:正在生成中的会话、以及会话分支的子会话不会被级联处理。

子会话级联删除(v0.4.23 新增)

「创建会话分支」建出来的子会话,单独删父会话会留下孤儿分支——它们仍在列表里、仍占磁盘,但已经失去了上游上下文。v0.4.23 起删父会话时会连整棵子树一起删。

  • 血缘链完全在客户端就能算出来,不需要新 RPC。 逐行核过内核:dsh-session:2017 fork 时写入 header.parentSession → dsh-api-session-controller:2129 投影成条目字段 parentSessionId → 客户端 service.js:512 再投影成摘要的 parentId。也就是说 sessions.list.getSnapshot().items 里每条摘要本来就带 parentId,拼成 parentId → [childId] 就能 BFS 出整棵子树。新增的纯函数 ctxDescendantSessionIds(sessions, parentIds) 就干这件事:多根、多兄弟、血缘成环(脏数据)不死循环、快照读不到时返回 [](级联是附加能力,绝不能因为它而弄坏删除本身)。
  • 确认弹窗必须说清楚要多删几个。 有子会话时正文换成「该会话下还有 N 个子会话分支,会一并删除。」——在用户点『永久删除』之前就把级联的代价摊开,而不是删完再告诉人家。没有子会话时逐字沿用旧文案,不吓唬人。
  • 有子会话就走批量/流式端点,没有就走单会话端点。 单会话端点一次只能删一个,级联必须多次往返;所以有子树时改调 /api/ui-tools/sessionsDeleteStream。没有子树时载荷逐字不变(仍只发 {sessionId, cwd}),v0.4.20 的老契约一个字没改。
  • 工作区批量删除也会顺手把「跨工作区的子会话」带上。 一个从 w1 的会话 fork 出来、后来被归到 w2 的子会话,在 w1 的 sessionIds 里是看不到的——只按 sessionIds 删就会漏。所以工作区删除同样先做一次级联展开再取并集,确认弹窗上的数字是展开后的真实条数。
  • origin 字段不参与判据。 内核 dsh-session:1053 里 origin 只允许 "subagent",但用户手动 fork 出来的分支 origin 是空的;只认 origin 会漏掉手动分支,故判据只用 parentId。

批量删除进度(v0.4.23 新增)

删 20 个会话在真机上要好几秒(每个都要先停 agent、再动工作区记账、最后递归删目录)。这期间如果界面只有一句「正在删除…」,用户会以为卡死并反复点击。

  • 宿主逐条推进度,客户端逐条刷提示。 新路由 /api/ui-tools/sessionsDeleteStream 返回 NDJSON(application/x-ndjson,一行一个 JSON),deleteSessions() 每删完一个就 onProgress({done, total, sessionId, ok, code?, message?}),宿主随即写出 {"type":"progress", …};全部结束后写 {"type":"done", …}(内容是和非流式端点完全一致的 deleteEnvelope())。
  • 为什么是 NDJSON 而不是 SSE。 EventSource 只能发 GET、带不了请求体,而这条端点必须收 sessions 数组;所以用 fetch + ReadableStream 手切行。响应头带 x-accel-buffering: no,防止中间层把流攒起来一次性吐出(那样进度就成了假的)。
  • 进度是真的数出来的,不是按时间估的。 断言直接钉住「3 个会话 → 恰好 3 次进度回调」且 done 逐次为 1,2,3,并附带静态钉「宿主 deleteSessions 里必须有 onProgress({done: 调用」+ 文本变异负向对照。
  • 老宿主自动降级。 如果这个进程还是 v0.4.22 的旧半边(没有流式路由,返回 404/405),客户端会立刻退回去打一次性批量端点,删除照常成功、只是没有进度。断言把这条降级路径单独跑了一遍。
  • 客户端断开不等于删除要停。 流写到一半用户关标签页,controller.enqueue 会抛错;宿主把它吞掉并继续把剩余的删完——中止到一半留下的半删状态比多删几秒更难收拾。

删除确认弹窗(v0.4.24 重做)

v0.4.20 ~ v0.4.23 的二次确认是「菜单原地换成一条确认条」——样式复用右键菜单、按钮竖排成菜单项。真机用下来两个问题:

  1. 观感一眼外挂。 内核自己的确认框(「停止并归档此会话?」「删除工作区」)用的是同一个 Modal primitive:遮罩 + 居中卡片 + 标题 + 正文 + 右下角横排按钮。插件的确认条长得像菜单,跟它并排看就是两套东西。
  2. 位置偏右。 旧代码把 window.innerWidth / 2 当成左上角传给 ctxPositionMenu(root, x, y),而那个函数收的正是左上角坐标(left = max(6, min(x, innerWidth - width - 6)))——于是整个框从屏幕正中开始往右铺,再被视口右边夹住,看起来就是贴着右半边。

重做后的做法:

  • 位置完全交给 CSS。 根节点 position: fixed; inset: 0 + display: flex; align-items: center; justify-content: center,卡片 width: min(380px, 100%)。ctxConfirmDialog 里再也没有 ctxPositionMenu / innerWidth —— 这两条都有静态钉 + 文本变异负向对照(把坐标计算塞回去必须报红)。
  • 结构、尺寸、间距照官方 Modal 抄。 类名 .dsh-ctx-confirm__mask / __dialog / __header / __title / __close / __content / __body / __footer,头部 padding: 22px 14px 12px 24px、标题 16px/24px 中等字重、正文 padding: 0 24px + 14px/22px、页脚右对齐 gap: 8px、圆角 --dsw-radius-panel、阴影 --dsw-elevation-prominent、底色 --dsw-alias-bg-layer-2 —— 全部取自 dsh-web-frontend 里那组 _root_ / _mask_ / _dialog_ / _header_ / _title_ / _content_ / _description_ / _footer_ 规则,所以深浅主题都跟客户端自洽。
  • 颜色一律走 --dsw-* token,缺了才掉回兜底色。 危险按钮用 --dsw-alias-state-error-primary —— 与官方删除确认里的 deleteAction 是同一个 token。
  • 按钮照官方 Button 的 md + outline 规格。 36px 高、0.5px solid var(--dsw-alias-border-l3) 描边、14px/22px 字、圆角 --dsw-radius-md;顺序与官方 footer 一致:取消在左、危险动作在最右。
  • 三种关窗方式都算取消,且一个请求都不发:点「取消」、按 Esc、点右上角关闭键、点遮罩空白处。点卡片正文不会误关(事件挂在遮罩上而不是根节点上,卡片不是遮罩的子节点)——否则用户想选文字复制都会把弹窗点没。
  • 断言从 196 条加到 228 条:新增 §16 整节,含弹窗语义(role="alertdialog" 在卡片上而非根上)、遮罩存在、不再复用 dsh-ctx-menu、按钮顺序(取消在左 + 危险在最右)、关闭键/遮罩/卡片正文三种点击行为、以及「弹窗里用到的每个类名都必须在 CTX_CSS 里有定义」(防止改了 JS 忘了改 CSS 导致掉回无样式观感)。负向对照 2 条:抽掉 justify-content: center 后居中钉必红;把坐标计算塞回 ctxConfirmDialog 后「不再算坐标」两条钉必红。

⚠ 为什么用 connection.fetch 而不是 connection.rpc

v0.4.20 首发用的是 connection.rpc.handle("/ui-tools", handler) + 客户端 connection.rpc.call(...)。真机上必然失败,报:

register-failed: cannot get property "webServer" without inject
    at Fiber.<anonymous> (…/dsh-client-connection/lib/index.js:871:35)
    at Object.handle (…/dsh-client-connection/lib/index.js:727:39)

原因在 dsh-client-connection 自己身上:

// :724  rpc getter —— owner 取的是 Connection 服务自己的 ctx
get rpc() { const owner = this.ctx; return { handle: (channel, handler) => this.register(owner, channel, handler) }; }
// :871  register 的最后一行 —— 拿 owner.webServer 去挂路由
return owner.effect(() => owner.webServer.register(route), `client-connection: ${channel} rpc channel`);
// :1013 而该插件自己的入口 inject 里没有 webServer
const inject = ["credentials"];

于是任何第三方插件调 rpc.handle 都会在 owner.webServer 上抛「without inject」,rpc 通道这条路对插件彻底关闭。(宿主侧 webServer 和这完全是两回事:ctx.inject(["webServer"], …) 在自己的子 fiber 里拿是拿得到的,但那条路由不经过鉴权,见上一节。)

connection.fetch.register(route) 走的是另一条链(registerFetchRoute,:840-854):它只往内部的 fetchRoutes Map 里塞一条精确路径,完全不碰 webServer,返回 disposer,天然满足 R3/拆卸纪律。约束有三条:

  1. 路径必须以 /api/ 开头(assertFetchRoute → endpointFromPath("/api", path)),且挂在 /api 那条 prefix 路由上按 pathname 精确匹配;
  2. methods 非空不重复;
  3. 处理函数是标准 (request: Request, peer) => Response,body 由内核按 requestBody: "buffered" 缓冲好。

⚠ peer 根本不会传进来。 createSharedFetchHandler(dsh-client-connection:820-839)在鉴权通过后写的是 return route.fetch(request) —— 只传 request(:831),形参声明成 (request, peer) 的那个 peer 恒为 undefined。v0.4.20/0.4.21 第一版就在这里翻车:handler 开头写了「peer.principal.kind !== "owner" 就回 403」当作纵深防御,结果每一次删除都被自己拦成 403,而且报错文案还是「只有本机会话可以删除会话」,看上去像鉴权在起作用,实际是插件把自己关死了。现在断言钉死:宿主 async fetch( 的形参个数必须为 1,且源码里不许出现 peer.principal(附负向对照:把签名改回 (request, peer) 后该断言必须变红)。鉴权已由内核完成且更严——传输层 admit() 做 Host/Origin 栅栏与 cookie 鉴权,授权层 :830 对非 owner Peer 直接 403。

已由断言钉死:宿主必须出现 connection.fetch.register(,且不许出现 rpc.handle(;客户端必须 fetch(ctxDeleteUrl(), …) 且 SESSION_DELETE_PATH 全文件只出现一处;宿主导出 DELETE_ROUTE_PATH = "/api/ui-tools/sessionDelete" 与客户端常量逐字一致;运行时逐项核对 URL(同源绝对地址)/ method / credentials: "same-origin" / content-type / body.sessionId / body.cwd。

为什么客户端拼同源绝对地址而不是相对路径:相对路径把「同源」交给运行环境解释,Node 测试沙箱里 fetch("/api/…") 直接 TypeError: Failed to parse URL —— 断言只能看到同一句无意义报错,等于测了个寂寞。ctxDeleteUrl() 先取 window.location.origin 拼绝对地址,取不到才退回相对路径。

目标识别:为什么不能用 aria-label

判据是官方渲染时写死的 data-row-key 前缀 —— session:<id> / workspace:<id>,不看 aria-label、不看界面语言。这一条来自参考插件注释里指出的坑:官方 row-actions 按钮在空白会话与已归档会话上根本不渲染,若靠「这一行有没有『会话操作』按钮」判定,这类行会被判成非会话行,菜单直接退化、拿不到 session。(同一结论、自写实现。)

解析:三级降级 + 唯一性保护

ctxResolveSession(sessions, row, workspace) 依次尝试:

  1. DOM 行 id —— data-row-key 命中,最可靠;
  2. 当前选中行 —— aria-selected="true" 时取 store 的 current;
  3. 标题兜底 —— 按 aria-label / title 在库中匹配,只在唯一命中时采用。

任一级无法唯一确定就返回 null,调用方据此保留浏览器原生菜单 —— 绝不猜。 尤其第 3 级:标题命中多个会话时同样返回 null,宁可不管,也不操作错会话。

实现原理(纯 DOM,不注册 slot)

  • 只挂 document 级 三个监听:contextmenu、pointerdown、keydown;菜单节点 appendChild 到 body;
  • 不注册任何 slot,因此旧内核上也照常可用(无 alpha 依赖,不进 alphaFeatures 子 fiber);用到的 sessions / workspaces 本就在入口 inject 里,官方 uiWorkspace 走可选获取(ctx.get("uiWorkspace"),缺席时返回 undefined 并降级)—— 绝不把可选服务写进入口 inject(见下方说明,守 R3);
  • R2 合规:不搬动、不包裹 slot 渲染出来的任何节点,无 MutationObserver、无轮询定时器 —— 菜单节点是本插件新建的,不是搬运既有节点;
  • 全部监听由 installContextMenu() 返回的拆卸函数解除,插件卸载后不留补丁(断言逐项核对 contextmenu / pointerdown / keydown 三类监听是否解绑干净);
  • 动作抛错被捕获并转成提示,不冒泡成未捕获异常;事件已被 preventDefault() 时不再接管,与其他右键类插件共存不打架。

⚠ 为什么 uiWorkspace 必须走可选获取,不能写进 inject

这是 v0.4.13 首发后真实发生的一次事故,代价是六个功能全部消失:

uiWorkspace 由官方 dsh-client-ui-workspace 提供,在精简 bundle / 旧内核里可能不存在。cordis 的 inject 数组语义是「必须等到这些服务就绪才开始」,任何一个等不到,整条 fiber 就停在 PENDING —— 所以把 "uiWorkspace" 加进入口 inject 的后果不是「右键菜单少一个功能」,而是:

dsh-ui-tools: pending (waiting for services: uiWorkspace)

即插件整体不激活:五个既有功能一起消失 + 顶部加载横幅,boot 审计报 1 entry did not activate。用户看到的现象是「右键菜单里的项都没效果」——极易误诊成菜单自身的事件 bug,实际菜单根本没装上。(这与 v0.4.2 在 uiConversation 上踩的是同一个坑。)

正解 —— 在子作用域里可选获取,配能力探测干净降级:

ctx.inject(["sessions", "workspaces"], (scope) => {
  const uiWorkspace = ctx.get("uiWorkspace");   // 缺席时是 undefined,不抛错
  // 每个调用点都探测:typeof uiWorkspace?.startSession === "function"
});

ctx.get(name) 在服务缺席时返回 undefined 而不抛错(cordis 内部 const impl = key && this.store[key]; if (!impl) return;),因此这条路径是干净的降级而不是崩溃。

已由断言钉死:把 "uiWorkspace" 塞回入口 inject,compat-check 从 101/101 掉到 50/85,并精确报出 pending (waiting for services: uiWorkspace)。

交互与无障碍

role="menu" / role="menuitem"、tabindex="-1" + roving tabindex(打开即聚焦首项);支持 Esc 关闭、↑/↓ 移动、Home/End 跳转、点击菜单外部关闭;归档等危险项走独立样式类 dsh-ctx-item--danger;空菜单被拒绝,不会弹出一个空白方块。

开关:设置页「会话 / 工作区列表右键菜单」,默认开;偏好字段 ctxMenuEnabled。关掉时一根监听都不挂,浏览器原生右键菜单原样保留;门控显式订阅偏好仓库,因此改开关即时生效、无需刷新页面,卸载时订阅与监听一并清理。

功能七:待办进度面板(v0.4.30 重构,取代 v0.4.28 的「待办清扫」)

v0.4.28 的初版把功能七做成了「回合结束后隐藏仍在转圈的待办面板」。用户实测后指出理解错了: 真正的问题是「待办列表不按进度更新状态」——模型写完一次 todo_write 之后就不再维护,列表永远停在 「进行中」;同时官方面板折叠时只显示一行汇总,不展开就看不到每项的状态。v0.4.30 按这两条重做。

根因:todos 投影的 host 端 reducer 只处理两个事件——todo/write 覆盖、turn/start 清空; 回合结束 / 停止 / 出错都不对账,模型不重写就冻结在最后一份快照。而官方 TodoPanel 里 !collapsed && <ul> —— 折叠时每项状态点根本不进 DOM。插件改不了 reducer(todo_write 在 tool / host 侧, client 侧无写入通道),只能做展示层推断 + 恒展开渲染。

做法

在同一个 conversation.input.dock 槽位挂一个镜像进度面板 TodoProgressDock(id="ui-tools-todo-progress", order 30,不同 id 不替换官方 todo 的 order:0):

  1. 组件从标准 props 读 useProjection("todos")(待办清单)与 useProjection("sessionStats").steps(助手步数);
  2. 用 tswSignature 认出「同一份列表」,记下它首次出现时的步数基线,之后按 tswAdvanceFromSteps(stepsSince, TSW_STEPS_PER_ITEM=2) 每 2 步推进一项(回合收尾至少 +1);
  3. tswDeriveStatuses(todos, advance, running) 把推断结果叠加到内核状态上,三条硬约束: 内核已判 completed 的项绝不降级、运行中不把最后一项也判完成、空闲时不新造 in_progress;
  4. 面板始终展开,每项渲染 [data-status] + 状态点 + 文案,折叠态看不到的问题从根上消失; 头部给「N 已完成 · N 进行中 · N 待处理」汇总,推断生效时带「按步数推断」小徽标;
  5. 渲染时给 <html> 打 data-ui-tools-todo-mirror="on",共享样式标签的 TSW_CSS 单条规则 html[data-ui-tools-todo-mirror] [data-testid="todo-panel"]{display:none !important} 隐藏官方本体,避免双层;
  6. 卸载 / 关开关时 useEffect cleanup 摘掉属性,官方待办面板原样恢复;
  7. 宽度与输入框卡片严格对齐(v0.4.31):镜像面板在 conversation.input.dock 里是独立块级元素, width: calc(100% - 16px) 会按整列宽铺开、比输入框宽出一大截,所以改用内核自己的 composer 变量 width: calc(100% - var(--dsh-composer-side-clearance,16px) - var(--dsh-composer-side-clearance,16px)) 再配 max-width: var(--dsh-composer-card-max-width,952px)——与输入框卡片同宽同左右边; 变量缺失(旧内核)时回退 16px/952px,仍是同级合理宽度。

设计取舍

  • 只做展示层:不写回任何投影、不拦内核请求、不轮询清理;推断只影响这一份镜像的渲染;
  • fail-open:useProjection 缺席或 todos 非数组时不渲染镜像、不打属性(官方面板照常);
  • 不搬 DOM、无 MutationObserver、无轮询定时器(R2 铁律):副作用只有 <html> 属性 + 一条 CSS 规则;
  • 不用 :has() / 内核哈希类名:选择器只含 [data-testid="todo-panel"](官方自家测试锚点)与自有属性。

开关:设置页「会话待办 → 显示待办进度面板(始终展开每一项,按进度推进状态)」,默认开;偏好字段 todoSweepEnabled。 断言:compat-check 场景 B4(注册位/默认值/推断口径/不降级/副作用矩阵/fail-open/卸载清理/反向钉)+ live-smoke 2d(CSS 契约与属性自洽)。

实现原理

  • 官方模型组件照常注册在 conversation.input.model(数据/提交逻辑原样保留),用 CSS 隐藏官方触发按钮;

  • 本插件通过 modelDirectories 服务读取同一份模型目录(groups = 供应商分组),注册到 conversation.input.right(list slot)追加双按钮;v0.4.4:该子作用域必须同时声明 remote 和 remote.session——内核对 directoryFor() 的实现会在调用方上下文里读 this.ctx.remote.session(cordis 的 Service tracker 会把 service 的 this.ctx 重绑到调用者),而 remote.session 是嵌套追踪服务,只声明 remote 仍会抛 cannot get property "remote.session" without inject;

  • 选择模型/推理等级时调用官方 directory.select(...),官方 store 同步更新,输入框状态与原生一致;

  • 折叠条注册到 sidebar.footer.action——官方渲染位置就是 footer 顶部、紧贴工作区列表正下方,用 CSS(对齐 --dsh-session-list-edge-inset 内边距)贴合列表即可,不搬动 DOM。搬动 slot 渲染出来的节点会与框架重渲染互相触发,导致渲染进程 100% CPU 卡死(v0.1.0 全局观察器、v0.1.2 收窄观察器均因此卡死;v0.1.3 起彻底不搬)。

  • 跨内核分层(v0.4.2;v0.4.9 收窄):uiConversation 是 0.1.2-alpha.1 才引入的 client target 体系服务,旧内核 store 里没有它的提供方。因此这个硬依赖不写在 loader entry 的 inject 上(写了会让整个 entry 停在 PENDING,被 boot 末尾只遍历 ctx.loader.entries() 的审计判为失败 → web boot: 1 entry did not activate + Failed to load plugins 横幅,其余功能一起丢),而是收进 ctx.plugin(alphaFeatures) 子 fiber:入口层只声明五个跨内核服务,功能五(速度计)整段注册(chat target 取数、conversation.composer.dock 注册、TSP 文案)放在子 fiber 内,alphaFeatures.inject = ["uiConversation"]。旧内核上该子 fiber 停在 PENDING——cordis 不执行其函数体、不注册其 effect、也不计入审计,服务(哪怕延后出现)就绪时自动激活,并随 entry dispose 一并释放。渲染方式完全不变(纯 slot,无观察器/定时器)。

  • 工作区徽章注册进 conversation.session.header.actions(见功能三)。

  • 设置页注册进 settings.section(见功能四);所有偏好走 apply 期创建的一次性 localStorage 偏好仓库,组件经 useSyncExternalStore 订阅。

  • 待办进度面板注册进 conversation.input.dock(见功能七):镜像面板(每项状态始终可见 + 按步数推断推进)+ <html> 属性 + TSW_CSS 隐藏官方本体,无 Observer、无定时器。

七个功能各自独立命名空间(locale / slot id / data-* 前缀),互不干扰。

v0.4.9 移除「修改的文件」选项卡:它原本注册进官方 conversation.view 槽(v0.2.0 引入、v0.4.9 整体删除)。移除原因:其取数是同一份 chat target 快照的又一遍解析,与速度计重复;维护成本集中在 run_code 内嵌路径提取这类易碎逻辑上(模板串、变量拼接、shell 级写操作都解析不了)。随之下线的还有 uiSession.provide 暴露的 useModifiedFiles hook、workspaces.openPath 打开链路、mfsCompact 偏好与 conversation.view 槽注册——alphaFeatures 子 fiber 的 inject 因此收窄为 ["uiConversation"],跨内核降级机制本身完全未变。

内核兼容矩阵

DSH 内核 入口 fiber alphaFeatures 子 fiber boot 审计 横幅 可用功能
0.1.2-alpha.1 及更新¹ active active pass 无 全部 7 项(含功能六纯 DOM、功能七待办进度面板)
0.1.1-rc.1 / 0.1.1-rc.2 active pending(静默) pass 无 1 / 2 / 3 / 4 / 6 / 7(功能五缺席,设置页速度计开关灰显)

¹ 「更新」指 同属 0.1.2-alpha.1 之后的发布线(0.1.2-alpha.1 / 0.1.2-* / 0.1.3-* / 0.1.5-* / 0.1.6-* 及更高)。DSH 的预发布版按 semver 只与同一 x.y.z 元组比较,所以字面区间 >=0.1.2-alpha.1 在真实 semver 下并不匹配 0.1.5-rc.2——本包因此按发布线逐条声明,而不是写一个连续区间,详见下节。

机器可读的内核约束(engines.dsh)

package.json 同时声明两处(生态两种写法都在用,值必须一致):

"engines":      { "dsh": "^0.1.1-0 || ^0.1.2-0 || ^0.1.3-0 || ^0.1.5-0 || ^0.1.6-0 || ^0.1.7-0 || ^0.2.0-0 || >=0.3.0-0" },
"dsh": { "engines": { "dsh": "^0.1.1-0 || ^0.1.2-0 || ^0.1.3-0 || ^0.1.5-0 || ^0.1.6-0 || ^0.1.7-0 || ^0.2.0-0 || >=0.3.0-0" } }
  • 谁在读:dshmarket 的 manifestFacts() 读顶层 engines.dsh,缺失时回落到 dsh.engines.dsh;市场会据此在 Discover 页展示宿主兼容性,并在安装/更新前直接拒绝判定为不兼容的版本(deriveHostCompatibility → incompatible → 400)。
  • 生效范围(请如实理解):市场是按 npm 包名向 registry 取这份事实的。本包目前以 github:qgx1992/dsh-ui-tools 分发,而该更新路径对 git 源不查 registry(usesNpmUpdateTarget = !restore && !isGit);且 dsh-ui-tools 这个 npm 名字属于他人(另一作者的插件,无此声明)。所以这条声明当下的作用是统一生态惯例、并为将来可能的 npm 发布备好,不构成本机 github 安装路径的运行时保险——本包的旧内核自动降级是靠 alphaFeatures 子 fiber 实现的(见上),与这条声明无关。
  • 为什么是逐条发布线而不是 >=0.1.1-rc.1:semver 的预发布规则要求预发布版本只匹配同 x.y.z 的区间,因此 >=0.1.1-rc.1 会漏掉 0.1.2-*、0.1.5-* 等线;^0.1.N-0 每项覆盖一条线,末项 >=0.3.0-0 兜住此后所有新线,且不硬顶未来内核(把未来内核误判为不兼容会让市场拒绝合法升级)。
  • -0 后缀的作用:^0.1.1-0 的下界取该线最早的预发布,否则区间会被预发布规则排除。
  • 该区间已对 npm 上全部 29 个已发布内核版本 + 3 个未来哨兵逐项断言,并保证普通 semver 语义与市场侧 includePrerelease 语义判决一致(见下「自检」)。
  • v0.4.8 修正:此前区间写作 … || >=0.1.6-0,其末项在普通语义下不覆盖任何 0.1.7-* / 0.2.0-* 预发布版(只在 includePrerelease 下才成立),实测有 6 条分流(0.1.7-alpha.1/2、0.1.7-rc.1/2、0.2.0-rc.1/2)。原因同上一节:预发布版只与同 x.y.z 的元组比较,而 >=0.1.6-0 的下界落在 0.1.6 线。更隐蔽的是——当时 0b 的版本表只列了 21 个,恰好漏掉整条 0.1.7/0.2.0 线,于是「两种语义判决一致」那条断言假绿。现改为逐条发布线 ^0.1.6-0 || ^0.1.7-0 || ^0.2.0-0 || >=0.3.0-0 并全量列表,令同类裂缝无处可藏。

验证口径:node tools/compat-check.mjs 跑在本机已装内核副本的真实 cordis 上(v0.4.32 实测 127/127 全绿;断言计数随内核枚举动态变化,门槛是零 FAIL 而非某个具体数字,因此别把它当固定指标读。另注意它需要 DSH_CORDIS_ENTRY / DSH_SEMVER_ENTRY 指向可用的 cordis 与 semver——本机可指向仓库内 .scratch/kernel/...),场景断言七个功能的槽位注册、子 fiber 状态与 boot 审计口径;B3 自 v0.4.25 起改为「Definition 通道已移除」的源码反向钉(不再逐内核驱动装配器);B4 自 v0.4.30 起覆盖功能七待办进度面板(注册位/纯函数推断口径/不降级/副作用矩阵/fail-open)。真机(浏览器)行为另由 tools/live-smoke.mjs 覆盖(含 2c 功能五胶囊浮窗契约与 2d 功能七待办进度面板 CSS 契约),需自备 URL+token。

profile 安装漂移:tools/check-profile-drift.mjs 会比对 ~/.dsh/profiles/*/ 三处口径——package.json 的声明、pnpm-lock.yaml 的 specifier 与 resolved 版本、node_modules 里实际安装的版本。三者不一致时必须先修 profile,否则「改了代码、刷新页面、毫无变化」——因为真实 GUI 跑的是 profile 里的安装副本,而 compat-check.mjs 读的永远是仓库 lib/client.js,两者是两份文件。

自检

node --check lib/client.js       # 语法
node tools/check-hooks-order.mjs  # Hook 顺序钉:条件 return 不得夹在 Hook 之间(防 React 卸载期抛错)
node tools/check-model-seat-hug.mjs  # 功能一「座位紧贴听写按钮」的选择器回归钉(手写 DOM 骨架 + 求值真实 CSS)
node tools/check-context-menu.mjs    # 功能六右键菜单钉:判据/三级降级/菜单组装/事件分发/键盘/拆卸/开关门控(手写 DOM 测试台)
node tools/check-profile-drift.mjs   # profile 安装漂移钉:声明 / lock / 实装三者须一致
node tools/compat-check.mjs      # 内核自适应回归(真实 cordis,场景 0/0b/A/B/B2/B4/B3/C/D + 速度计组件/口径断言)
node tools/live-smoke.mjs <URL+token>   # 真机冒烟(可选;自动探测本机 Chrome/Edge)
# 或一次跑完:npm run check

tools/check-hooks-order.mjs 专治一类只在特定状态切换时才炸的崩溃:React 靠「Hook 调用顺序 + 数组下标」识别 Hook,而它从不清空 hook 数组,只按 hookIndex++ 覆盖——于是 if (!cond) return null; 夹在两个 Hook 之间时,cond 为假的那次渲染里后续 Hook 槽位残留上一次的值(若上次该槽是 undefined,卸载时 React 执行 destroy() 直接抛 undefined is not a function)。脚本用纯词法扫描(不引入 babel/typescript,守 R6)切出每个函数组件的函数体、跟踪最外层 return 与 useXxx( 调用的相对位置,命中即 FAIL;并自带双向负向对照(违规样例必红 + 合规写法不误报),确保断言本身不会退化成同义反复。

tools/check-context-menu.mjs 用手写 DOM 测试台(零依赖,不引 jsdom)跑功能六的 228 条断言:从 lib/client.js 里按花括号配平取出真实函数注入沙箱执行,并把 CTX_ZH 词典求值出来做文案核对(断言看的是用户真正会看到的字,不是 stub 返回的键名)。覆盖判据识别(含「工作区行不被误判成会话行」)、三级降级与标题歧义必须返回 null、菜单组装与空菜单拒绝、三类目标的右键分发、键盘与外部点击、卸载后三类监听全部解绑且补丁摘干净、动作真的调到官方服务而非空壳、动作抛错不冒泡、不引用参考插件包名、CSS 并入共享标签、开关门控语义,以及 v0.4.20 的删除会话全链路(二次确认不可跳过、载荷字段逐字正确、宿主半边路由常量与客户端一致、connection.fetch.register 而非裸路由也非不可用的 rpc.handle、可选服务确实没进 inject、删完不调 archiveSession 而调 stopSessionActivity + clearMain + refresh、失败不摘行、通道缺席给可读原因)。v0.4.21 起沙箱额外注入 location 与 fetch 两个形参(Node 无全局 location;不注入 fetch 时相对 URL 会直接 TypeError: Failed to parse URL,断言只能看到同一句无意义报错),并用可编程的 fetchRoutes 路由表模拟宿主行为 —— 未注册路径回 405 就是「宿主没重启」的真实形状。calls.deleted 与 fetchCalls 是同一个数组,且它全局共享:负向对照必须先 const since = fetchCalls.length 再 slice(since) 取新记录,否则读到的是上一个用例的正常 URL,对照会假绿(踩过)。负向对照必须真的变红:判据改坏的方式是把源码文本改掉后重新编译沙箱(给 env.ctxSessionRowId 重新赋值是无效的——闭包内的调用走词法绑定,改不到;这一点本身就是踩过的坑)。另外,凡是「代码里不许出现 X」的断言都先剥离注释再比对,否则文件顶部写明「参考插件:@baihejiangnan/…」「无 MutationObserver」会把注释当代码判红。

⚠ 测试台的一个时间陷阱(v0.4.20 踩到):沙箱把 setTimeout 注入成 (fn) => realSetTimeout(fn, 0),即立刻执行。而 ctxToast 用 window.setTimeout(..., 1800) 清理旧提示——真实浏览器里 1800ms 远大于微任务,但沙箱里那个清理回调会和新提示挤在同一批微任务里、并且后执行,于是一次 .dsh-ctx-toast 全扫会把刚落地的「已删除…」也删掉(断言看到的是空)。规程:凡断言「提示文案」的地方,让出宏任务(await new Promise((r) => setImmediate(r)))而不是若干次 await Promise.resolve()。

tools/check-profile-drift.mjs 专治一类排查成本极高的假象:profile 的 package.json 写着 #v0.4.11,而 pnpm-lock.yaml 仍钉在旧 commit——下次 pnpm install 会把插件无声回退;更常见的是「改了仓库代码、刷新页面毫无变化」,因为真实 GUI 加载的是 profile 里的安装副本。脚本只读不写,逐 profile 比对声明 / lock specifier / lock resolved 版本 / node_modules 实装版本,找不到 ~/.dsh/profiles 时优雅 SKIP。

tools/check-model-seat-hug.mjs 专治一类静态阅读看不出来的样式 bug:它把手写 DOM 骨架建成内核 InputBar 右侧动作区的真实层次(座位在 div.standardControls 内、听写按钮嵌在兄弟容器 div.activity 的 span.triggerAnchor 里),求值 MSS_CSS 模板串(而不是只读源码文本)后用自写选择器匹配器逐条核对:座位父容器被推到最右、横缝收到 0、中英两套 aria-label 都命中、无座位/听写缺席时不误伤、CSS 里不出现内核哈希类名、官方模型按钮仍被隐藏。它也守住「模板串被反引号提前闭合」这类事故——那种情况下求值会失败或留下未展开的 ${...}。本仓库零依赖,不引入 jsdom。

tools/compat-check.mjs 不开浏览器也不重启服务:它从本机已装内核副本里加载 cordis,搭一个最小宿主把 lib/client.js 的 entry 挂上去,在「旧内核 / 新内核 / 服务后到 / 内核不提供 remote」四种主场景(外加声明层 0/0b、速度计 B2、待办进度面板 B4、Definition 通道已移除的反向钉 B3)下复刻 boot 的审计口径(只看 loader entry fiber 是否停在 PENDING),断言入口激活、子 fiber 状态、各槽位注册、功能一目录冷解析、设置页能力位(含三条「conversation.view / uiSession.provide / workspaces.openPath 永不被调用」的逆向钉),以及功能五的数值口径(tspTurnSpeed/tspFoldTurn/tspLastTurnSpeed/tspBlocksEffort)与 dock 组件的 DOM/开关门控(后两项直接截取 bundle 源码段执行,测的就是被测 bundle)。

另含声明层断言(场景 0b):用内核自带的真实 semver 校验 engines.dsh 覆盖全部已发布内核版本、不误伤未来内核,并保证普通语义与市场侧 includePrerelease 语义判决一致——手搓匹配器会把「被测语义」偷换成「我以为的语义」,故不用。可用 DSH_SEMVER_ENTRY=<...>/semver/index.js 指定匹配器。

tools/live-smoke.mjs 用 puppeteer-core 驱动本机 Chromium 内核浏览器(按 DSH_SMOKE_CHROME → ms-playwright 缓存 → 系统 Chrome/Edge → 常见类 Unix 路径依次探测),打开正在运行的 DSH Web 断言无加载横幅、各功能 DOM 标记在位、并截图。拿不到 URL/token 或找不到浏览器时 SKIP(exit 0)。

变更记录

  • v0.4.32(本次):插件简介改回一句话(用户:「把插件的简介弄简短一点啊,现在点开插件一大篇」)。

    • 根因:真机定位到 DSH「插件」页点开某插件后的详情面板,是把该包 package.json 的 description 字段原样全文渲染的; 而本项目此前惯例是「每版在 description 末尾追加一条变更说明」,v0.4.2 一路累积到 v0.4.31,description 涨到 16376 字, 所以点开就是一大篇。(列表页的卡片是单行 -webkit-line-clamp: 1 截断,本来不丑——丑的只有点开之后。)
    • 修法:description 固定为一句话简介(约 90 字,覆盖七个功能 + 「只改界面,不改内核源码」定位), 16376 → 93 字;版本变更说明只进本文件「变更记录」与 git tag / GitHub Release,不再往 description 里堆。
    • 验证:description 仅此一处被替换,name/version/engines/dsh/files/exports 等字段逐字节未动(脚本化 splice + 逐字段比对断言); 全仓库无任何断言依赖 description(grep tools/*.mjs 零命中),故不影响回归。
    • 文档与版本:package.json 0.4.31 → 0.4.32;本条替代此前「description 追加本条」的惯例。
  • v0.4.31:修功能七镜像面板过宽(用户:「你是有毒吗,把待办拉那么宽干什么?把宽度跟输入框对齐」)。

    • 根因:[data-todo-mirror] 用的是 width: calc(100% - 16px)。它在 conversation.input.dock 里是独立块级元素, 100% 取的是整个 composer 列宽(比输入框卡片宽出一大截),减 16px 仍远宽于输入框,于是面板左右各溢出一截。
    • 修法:改用内核自己的 composer 变量——width: calc(100% - var(--dsh-composer-side-clearance,16px) - var(--dsh-composer-side-clearance,16px)) 配 max-width: var(--dsh-composer-card-max-width,952px),得到与输入框卡片同宽、同左右边的结果; 变量缺失(旧内核)时回退 16px / 952px,仍是同级合理宽度,且不引入任何写死像素的绝对定位。
    • 断言:compat-check B1 新增「镜像面板宽度与输入框对齐(width 用 side-clearance + max-width 用 card-max-width)」的数值级样式哨兵 (只扫选择器会被旧实现假绿,故必须查两个变量名与 width/max-width 同现),126 → 127/127;live-smoke 2d 增加真机宽度实测 (镜像面板与输入框卡片的 getBoundingClientRect 宽度差 ≤ 2px、左右边缘差 ≤ 2px)。
    • 文档与版本:package.json 0.4.30 → 0.4.31、description 追加本条;README 功能七「做法」补第 7 条与本节、验证口径(127/127)同步;AGENT.md §4/§6.3 口径同步。
  • v0.4.30:重构功能七——用户指出 v0.4.28 理解错了需求。

    • 用户原话:「功能七你是不是理解错了,我是想解决待办列表不按进度更新状态的问题,不是消不消失的问题」;随后在选项里明确了两个痛点:①「模型写完一次待办就不再更新,列表停在进行中」;②「折叠时不显示每项状态,希望不展开也能看到进度」。
    • 形态变化:v0.4.28 是「回合结束后隐藏仍在转圈的待办面板」的恒 null 哨兵;v0.4.30 换成 conversation.input.dock(id="ui-tools-todo-progress",order: 30)的镜像进度面板 TodoProgressDock —— 自己渲染一份「每项状态点始终可见、永不折叠」的面板,同时用 <html> 属性 data-ui-tools-todo-mirror + TSW_CSS 单条规则隐藏官方本体,避免双层。
    • 按进度推进(展示层推断):读 useProjection("todos") 与 useProjection("sessionStats").steps;tswSignature 认列表身份并记下首现时的步数基线,tswAdvanceFromSteps(stepsSince, TSW_STEPS_PER_ITEM=2) 每 2 步推进一项(回合收尾至少 +1),tswDeriveStatuses(todos, advance, running) 叠加推断状态。三条硬约束:内核已判 completed 的绝不降级、运行中不把最后一项判完成、空闲不新造 in_progress。
    • 为什么只能做展示层:todos 投影唯一写入源是 host 侧的 todo/write 事件(@deepseek-ai/dsh-tool-todo),client 侧没有 reducer 可挂、也不许拦请求;因此推断只影响这一份镜像的渲染,不写回任何投影。
    • 实测澄清:真机逐帧探针证实「展开后单项状态不刷新」其实是折叠只显示汇总 + 模型不重写,不是渲染 bug——模型真按步 todo_write 时 DOM 每项 data-status 与 glyph 确实实时更新。
    • 断言:compat-check B4 重写(注册位/order、todoSweepEnabled 默认开、tswAdvanceFromSteps 口径、tswDeriveStatuses 不降级/运行中/空闲三态、fail-open、cleanup 摘属性、样式哨兵 [data-todo-mirror]),113 → 126/126;live-smoke 2d 改为「镜像面板 CSS 契约 + 属性与可见性双向翻转」。
    • 文档与版本:package.json 0.4.29 → 0.4.30、description 追加本条;README 功能七整节重写、验证口径(126/126)与兼容矩阵同步;AGENT.md §0 定位块、§2 功能表第 7 行、§3「改待办进度面板」行、§6.3 整节重写、§7/§8 口径同步。
  • v0.4.29:把功能五「输出速度计 tok/s」改成可随意拖动的胶囊悬浮窗。

    • 用户要求(原文):「我想把功能5作为胶囊悬浮窗的,可以随意拖动位置,再验证一下功能7是否生效」。
    • 形态变化:v0.4.25–v0.4.28 是「输入框下方统计行里、与官方 activity / usage 同行的固定 pill」,位置由内核布局决定、用户改不了;v0.4.29 起改成 position:fixed 的可拖动胶囊,用 require("react-dom").createPortal 挂到 document.body(脱离 slot 祖先链的 overflow:hidden / transform 裁剪),默认落在右下角偏内。
    • 保留 conversation.composer.dock 注册:它是拿到 session 作用域标准 props(useChat)的唯一可靠通道(v0.4.25/v0.4.26 两次真机坑的教训),组件仍从这里取 chat 快照(legacy.nodes 精确值 / legacy.partial 生成中估算),只是渲染目标从 slot 内改成 body 浮层。
    • 拖动与位置记忆:[data-tsp-capsule] 外壳监听 onPointerDown/Move/Up/Cancel(setPointerCapture + posRef 高频读最新坐标 + drag ref 存按下偏移),拖动结束写 localStorage dsh-ui-tools:token-speed:pos:v1;onDoubleClick 复位并清键。几何工具 tspReadPos / tspWritePos / tspClearPos / tspClampPos(夹回视口、留边距)/ tspDefaultPos(右下角默认位)都在「功能五取数段」内(compat-check 截取作用域)。
    • 降级:拿不到 react-dom.createPortal 或 document.body 时组件退化为内联 fixed 元素(仍可拖动),保证任何内核/SSR 形态下都不抛。
    • 断言:compat-check B2 新增「portal 到 body」「fixed 浮层」「四个拖动句柄」「拖动写 localStorage」「tspClampPos 夹取 / tspDefaultPos 默认位 / tspReadPos 坏数据回退 / 写读清往返」断言;require 桩放行 "react-dom"、makeFakeDom 补 body、loadTspComponents 注入 createPortal/useState/useEffect/useCallback/window/localStorage;样式哨兵加 [data-tsp-capsule]。compat-check 104 → 113/113;hook 钉 7/7、座位钉 38/38、右键钉 228/228、漂移钉 3/3、live-smoke SKIP(无 URL)。
    • live-smoke 2c 重写:从「与统计行同行、排 activity 右侧」改为「[data-tsp-capsule] 是 position:fixed 浮层、不在 [data-composer-dock] 内、挂在 document.body、带 cursor: grab/move」+ 真实鼠标拖拽(page.mouse down/move/up)后坐标变化且写入 localStorage,再双击复位;文件头功能条目与 MARKERS.tokenSpeedPill 注释同步。
    • 文档与版本:package.json 0.4.28 → 0.4.29、description 追加本条;README 功能五整节改写为「可拖动胶囊」并加 v0.4.29 迁移说明、布局偏好行与验证口径(113/113)同步;AGENT.md 功能表第 5 行、§3 新增「改胶囊悬浮窗的拖动 / 位置」行、§6.1 标题与前言、§7 口径、§4/§8 基线数字(113/113)同步。
    • 后续两处修正(同一版本内):① 双击复位曾失效 —— pointerdown 里 preventDefault() 会抑制浏览器原生 dblclick,真机实测只有 pointerdown/click 而没有 dblclick,改为在 pointerdown 处理器内自行判定两次按下间隔 < 350ms 即复位(不再依赖 onDoubleClick);② live-smoke 双击断言假红 —— 复位后立即读 DOM 读到的是 React 异步渲染前的旧坐标,改为等 450ms 再断言「位置回到默认位 + localStorage 键已清」。真机复验:拖动 → {"x":668,"y":312} 落盘、刷新后保持、双击回默认位并清键(RESET_OK)。
    • 设置页开关文案同步:v0.4.29 起不再「在输入框下方…旁」,改为「显示输出速度计(tok/s)——可拖动的胶囊悬浮窗,双击复位」。
  • v0.4.28:新增功能七「待办清扫」——修「会话结束后输入框上方的『任务』待办面板带着转圈圆点永驻」。

    • 现象:回合都结束了,官方 TodoPanel 还在转圈,直到同会话发出下一条消息才消失;
    • 根因:内核 todos 投影只有 todo/write 覆盖与 turn/start 清空两个事件,回合结束不对账(哑快照),模型留下未完成项直接收尾时面板就冻结在转圈态;
    • 修复:同槽(conversation.input.dock)挂恒 null 哨兵 ui-tools-todo-sweeper(order 30):确知 session.running === false 且待办仍有 in_progress/pending 时给 <html> 打 data-ui-tools-todo-sweep,共享标签新增 TSW_CSS 隐藏 [data-testid="todo-panel"] 本体;新一轮开始或待办清空自动恢复,组件卸载时 cleanup 摘属性;
    • 断言:compat-check 新增场景 B4 共 18 条(注册位 order 30 / todoSweepEnabled 默认开 / tswHasUnfinished 纯函数口径 / 副作用矩阵:running 不打属性、idle+残留打 on、全完成不打、开关关不打、session 与 useProjection 缺席 fail-open、SSR 形态跳过、cleanup 摘除 / Definition 反向钉),共享标签样式哨兵五份 → 六份(功能七哨兵钉在目标选择器 [data-testid="todo-panel"] + display:none !important 同现上,不是只扫作用域前缀——负向对照实测:只扫前缀时删掉 display:none 仍假绿),98 → 104/104;行为矩阵新内核 5/5 → 6/6 槽位功能、旧内核 4/5 → 5/6。
    • live-smoke 补覆盖:新增「2d. 功能七:待办清扫」段(真机不赌「恰好残留 in_progress」的现场,改为验确定性的 CSS 契约)——① 共享 style[data-plugin-css="dsh-ui-tools/styles"] 里确实含 TSW_CSS 隐藏规则;② 面板存在时双向就地翻转 <html> 属性:打上 → display:none、摘掉 → 恢复 block、还原回原状,证明规则只在有属性时生效、不误伤面板;③ 属性与可见性自洽(attr=on ⇒ 面板被隐藏,无属性 ⇒ 不误伤)。设置页断言由「四个偏好行」升为「五个偏好行(含 v0.4.28 会话待办)」,并新增功能七开关不灰显断言。
    • 真机验收(Edge + 自签 cookie 打真 GUI):live-smoke 30/30 全 PASS,2d 探针实测 {"hasRule":true,"attr":"on","hasPanel":true,"before":"none","hiddenWhenOn":"none","visibleWhenOff":"block","restored":"none"}。
    • 文档:AGENT.md 补齐功能七(§0 定位「七个 UI 工具 / v0.4.28」、§2 功能表第 6/7 行、内核自适应表、prefs 字段、§3 定位表新增「改待办清扫」行、§4 断言计数与 live-smoke 段落说明、§6.3 新增「功能七踩过的坑」六行、§7 明确不做、§8 自检清单)。
  • v0.4.27:修「回合结束后速度计整个消失」(v0.4.26 装到真机后的现象)。

    • 现象:生成中能看到 ⚡≈ 245 tok/s · 生成中估算,回合一旦结束 pill 就消失,统计行只剩官方 activity / usage 两枚。
    • 根因:真机实测(React fiber 遍历 + 逐帧采样)发现内核 legacy 快照里 assistant 节点的 timing.firstTokenTime 恒为 null —— 连刚流式生成完的回合落地后也是 null(stepStartTime / completedTime / usage.outputTokens 都在)。内核只在 durable log / sessionStats projection 里保留首字时间,客户端窗口的 settled 节点拿不到(官方 ActivityPill 显示 70 tok/s 是走 useProjection("sessionStats"),不是 deriveStats(nodes))。原 tspFoldTurn 硬要求 firstTokenTime 为数字,于是 sampled 恒 false → 返回 null → pill 消失。compat-check 的假节点自带了 firstTokenTime,所以再次假绿。
    • 修复:tspFoldTurn 改为两级口径——① 若有带 firstTokenTime 的 step,仍按内核解码口径 ΣoutputTokens ÷ Σ(completedTime − firstTokenTime)(墙钟 step 不参与分母);② 所有 step 都缺 firstTokenTime 时回退整步墙钟口径 ΣoutputTokens ÷ Σ(completedTime − stepStartTime)(含首字延迟,legacy 快照恒可得)。tooltip 与 README 明确标注口径差异。
    • 断言补钉:compat-check 新增 4 条 —— 墙钟回退算出 50 tok/s、缺 stepStartTime 返回 null、有解码数据的 step 优先且不混墙钟分母、组件层「firstTokenTime=null 的已固化回合仍渲染 ⚡30 tok/s」(真机回归钉);compat-check 85/85。
    • 真机验收(tools/live-smoke.mjs,Edge + 自签 cookie 打真 GUI):生成中 ⚡≈ 2xx tok/s · 生成中估算 逐帧更新,回合结束后仍有 ⚡87 tok/s 常驻(不再消失),位置探针 {inDock:true, sameRow:true, rightOf:true} —— 与官方「4 轮 249 步 · 166 tok/s」同一行、排在其右侧;live-smoke 26/26 全 PASS。
    • live-smoke 2c 重构:原断言直接要求 pill 文案 ^(≈ )?\d+ tok/s$、且用「逐层向上找含『轮/步』的容器 + 叶子节点」推断行位置,在生成中估算形态与真实 DOM 结构下必然误判。改为 ① 文案正则允许 ⚡ 前缀与 · 生成中估算 长尾;② 位置用 pill.closest("[data-composer-dock]") + [data-composer-stat="activity"] 的 getBoundingClientRect() 直接比对(同行判定按中线 ±8px、右侧判定按左边缘),不再猜文本;③ 内核「用时」弹窗对照分三态(打不开 / 估算形态 / 可对照),避免把探测失败当产品缺陷;④ 估算形态额外断言 role="status"。
    • 版本:0.4.26 → 0.4.27。
  • v0.4.26:修 v0.4.25 真机上速度计根本不渲染的根因。

    • 现象:v0.4.25 装进 desktop profile 后,输入框下方统计行只有官方 activity / usage 两个 pill,插件的 [data-tsp-pill] 始终为 0(compat-check 却全绿)。
    • 根因:conversation.composer.dock 是 session 作用域 槽,sessionId 由内核槽框架作为标准 props 注入组件(官方 ActivityPill 直接 useChat(s => s.legacy.nodes) 即证据;同插件 WorkspaceNameChip 也是从 props 解构 sessionId,而它的 inject 只返回 { prefs }、不收参数)。v0.4.25 误写成 inject: (sessionId) => ({ hooks: { tspChat: tspChatSource(ctx, sessionId) } }),真机上该形参恒为 undefined → ctx.uiConversation.binding(undefined) 抛错 → 走空快照兜底 → 组件 legacy 恒空 → 永远 return null。compat-check 的假 kernel 自己造了 sessionId 并直接喂 useTspChat,所以假绿、测不出。
    • 修复:注册改为 inject: () => ({ prefs });TokenSpeedDockPill 优先取内核标准 props 的 useChat(useChat(sel) 与官方同款,sel(s).legacy.nodes / .legacy.partial 均可读),useTspChat 仅保留作单元测试兜底(真机不存在,取不到就走 useChat)。tspChatSource 保留未删(历史通道,无人调用不再影响渲染)。
    • 断言补钉:compat-check 新增两条 —— 「只给 useChat 也能算出末回合速度」(防线:不再依赖自造 inject)与「useChat 与 useTspChat 并存时优先用标准 useChat」;compat-check 81/81。
    • 版本:0.4.25 → 0.4.26。
  • v0.4.25:把功能五「输出速度计 tok/s」换位置 —— 从「官方『用时』pill 左侧 + 消息流末尾估算条」迁到输入框下方官方统计行(「N 轮 M 步」所在的那一行,与官方 activity / usage 统计 pill 同一行)。

    • 用户要求(原文):「把插件的功能五换个地方显示」;被问放哪儿时答:「我在想放在底部几轮几步的旁边」。
    • 位置实现:新注册进官方 list 槽 conversation.composer.dock(session 作用域),id: "ui-tools-token-speed"、order: 2(官方 activity=0 / usage=1 之后,纯增量新增、不替换任何官方控件)。组件 TokenSpeedDockPill 经槽位 inject(sessionId) 取插件自建的 tspChat 快照源,渲染 ⚡42 tok/s。视觉对齐官方 StatsPills(tertiary 文本、tabular-nums、gap: 6px、padding: 1px 8px、border-radius: var(--dsw-radius-sm)),不再有独立 height: 28px 的按钮感。
    • 口径分工:官方统计行里的 TPS 是全会话口径;本 pill 是本回合 / 最近一回合口径(tspLastTurnSpeed),两者互补。tps.title 文案明写这一区别,避免误解为重复。
    • 删除 Definition 通道(风险面归零):生成中估算原走 ctx.uiConversation.events.register 自建 token-speed-live chat 节点 + conversation.chat.node 渲染器,这就是 v0.4.7「回合结束后界面不再刷新」事故的来源(内核 buildTargetUpserts() 见「已物化节点本帧返回 null」即抛 withdrew materialized target,整条 chat 快照链停更)。且 Definition 节点不进 legacy 快照,dock 组件本就读不到它的 data。故整体删除 tspLiveDefinition、TokenSpeedLive、TokenSpeedPill、conversation.chat.node 注册、assistant-actions 注册、tspChunkEffort、TSP_LIVE_KIND / TSP_LIVE_ANCHOR / tspCurrentShapeWarned。
    • 生成中估算改走 legacy.partial:内核在 assistant-step 运行期持续发布 legacy.partial = {turn, step, blocks},新增纯函数 tspBlocksEffort(blocks) 按 kind 折算(text / reasoning → 正文;tool-call → name + argsRaw;image / other → 0),CJK 1 字 ≈ 1 token、其余 4 字 ≈ 1 token。分母只计相邻增量帧之间的真实出字时间,间隔超过 TSP_LIVE_GAP_CAP_MS(1000ms,常量移入「功能五取数段」以便 compat-check 截取作用域)视为等工具/等请求、不计入;有效出字 < 250ms 或折算 token 为 0 时不显示,避免爆表读数。估算始终带「≈」与「生成中估算」标注。
    • 新增/保留的纯函数:tspFoldTurn(nodes, turn)(按回合号折叠)、tspLastTurnSpeed(nodes)(取最大回合再折叠)、tspBlocksEffort(blocks);tspTurnSpeed(nodes, messageId) 保留(定位回合后转调 tspFoldTurn),与内核「本轮用时和速度」弹窗同源同口径不变。
    • 断言重写:compat-check B2 整段改为 dock 契约(槽名 / order: 2 / name 一致 / 反向钉「不再注册 assistant-actions 与 chat.node」/「ledger.definitions 为空」/ 三条新纯函数 / 精确值与估算两路 DOM);B3 由「真内核装配器驱动 Definition」整节改为反向钉(源码不得再含 tspLiveDefinition / token-speed-live / TSP_LIVE_* / tspChunkEffort / data-tsp-live);场景 C 的 ledger.registered 字符串改为 conversation.composer.dock/ui-tools-token-speed。live-smoke 的 2c 改为「dock 统计行」契约(与「N 轮 M 步」同行且在右侧、文案允许 ≈ 前缀、旧 data-tsp-live 必须为 0)。compat-check 79/79;hook 钉 7/7、座位钉 38/38、右键钉 228/228、漂移钉 3/3、live-smoke SKIP(无 URL)。
    • 文档与版本:package.json 0.4.24 → 0.4.25、description 第 5 条重写为「显示在输入框下方、官方轮/步统计旁,生成中显示估算」;README 功能五整节重写并加迁移说明;设置页开关文案改为「在输入框下方「几轮几步」旁显示输出速度计(tok/s)」。
  • v0.4.24:重做删除确认弹窗——位置不再偏右,观感与客户端对齐。

    • 用户反馈(原文):「删除确认框UI有点跟客户端不一样,而且偏右,重新优化一下」。
    • 「偏右」根因:ctxConfirmDialog 把 window.innerWidth / 2 当左上角传给了 ctxPositionMenu(root, x, y)(该函数收的就是左上角,内部还会 Math.min(x, innerWidth - width - 6) 夹一次),于是框从屏幕正中往右铺并被右边夹住。现在根节点用 position: fixed; inset: 0 + display: flex; align-items: center; justify-content: center 居中,ctxConfirmDialog 里再也没有 ctxPositionMenu 与 innerWidth。
    • 观感对齐:结构、尺寸、间距、圆角、阴影、遮罩全部照 dsh-web-frontend 里那组官方 Modal 规则抄(_root_ / _mask_ / _dialog_ / _header_ / _title_ / _content_ / _description_ / _footer_),按钮照官方 Button 的 md + outline 规格(36px 高、0.5px solid var(--dsw-alias-border-l3) 描边、14px/22px 字)。颜色一律走 --dsw-* token,危险按钮用 --dsw-alias-state-error-primary(与官方删除确认的 deleteAction 同源)。按钮顺序与官方 footer 一致:取消在左、危险动作在最右。
    • 不再复用右键菜单样式:确认弹窗根节点不再带 dsh-ctx-menu(旧版是「菜单原地换成确认条」,样式同源、按钮是竖排菜单项,一眼外挂)。断言直接钉住这一点。
    • 关窗行为(都等同于取消、都不发请求):点「取消」、按 Esc、点右上角关闭键、点遮罩空白处。点卡片正文不会误关(pointerdown 挂在遮罩节点上,卡片不是它的子节点)——否则用户想选中正文文字复制都会把窗点没。
    • 断言 196 → 228 条:新增 §16 整节(role="alertdialog" 在卡片上、遮罩存在、无 dsh-ctx-menu、按钮顺序、关闭键/遮罩/卡片正文三种点击行为、弹窗用到的每个类名都必须在 CTX_CSS 里有定义),负向对照 2 条:抽掉 justify-content: center 后居中钉必红;把坐标计算塞回 ctxConfirmDialog 后「不再算坐标」两条钉必红。
    • 回归:node --check 双文件通过;钩子钉 7/7 + 座位钉 38/38 + 右键钉 228/228 + 漂移钉 3/3 + compat-check 91/91 + live-smoke SKIP。
  • v0.4.23:给「删除会话」补上两块真机上最缺的东西——子会话级联与逐条进度。

    • 新功能一:子会话级联删除。 删父会话时把整棵分支子树一起删掉,确认条提前说明「会一并删除 N 个子会话分支」。血缘链不需要新 RPC:dsh-session:2017 的 header.parentSession → api-session-controller:2129 的 parentSessionId → 客户端 service.js:512 的摘要字段 parentId,客户端自己 BFS 就能算。判据只用 parentId,不看 origin(内核 dsh-session:1053 里 origin 只允许 "subagent",用户手动 fork 的分支是空的,认它会漏)。
    • 新功能二:批量删除逐条进度。 新增 NDJSON 流式路由 DELETE_STREAM_ROUTE_PATH = "/api/ui-tools/sessionsDeleteStream",deleteSessions() 每删完一个就 onProgress({done, total, sessionId, ok}),客户端每收到一行 progress 就刷一次「正在删除 3/20…」,结束时推一行 done(内容与非流式端点共用同一个 deleteEnvelope())。用 NDJSON 而不是 SSE:EventSource 只能 GET、带不了请求体。响应头带 x-accel-buffering: no 防止中间层攒流(攒了进度就是假的)。
    • 老宿主自动降级。 客户端先打流式路由,遇到 404/405 立即退回去打一次性批量端点;response.body.getReader() 不可用、或流里一行有效数据都没读到,同样退回。升级插件但没重启宿主不会坏功能,只会没有进度。
    • 工作区批量删除也做级联展开。 从 w1 的会话 fork 出来、后来被归到 w2 的子会话,在 w1 的 sessionIds 里根本不可见;只按 sessionIds 删就会漏。现在先展开子树再取并集,确认条上的数量是展开后的真实条数。
    • 没有子树时一字不改。 仍只发 {sessionId, cwd} 打单会话端点,v0.4.20 的载荷契约保持原样(断言钉死)。
    • 新增断言 25 条 + 负向对照 3 条(右键钉 171 → 196):子会话索引的三级树 / 兄弟分支 / 血缘成环不死循环 / 快照读不到返回 [] / 字段名改坏后查不出子树(负向对照);有子树时确认条文案、载荷 = 父 + 子树且不含兄弟;无子树时仍走单会话端点;流式端点 3 个会话 → 恰好 3 次进度且 done 为 1,2,3;老宿主降级后删除照常成功且确实打到了批量端点;宿主 onProgress({done: 静态钉 + 去掉该调用后必红(负向对照);工作区跨区子会话级联。
    • ⚠ 负向对照又踩了一个坑(教训与 v0.4.22 同源):buildBrokenEnv(target, mutate) 返回的是沙箱工厂而不是沙箱本身,直接当 env 用会得到 brokenEnv.ctxDescendantSessionIds is not a function——必须先经 buildEnv(...) 求值。「构造了一个坏对象」不等于「坏代码被执行过」。
    • 回归:node --check 双文件通过;钩子钉 7/7 + 座位钉 38/38 + 右键钉 228/228 + 漂移钉 3/3 + compat-check 91/91 + live-smoke SKIP,npm run check 零 FAIL。
  • v0.4.22:新增**「删除工作区全部会话」,并修掉 v0.4.21 遗留的致命 peer 参数 bug**。

    • 新功能:右键工作区行 → 「删除工作区全部会话…」,二次确认(点名工作区与数量)后一次性把该工作区的会话全部从磁盘删除。
    • 宿主:新增精确路由 DELETE_BATCH_ROUTE_PATH = "/api/ui-tools/sessionsDelete",与单会话端点共用同一个 registerDeleteRoute(path) 工厂(单删 = 只含一项的批量),并把删除逻辑抽成 readDeleteBatchPayload() + deleteSessions()(串行执行,逐条收集 deleted / failed)。条目上限 DELETE_BATCH_LIMIT = 200。部分失败回 {ok:false, error:{code:"partial", details:{deleted, failed}}}。
    • 客户端:抽出两条路由共用的 ctxPostDelete(path, payload)(原来那份 .then/.catch 只服务单删,复制第二份必然分家),新增 ctxDeleteUrlFor(path) / ctxCallSessionsDelete() / ctxSessionSummaryIndex()(后者把列表快照建成 sessionId → cwd 的 Map,好让批量载荷带上 cwd);新增词典六条(菜单项、确认标题与正文、完成提示、部分失败提示、无会话提示),中英各一份。
    • 修掉的致命 bug(v0.4.20/0.4.21 真机上每次删除都被自己拦成 403):内核 createSharedFetchHandler 在鉴权通过后写的是 return route.fetch(request) —— 只传 request,peer 根本不下传(dsh-client-connection/lib/index.js:831)。而我在 handler 开头写了 if (peer?.principal?.kind !== "owner") return 403 当纵深防御,peer 恒为 undefined ⇒ 判据恒真 ⇒ 每一次删除都回 403「只有本机会话可以删除会话」。这句文案看着像鉴权在正常工作,实际是插件把自己关死了,极难怀疑到「形参根本不存在」。修法:删掉插件侧那层判断(鉴权已由内核传输层 admit() + 授权层 :830 完成,比插件能做的更严)。
    • 新增断言 4 条 + 负向对照 2 条(右键钉 164 → 171):① 宿主 fetch 回调形参个数必须 === 1;② 源码不许出现 peer.principal;③ 批量路径字面量全文件只写死一处;④ 两条删除路由必须共用同一工厂(registerDeleteRoute(DELETE_…) 恰好 2 次);⑤ 工作区菜单 5 项且「删除全部」紧跟「归档全部」;⑥ 宿主导出 DELETE_BATCH_ROUTE_PATH 与客户端逐字一致。负向对照:把签名改回 (request, peer) → ① 必须变红;把 ctxPostDelete 里的 URL 拼错一位 → URL 断言必须变红。
    • ⚠ 负向对照本身也踩了一个坑:第一版把 ctxDeleteUrlFor 末尾的 return path; 改坏,对照照样「通过」——因为函数开头 return loc.origin + path; 在测试环境里总是先命中,末尾那条兜底分支根本执行不到。「改了源码」不等于「改了被执行的那条路径」,对照变红才是唯一证据。现在改为直接改 ctxPostDelete 里 fetch(ctxDeleteUrlFor(path), 这个调用点。
    • 回归:node --check 双文件通过;钩子钉 7/7 + 座位钉 38/38 + 右键钉 228/228 + compat-check 91/91 + 漂移钉 2/3(唯一 FAIL 是「声明 v0.4.21 但实装 v0.4.20」,属未发版前的预期状态)+ live-smoke SKIP。
  • v0.4.21:修复「删除会话」在真机上必然失败的问题 —— 通道从 connection.rpc 换成 connection.fetch。

    • 现象:装好插件、重启 DSH、点「删除会话」→ 永远提示「插件宿主半边未加载(宿主进程只在启动时装配插件),无法删除会话;请重启 DSH 后重试」。重启多少次都一样。
    • 根因(不是重启没生效,是 API 用错了):connection.rpc.handle(channel, handler) 内部走 this.register(owner, channel, handler),owner 取的是 Connection 服务自己的 ctx,最后一行 dsh-client-connection/lib/index.js:871 执行 owner.webServer.register(route) —— 而该插件入口 :1013 的 inject 只有 ["credentials"],没有 webServer。于是任何第三方插件调 rpc.handle 都会抛 cannot get property "webServer" without inject,整条 IPC 通道对插件彻底关闭。异常被注册过程的 catch 吞成 channelState = "register-failed: …",客户端收到 404/405,翻译成「宿主半边未加载」——提示完全正确,只是它在描述一个永久状态,不是重启能解决的。
    • 修法:改用 connection.fetch.register({ path: "/api/ui-tools/sessionDelete", methods: ["POST"], requestBody: "buffered", fetch })(内核 registerFetchRoute,:840-854)—— 它只往内部 fetchRoutes Map 里塞一条精确路径,完全不碰 webServer,返回 disposer,天然满足 R3 与拆卸纪律。请求先经 connection 的 /api 共享处理器:Origin 栅栏 403 → cookie 鉴权 401 → principal.kind === "owner",安全边界仍在内核。客户端相应改为 fetch(ctxDeleteUrl(), { method:"POST", credentials:"same-origin", body }),404/405 统一翻译成 no-host(保留「请重启 DSH」提示,那是给真·未重启的场景用的)。
    • 顺带修掉两个排查期发现的真 bug:① 宿主 handler 原先把 scope(只声明了 connection 一个依赖)传给 deleteSession(),导致 sessionPersistence / sessionQuery / workspaceRegistry 全是 undefined —— 改成传插件根 ctx;② 探针路由原先用 ctx.get("webServer") 探测,返回 undefined(Context._getImpl 对未 ACTIVE 的服务返回 undefined),改成 ctx.inject(["webServer"], …) 子 fiber —— 顺带证实了「inject 缺席会让整条 fiber 静默 INACTIVE」这条老坑在宿主侧同样生效。
    • 入口 inject 收为 []:宿主半边的一切依赖(connection、探针要的 webServer)都放进 ctx.inject([...], cb) 子 fiber,入口不再声明任何服务 —— R3 的加强版,插件永远不会因为某个服务缺席而整体不激活。
    • 保留诊断能力:lib/index.js 里留了 diagLog()(追加写 ~/.dsh/ui-tools-host.log,全程 try/catch 吞异常)与只读探针 GET /__dsh_ui_tools_probe(返回 delete channel = registered | pending | register-failed: …)。排查这类「宿主侧静默失败」时,node tools/diag-session-delete.mjs <port> 一条命令就能区分「模块没加载 / apply 没跑 / 路由没挂 / 鉴权拦截」。
    • 测试台改造(tools/check-context-menu.mjs 156 → 164 条):沙箱新增 location(Node 无全局 location)与 fetch 两个形参;新增 fetchCalls 记录 + fetchRoutes 可编程路由表(未注册路径回 405,用来模拟「宿主没重启」);connection 换成「一碰就抛」的桩(谁改回 rpc.call 当场炸)。断言从「rpc.call("/ui-tools", "sessionDelete") 逐字」改为「fetch(ctxDeleteUrl()) + 同源绝对地址 + method/credentials/content-type/body 逐项核对」;宿主静态钉改为「必须有 connection.fetch.register(、不许有 rpc.handle(、DELETE_ROUTE_PATH 与客户端常量逐字一致」。负向对照 6 条(含新增:把 404/405 → no-host 判定拆掉 → 重启提示必须变红;把删除路径改坏 → URL 断言必须变红)。
    • 回归:npm run check 全绿 —— 钩子钉 7/7 + 座位钉 38/38 + 右键钉 228/228 + 漂移钉 3/3 + compat-check 91/91 + live-smoke SKIP(需 DSH_SMOKE_URL+token)。
    • 需要重装插件 + 重启 DSH:lib/client.js 与 lib/index.js 都改了,宿主侧只在启动时装配一次 profile。
  • v0.4.20:新增**「删除会话」**——右键会话行即可把会话从磁盘上真正删掉(不是归档)。

    • 动机:既有「归档会话」只把 id 记进 archivedSessionIds,日志文件一个字节不动;用户要的是「真删」。
    • 内核事实(逐个核过,不是猜的):session/* 20 个方法、workspace/* 12 个方法都没有 delete/remove session;workspace/delete 的描述是「移除一个工作区登记,保留文件与会话」;workspace/archiveSession 第一步 await sessionKnown(id),文件删掉后会抛 WorkspaceUnknownSessionError。渲染进程也没有任何工具执行通道(tools/* 只有 tools/message)。社区插件 Geralt4/dsh-plugin-session-delete 的 README 给出同一结论。
    • 实现:宿主半边 lib/index.js 从 4 行空壳变成真正的 host 入口 —— inject = ["connection"],ctx.effect(() => connection.rpc.handle("/ui-tools", handler)) 注册端点 sessionDelete:核 owner 身份 → agents.get(id)?.cancel({kind:"user"}) + workspaceRegistry.stopSessionActivity 尽力停掉活动 → WorkspaceEntity.detachSession 从工作区登记摘除 → sessionPersistence.locate({id, cwd}).path(三级 cwd 解析:sessionQuery.observeSession → persistence.list → 客户端带上的 cwd)→ fs.rm(dirname(path), {recursive:true, force:true, maxRetries:3})。客户端半边则在会话菜单加 danger 项「删除会话…」+ 二次确认,成功后 stopSessionActivity / clearMain / sessions.refresh() 三步收尾让行消失。
    • 安全性(与社区插件的关键分歧):社区那条 POST /api/session-delete 裸挂 webServer.register,而 webServer 的 exact 路由在分发时直接命中 handler,不经过 frontend-static 也不经过 authorizeIndex —— 等于任意网页都能跨站删用户磁盘上的会话。本插件改走 connection.rpc.handle(),它内部 admit() 会先做 Host/Origin 栅栏(403)→ cookie 鉴权(401)→ 给出 principal.kind === "owner" 的 peer,handler 里再显式核一遍。安全边界在内核,不在插件。
    • 守住 R3:入口 inject 一个字节没改(仍是 slots / modelDirectories / sessions / locale / workspaces),connection 只在宿主半边声明,客户端照旧 ctx.get("connection") 可选获取;sessionPersistence / sessionQuery / agents / workspaceRegistry 全部可选 + 能力探测。旧内核上功能六照常可用,「删除会话」会给出可读原因「当前内核未提供插件通道(connection),无法删除会话;请升级内核,或改用『归档会话』」——不静默隐藏、不拖垮 fiber。
    • 删完不调 archiveSession(这一步曾是设计陷阱):archiveSession 会因 sessionKnown 查不到而抛错;改为 stopSessionActivity + (若删的是当前会话)uiWorkspace.clearMain() + sessions.refresh()。api-session/removed 事件只在宿主 Agent 彻底 dispose 时发,而宿主 Agent 是懒创建的,冷会话本就没有 Agent 可 dispose —— 所以 refresh 重拉基线才是通用解。
    • 二次确认:点菜单项不发请求,菜单原地换成 role="alertdialog" 确认条,点名会话、写明不可撤销、危险按钮排最后、默认焦点在「取消」、Esc 可取消。断言钉死「确认前删除请求数 = 0」。
    • 断言(tools/check-context-menu.mjs 109 → 155 条):新增 §15 整节——菜单 7 项结构与「删除会话…」位次/唯一 danger 项、二次确认全流程(取消 / Esc / 默认焦点 / 确认前零请求)、载荷字段、通道名与端点逐字正确、宿主导出常量与客户端一致、宿主用 connection.rpc.handle 且没有裸 webServer 路由、可选服务没进 inject、收尾四步、失败不摘行、通道缺席给可读原因、四个新模块作用域函数不引用 t(R13);另有「宿主路由缺失 → 提示必须指向重启 DSH 而不是 405 原文」与「宿主半边确实注册了 /ui-tools + sessionDelete(不是空壳)」两条静态钉。负向对照 5 条(收尾短路 → 重拉基线必须没发生;通道名少一位 → 静态断言必须报红;.code 写坏 → 提示可读必须报红;no-host 判定拆掉 → 重启提示必须报红;旧写法 capture:true 的 blur 必须报红)。
    • 回归:npm run check 全绿 —— 钩子钉 7/7 + 座位钉 38/38 + 右键钉 152/152 + 漂移钉 3/3 + compat-check 91/91 + live-smoke SKIP(需 DSH_SMOKE_URL+token)。
    • 需要重装插件 + 重启 DSH:本次同时改了 lib/client.js 与 lib/index.js(宿主半边是本次新写的),刷新页面不够 —— 须把 lib/ 同步到 profile 的 node_modules/dsh-ui-tools/,然后完全退出并重启 DSH。宿主侧只在启动时装配一次 profile,不重启则 /ui-tools 通道不存在(此时菜单项仍会出现,并明确提示「请重启 DSH」,不会静默失败)。
  • v0.4.19(历史):把「取不到认证就会静默失败」的真机冒烟链路修通,并补一处浅色主题下的白底白字隐患。

    • tools/live-smoke.mjs 之前根本跑不起来(三处独立缺陷):
      1. 缺会话 cookie 就 401:桌面端 Host 的 BrowserAuth 只接受 /?token=<launchToken>,而 launchToken 是 BrowserSession 构造时 randomBytes 生成的进程内值(存在 WeakMap 里,不落盘、不进命令行),401 文案只有一句 dsh web authentication required; reopen the URL printed by dsh web.。旧脚本拿裸 URL 直接 goto,页面 401 → 插件一个都没加载 → style 标签在位/双按钮座位/设置页 三类断言全红。修法:新增 DSH_SMOKE_COOKIE 环境变量,脚本把 dsh-auth-<hash>=v1.<body>.<sig> 注入 puppeteer 的 cookie jar 后再导航。cookie 可由 .dsh/.credentials.yaml 里 client-connection/browser-session.secret(32 字节 HMAC 密钥)自签:name = dsh-auth- + base64url(sha256(authority)),body = base64url({version:1,authority,issuedAt,expiresAt}),sig = base64url(HMAC-SHA256(secret, body)),authority 就是 Host 头(如 127.0.0.1:51519)。已实测可用(GET / 返回 200)。
      2. puppeteer-core 只找 ~/.dsh/profiles/web:本机早已没有 web profile,解析永远失败 → 脚本直接 SKIP 退出(exit 0),看起来「没报错」,其实是什么都没测。修法:解析顺序改为 DSH_SMOKE_PROFILE → web → desktop → default → acp → 本仓库。
      3. 设置中心入口选择器过时:内核把「设置」挪进了账号下拉菜单,button[data-slot=sidebar.settings] 现在只负责打开菜单,点它不会进设置中心;旧脚本因此拿不到 [data-set-root]。修法:先点槽位按钮开菜单,再点菜单项 [role=menuitem](其 textContent 是 设置Ctrl+Alt+,,快捷键作为子节点拼接,不能用精确相等匹配),最后点左侧导航项。顺带把写死的「三个偏好行」改成逐行点名(工作区徽章 / 默认折叠 / 输出速度计 / 右键菜单,缺哪行报哪行,行数变化不误报)。
    • 修复结果:真机冒烟 12/16 → 23/23 全绿(含「功能一双按钮座位真的渲染 {"seat":1,"btn":2,"fallback":null}」「设置页四个偏好行都在」)。
    • 白底白字隐患:demo/two-button-model-open.html 的面板根规则还留着 color: var(--dsw-alias-label-primary),而该 token 在本机浅色主题下解析成 #f9fafb(近白)——落在写死的实底 #f8f9fa 上等于隐形(浅色主题下整张列表看不见字)。修法:面板根 color 字面写死 #1b1d20,并给 .mss-row 补浅色/深色两条字面 color,与 v0.4.18 建立的面板自洽体系对齐;lib/client.js 里那段「浅色取 label-primary」的旧注释同步改正。
    • 不受影响:lib/client.js 面板根 color 本来就是字面 #0f1115 / 深色 #f9fafb,真机计算样式实测 panelBg=rgb(44,44,46)、backdrop-filter=none、分组标题 rgb(185,190,198)、选中底 rgba(90,140,250,.22),全部符合预期。
    • 关于用户截图里那个半透明列表:那不是本插件——是内核工作区选择器的 MenuSurface(data-menu-material="translucent" + --dsw-menu-surface-fill 半透明 + blur(40px))。插件面板自 v0.4.13 起就是实底。
    • 需要刷新页面:本次只改 demo/ 与 tools/,不动 lib/client.js 的 CSS 逻辑,已装插件无需重装;看 demo 直接刷新即可。
  • v0.4.18(历史):修掉功能一弹出列表的「文字不自适应」(用户实测:按钮弹窗的列表文字发灰、选中项看不清)。

    • 问题:v0.4.16 只把面板根节点的文字换成了字面值,而面板里的子元素仍在引用内核 token:.mss-group-title / .mss-empty / .mss-loading 用 --dsw-alias-label-tertiary,.mss-row .mss-desc 用 --dsw-alias-label-caption。查内核主题源码(@deepseek-ai/dsh-client-ui-theme/lib/client.js)得两组 token 的两套取值:label-tertiary 浅色 #81858c / 深色 #adb2b8,label-caption 浅色 #adb2b8 / 深色 #81858c。而本插件面板实底是自己写死的 #f8f9fa / #2c2c2e,两套体系一不同步就翻车:浅色面板 #f8f9fa 配 #81858c 只有约 3.3:1,11px 小字直接发灰;深色面板 #2c2c2e 配 caption #81858c 约 3.2:1。同一根因还带出三个隐性可见问题:选中底用 color-mix(in srgb, var(--dsw-alias-brand-primary) 12%),而本机 brand-primary 解析成近黑(bluish-1000),浅色面板上那层选中底几乎看不见;勾选色用 --dsw-alias-brand-text(本机同样近黑),在深色面板上等于隐形;hover 底用 --dsw-alias-interactive-bg-hover(浅色 #2631480f),是给内核表面调的,落在本插件实底上偏薄。
    • 改法:把面板内每一个文字层(面板根 / 分组标题 / 选项描述 / 空态+加载态)与三种交互底(hover / 选中 / 选中+hover)以及勾选色全部改成字面值、深浅各一套,用 [data-ds-dark-theme] 属性选择器切档(比 light-dark() 兼容性稳,DSH 桌面端不保证 Chrome 123+)。取色都按对各自实底的 WCAG 对比度反推:分组标题 #5f6469 浅 5.67:1 / #b9bec6 深 7.46:1,描述 #6a7079 浅 4.74:1 / #a7adb6 深 6.17:1,勾选 #4176e6 浅 4.02:1 / #6a9bff 深 5.14:1,选中底浅 rgba(65,118,230,.12) / 深 rgba(90,140,250,.22)。由此面板配色完全自洽,不再与内核任何一次主题改动产生牵动。
    • 为什么不用 var(--x, fallback) 兜底:兜底只在 token 缺席时生效,而本机 token 恰恰是「存在但取值不适合这套实底」,兜底永远轮不到。
    • 断言(tools/check-model-seat-hug.mjs 32 → 38 条):新增 §6.1 六条按实际解析出的颜色算 WCAG 对比度的断言——分组标题/选项描述/空态浅深各 ≥ 4.5:1、选中底深浅 alpha 必须可见、勾选色 ≥ 3:1、面板范围内不许再出现任何 label-* / brand-* / interactive-bg 内核 token(负向兜底:想改回引 token 立刻变红)。同时把两条旧断言从「必须写某个 token」改成按实际颜色判定(深色面板文字按相对亮度 ≥ 0.6、面板根文字对实底 ≥ 4.5:1),并修正了旧版把半透明交互底误当成「面板底色」判红的取样范围 bug(只认选择器最后一级就是 [data-mss-panel] 的规则)。
    • 需要刷新页面:本次改了 lib/client.js 的 CSS,属于客户端 bundle 变更。
  • v0.4.17(历史):只动断言工具,插件运行时(lib/client.js / lib/index.js)一行未改。

    • 问题:tools/compat-check.mjs 的「未知依赖模块兜底桩」写成 new Proxy({}, { get: () => () => ({}) }) —— 任何属性都返回一个普通函数。内核 bundle 里有 class X extends SomeBase 这类写法,而普通函数不可当基类,于是抛 Class extends value () => ({}) is not a constructor;该异常又被外层的 catch 静默吞掉,整段 B3 判为空,最终对外表现成「本机找不到内核副本,本节跳过」。这是断言工具自己的假绿,不是内核缺失。
    • 改法:换成「可调用 + 可构造 + 任意属性返回自身」的 Proxy 兜底(apply 返回自身、construct 返回空对象、prototype 返回空对象、then/toJSON/Symbol.toPrimitive 返回 undefined 以免被当成 thenable),并补齐 react-dom 与 useLayoutEffect / useReducer / useTransition / useDeferredValue / useId / useDebugValue / createContext().Provider 等形状。
    • 效果:断言数 86/87(B3 整组被吞)→ 91/91,五个真内核断言全部真跑:流式增量后节点物化为 visible、settleAssistant 收尾不抛 withdrew、收尾后节点转 hidden、多回合连续会话全程无异常、每回合都重新物化出可见读数。
    • 不需要重新构建或刷新:本次只改 tools/ 下的校验脚本与文档,客户端 bundle 无变化。
  • v0.4.16(历史):功能一两个主按钮点开的面板改成真正写死的实底(用户第二次确认:「两个按钮直接改成实底的,不用加设置项」,即不加开关、直接落成默认行为)。

    • 为什么要再改一次:v0.4.13 已经把毛玻璃(color-mix(...) + 两条 backdrop-filter)换成「不透明实底」,但底色取的是 var(--dsw-specific-menu)。查内核主题源码(@deepseek-ai/dsh-client-ui-theme/lib/client.js)后发现这个 token 并不是稳定实色:它在非 darwin 平台上就是 --dsw-menu-surface-fill,取值为 #f8f9fa94(浅色,约 58% 不透明)/ #43454a73(深色,约 45% 不透明)—— 引用它等于把半透明底重新引回来,面板会透出背后的 composer 与输入框文字。这也正好解释了用户看到的「弹出是透明背景」。
    • 改法:基础规则写死 background: #f8f9fa(= 内核浅色 --dsw-static-neutral-bluish-50);另加一组深色主题规则 html[data-ds-dark-theme] [data-mss-panel], [data-ds-dark-theme] [data-mss-panel] { background: #2c2c2e; color: var(--dsw-alias-label-primary-foreground, #fff); }(#2c2c2e = 内核 --dsw-static-neutral-bluish-850),同时把深色下的面板文字换成浅字——否则深底配基础规则里的近黑字会看不清。描边 / 圆角 12px / 三级投影原样保留,浮层与 composer 仍有明确分层。不加任何设置项,也不挂开关。
    • 断言(tools/check-model-seat-hug.mjs 28 → 32 条):新增「底色必须是字面实色、不许引用 --dsw-specific-menu / --dsw-menu-surface-fill」「必须存在带 data-ds-dark-theme 的面板实底规则且不含半透明」「深色规则里必须把文字换成 label-primary-foreground」三条;原来那条「底色不许含 transparent / rgba 半透明」保留(负向:改回引用菜单 token → 立刻变红)。
    • 同批第二个要求:两个主按钮加淡描边 + 文字色自适应(用户第二次要求:「加淡描边,文字颜色自适应」)。
      • 描边:border 由 none 改为 1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.12)) —— 该 token 深色为 #ffffff1f、浅色为 #0000001a,两套主题都有可见但极淡的轮廓。
      • 文字色去向:color 由 v0.4.13 写死的 #fff 改为 var(--dsw-alias-label-primary, inherit)。这个 token 本身是主题自适应的:浅色解析 --dsw-static-neutral-bluish-1000(近黑)、深色解析 --dsw-static-neutral-bluish-50(近白)。顺带修掉一个潜伏缺陷:写死白字在浅色主题下会变成「白字 + 近白 composer 底」直接隐形(v0.4.13 的注释里自己就写着「若日后切浅色要改回来」,这次直接按自适应解决)。
      • 底色仍是 transparent:描边已经提供可点性提示,不再加实心底;hover / 展开态的半透明反馈保持不变。
      • 断言改动:§5 由「常态文字色必须纯白」整节改写为 v0.4.16 口径 —— ① 文字色必须是 --dsw-alias-label-primary(若为纯白直接判红并说明「浅色主题下会隐形」);② 必须有 1px solid var(--dsw-alias-border-l2, ...) 淡描边(border: none 判红);③ 全表不得再出现任何纯白 color 规则;④ 箭头仍 color: inherit;⑤ 描边/文字色规则不得牵连面板下拉项。条数 31 → 32。
    • 预览同步:demo/two-button.html、demo/two-button-model-open.html、demo/two-button-provider-open.html 里陈旧的 color-mix(... 90%, transparent) 一并换成 #f8f9fa(面板),遗留的 backdrop-filter 删掉,按钮补 1px 描边、文字色改 token,并在深色 .composer 内覆写 --dsw-alias-label-primary / --dsw-alias-border-l2 为内核深色取值,避免预览页出现近黑字配深底。
    • package.json 版本 0.4.15 → 0.4.16。只改 lib/client.js、tools/check-model-seat-hug.mjs、三份 demo 与文档(客户端 bundle,无构建步骤),刷新页面或等热更新即生效。
  • v0.4.15(历史):修掉功能六(右键菜单)两个互相独立的真实缺陷 —— 用户实测「右键工作区 → 复制工作区路径」与「刷新页面」两项都没反应(两条抱怨其实是两个不同的 bug,第 ① 条还会让所有菜单项一起失效)。

    • ① 所有菜单项都点不动:视口监听写成 window.addEventListener("blur", onViewportChange, true)。blur 不冒泡,但有捕获阶段,挂在 window 上的捕获监听因此会收到元素级失焦:按下鼠标时焦点从菜单第一项(菜单打开时 first.focus() 留在它身上)移到被点的那一项,前一项触发 blur → closeCtxMenu() 把菜单节点从 body 摘掉 → 紧接着的 pointerup / mouseup 落到菜单背后的列表行上 → click 永远不会派发。真机 HUD 轨迹是铁证:C:pointerdown>span IN_MENU ON_ITEM → C:mousedown>span → C:pointerup>div.hIlkoa_projectRow[rk=workspace:…] → MENU_CLOSED,全程没有 C:click;同一机制也解释了方向键导航「一按菜单就消失」(next.focus() 引发前一项 blur)。
      • 修法:只监听窗口自身失焦 —— const onWindowBlur = (event) => { if (event.target !== window) return; closeCtxMenu(); }; + window.addEventListener("blur", onWindowBlur)(不加 capture);resize 仍保留捕获监听,拆卸段对称 removeEventListener("blur", onWindowBlur)。
    • ② 刷新页面只弹一条 t is not defined、页面纹丝不动:ctxReload() 位于模块作用域,函数体第一行 ctxToast(t("toast.reloading")) 却引用了 installContextMenu(scope, t, uiWorkspace) 的形参 t —— 那个作用域里根本没有 t,ReferenceError 又在 try 之外抛出,于是 window.location.reload() 与 150ms 的 location.replace 兜底一次都没跑到,只剩菜单项 catch 弹出的那条报错(真机截图里的 toast 文案就是 t is not defined)。
      • 修法:ctxReload(reloadingText) 改为收参数,三个调用点(选中文本 / 工作区行 / 会话行)统一传 ctxReload(t("toast.reloading"))。
    • 断言(tools/check-context-menu.mjs 94 → 109 条):新增 §8b(元素级 blur 不许关菜单、窗口自身失焦仍要关;负向对照:改回带 capture 的写法必须变红)与 §8c(点「刷新页面」不抛异常、window.__reloaded === true、toast 含「刷新」;负向对照:ctxReload 改回引用 t 必须复现 t is not defined 且不刷新);§14 新增静态断言「ctxReload 剥注释后不得出现 t(」「三处调用点必须传 t("toast.reloading")」「window 的 blur 监听不得带 capture」。顺带修掉测试台一个从未被发现的自身缺陷:4 处负向对照沿用上一节的 DOM,querySelector(".dsh-ctx-menu") 命中残留的旧菜单节点(点到旧闭包),负向对照因此恒以「没弹菜单」假红 —— 改为每节重建 DOM 后才真正生效。
    • 负向对照实测(本轮真跑,不是推断):基线 109/109;把 blur 监听改回带 capture 的旧写法 → 106/109;把 ctxReload 改回引用 t → 106/109;把三个调用点改回无参 ctxReload() → 107/109。三组全部真红,改完即还原(文件 SHA256 与部署版一致)。
    • 真机验收(真实鼠标输入,非消息注入):复制工作区路径 → 剪贴板由哨兵值变为该工作区真实路径(D:\888yhxy);刷新页面 → 抓到底部「HARNESS / Loading plugins…」启动画面、约 1.4s 后窗口标题恢复为会话名、无错误对话框、宿主 boot 标识不变。npm run check 退出码 0:钩子钉 7/7 + 座位钉 28/28 + 右键钉 109/109 + 漂移钉 4/4 + compat-check 101/101 + live-smoke SKIP。
    • ⚠ 真机踩坑(热更新 vs 整页 reload):把 profiles/desktop 里的实装 client.js 就地改掉之后,靠 DSH 对插件 bundle 的热更新(约 10s 自动重跑新代码、不必刷新页面)验证是安全的;但此时若再触发整页 reload,shell 的 boot 审计会报 dsh-ui-tools: import failed (see console for the import error) 并弹出「应用无法启动」原生对话框(logs/crash-*-web-boot.log 只有摘要行、没有底层异常栈),而冷启动同一份文件完全正常 —— 怀疑是宿主进程启动时算出的插件 rev 与磁盘现文件不一致。结论:改完实装文件后,要么只靠热更新验证,要么连宿主一起重启,别在改完实装文件的当下立刻整页 reload。
    • package.json 版本 0.4.14 → 0.4.15。只改 lib/client.js、tools/check-context-menu.mjs 与文档(客户端 bundle,无构建步骤),刷新页面或等热更新即生效。
  • v0.4.14(历史):修掉功能六首发后暴露的两类真实缺陷(详见下条 v0.4.13 的「首发后修复」)。

  • v0.4.13(历史):新增功能六「会话 / 工作区列表右键菜单」——插件由五功能变为六功能。

    • 来源:用户要求「加个右键功能」,参考社区插件 @baihejiangnan/dsh-session-context-menu。只借鉴能力面与那条 data-row-key 判据经验,代码完全自写:不 import / 不 require / 不引用对方包名与全局注册表(dsh.session-context-menu.extensions)、不依赖对方自定义事件(dsh:session-context-menu)——四条都由断言钉死,防止以后手滑把参考插件变成硬依赖。
    • 菜单内容:会话行 6 项(重命名 / 归档会话 / 创建会话分支 / 复制会话 ID / 复制工作目录 / 刷新页面);工作区行 4 项(新建会话 / 复制工作区路径 / 归档工作区全部会话 / 刷新页面);选中文本时 2 项(复制所选文本 / 发送所选文本到输入框)。
    • 判据不依赖界面语言:用官方写死的 data-row-key 前缀(session: / workspace:)识别目标,而不是「这一行有没有『会话操作』按钮」或 aria-label —— 官方 row-actions 在空白会话 / 已归档会话上不渲染,靠按钮存在性判定会把这类行判成非会话行(该坑由参考插件注释指出,此处按同一结论自写实现)。
    • 绝不猜:解析三级降级(DOM 行 id → aria-selected→current → 标题唯一命中),任何一级无法唯一确定就返回 null 并保留浏览器原生菜单;标题命中多个会话时同样拒绝——宁可不管,也不操作错会话。
    • 纯 DOM,守 R2/R3:只挂 document 级 contextmenu / pointerdown / keydown 三个监听,菜单 appendChild 到 body,不注册任何 slot、不搬动也不包裹 slot 节点、无 MutationObserver、无轮询定时器;用到的 sessions / workspaces 本就在入口 inject 内,官方 uiWorkspace 走可选获取(ctx.get,缺席时降级)——不往入口 inject 里加任何可选服务。因此旧内核上照常可用(无 alpha 依赖,不进 alphaFeatures 子 fiber)。卸载时三类监听全部解绑,不留补丁。
    • 健壮性:动作抛错被捕获并转成用户可见提示(不冒泡成未捕获异常);已 preventDefault() 的事件不再接管(与其他右键插件共存);空菜单被拒绝(不弹空白方块);危险项独立样式类。无障碍:role=menu/menuitem、roving tabindex、Esc/方向键/Home/End、点击外部关闭。
    • 新增设置页开关:「会话 / 工作区列表右键菜单」(ctxMenuEnabled,默认开)。关掉时一根监听都不挂,浏览器原生右键菜单原样保留;门控显式订阅偏好仓库(prefs 是普通 Set 仓库、不是 cordis 追踪服务,scope.effect 不会自动收集它),所以改开关即时生效、无需刷新页面,卸载时订阅与监听一并清理。
    • 新增守护断言 tools/check-context-menu.mjs(首发 82/82,本批加固后 94/94)并纳入 npm run check:手写 DOM 测试台,从 bundle 里按花括号配平取出真实函数注入沙箱执行,并求值 CTX_ZH 词典以核对用户真正看到的文案;覆盖判据/降级/组装/分发/键盘/拆卸/真实服务调用/异常吞掉/不引用参考插件/CSS 合并/开关门控。
    • 断言自身的两个坑(已修,值得记下):① 「代码里不许出现 X」类断言必须先剥离注释——文件顶部与历史注释里的「参考插件:@baihejiangnan/…」「无 MutationObserver」会被当代码判红(3 条假阳性)。② 负向对照不能靠给 env.<fn> 重新赋值来改坏判据——所有函数注入同一闭包作用域,内部调用走词法绑定,赋值影响不到;必须改源码文本后重新编译沙箱(原写法产出的是结构性假绿)。
    • 顺带(同一工作区内的用户要求):把功能一两个主按钮(供应商 / 模型)的常态文字色改为纯白 #fff——本机 DSH 主题偏好是 system,而 Windows AppsUseLightTheme=0(深色),composer 底为深色,白字对比最高;.mss-caret 用 color: inherit,箭头随字一起变白。不引主题 token而是写死白:--dsw-alias-brand-text 在本机解析为 #0f1115(近黑)、--dsw-alias-label-primary-foreground 也只在浅色主题下是白,两者都会随主题解析成非白,与用户明确要求的「白色」不符。tools/check-model-seat-hug.mjs 新增 4 条断言钉住「常态必须纯白 / 白字规则不外溢到面板 / 箭头随字变白 / 下拉项不被染白」(19 → 23 条);demo/two-button*.html 三张预览页同步改白字,并把预览用输入框底改为深色 #17181c(否则白字在浅色预览页上看不见)。
    • 弹出列表改为完全不透明实底(同批用户要求):功能一两个按钮点开的面板原是「90% 菜单色 + 14px 背景模糊」的毛玻璃(color-mix(in srgb, var(--dsw-specific-menu, #fff) 90%, transparent) + 两条 backdrop-filter),现改成 background: var(--dsw-specific-menu, #fff) 实底并删干净 blur——半透明底会把背后的深色 composer 与输入框文字透上来,读菜单时干扰明显。描边(--dsw-alias-border-l2)、圆角 12px、三级投影一律保留,否则实底菜单会「贴」在输入框上分不清边界;面板文字仍是 --dsw-alias-label-primary(深色字配浅色实底),不跟着按钮变白。check-model-seat-hug 再加 5 条断言钉住「底色不许含 transparent / 半透明、两条 blur 必须删干净、描边圆角投影必须还在、文字不许变白、面板基础规则唯一」(23 → 28 条)。
    • ⚠ 首发后修掉一个「全功能消失」级事故(同为用户报障,最贵的一次):首发版把功能六需要的官方 uiWorkspace 服务(由 dsh-client-ui-workspace 提供)直接加进了入口 inject。入口 inject 里的任何服务缺席都会让整条 fiber 停在 PENDING 等它,而这个服务在精简 bundle 里可能不存在 —— 于是不是「右键菜单少个功能」,而是五个功能全部消失 + 顶部加载横幅,boot 审计报 dsh-ui-tools: pending (waiting for services: uiWorkspace)。这与 v0.4.2 在 uiConversation 上踩的是同一个坑(见 R3)。用户看到的表象是「右键菜单里的项都没效果」,极易误诊成菜单自身的事件 bug —— 实际菜单根本没装上。
      • 修法:uiWorkspace 移出入口 inject,改为可选获取 —— 函数体里 ctx.get("uiWorkspace")(cordis 的 get() 在服务缺席时返回 undefined 而不抛错),配合 typeof svc?.method === "function" 能力探测干净降级,拿不到时给用户明确提示。现在 ctx.inject(["sessions","workspaces"], (scope) => { const uiWorkspace = ctx.get("uiWorkspace"); … })。
      • 已钉死:把 "uiWorkspace" 塞回入口 inject,compat-check 从 101/101 掉到 50/85,并精确报出 pending (waiting for services: uiWorkspace)。
    • 同一批修掉的动作失效:新建会话 / 复制工作区路径 / 刷新页面 / 归档 / 分叉 曾因调用内核里不存在的方法(workspaces.startSession、sessions.open)而静默失效(异常被 ctxToast 吞掉 = 用户看到「没反应」)。全部改调真实存在的官方方法:uiWorkspace.startSession / uiWorkspace.openSession;刷新页面 走自写的 ctxReload()(location.reload() + 兜底)。
    • 新增守护断言 tools/check-context-menu.mjs(82/82 → 94/94):新增第 14 节 服务契约——① 列出真实方法表(sessions / workspaces / uiWorkspace 各自真实存在的方法名)逐条核对源码没有调用表外方法;② 点名两条历史写错的(sessions.open、workspaces.startSession);③ 两组运行时负向对照。这一节的由来值得记:首发时 §8 的假服务把 startSession 挂在 workspaces 上、把 open 挂在 sessions 上,测试台照着一个不存在的契约写,于是断言把 bug 固化成了「正确」、全绿放行 —— 断言的价值上限 = 假数据对真实契约的还原度。
    • 回归:npm run check 退出码 0 —— 钩子钉 7/7 + 座位钉 28/28 + 右键钉 94/94 + 漂移钉 4/4 + compat-check 101/101;行为矩阵「新内核 5/5 槽位功能(功能六为纯 DOM、无 slot,不参与该矩阵)」。(三组负向对照均真红:uiWorkspace 塞回 inject → compat-check 50/85;改调 workspaces.startSession → 右键钉 91/94;删掉 location.reload() → 右键钉 93/94。)
    • package.json 版本 0.4.12 → 0.4.13。只改 lib/client.js、tools/ 与文档(客户端 bundle,无构建步骤),刷新页面即生效。
  • v0.4.12(历史):一次全量审查后的稳定性加固——修掉两处同类崩溃隐患、一处样式重复注入,并把三个真实事故固化成守护断言。

    • ① 两处「条件 return 夹在 Hook 之间」崩溃隐患(本次核心):ModelSeatSplit 曾把 if (!available) return null; 写在两个 useEffect 之间;WorkspaceNameChip 曾把 if (!prefsState.chipEnabled) return null; 写在 useWorkspaces 之前。二者是同一类缺陷:React 从不清空 hook 数组,只按 hookIndex++ 覆盖,所以条件为假的那次渲染里后续 Hook 槽位残留上一次的值——若上次该槽是 undefined,卸载时 React 执行 destroy() 会抛 undefined is not a function。触发面真实存在:available = usable && face.ok,而 face.ok 来自 try/catch 的 mssResolveDirectory(directoryFor() 有缓存早返回,available 会随会话摇摆);chipEnabled 则是设置页开关,用户随时可切。现两处提前返回一律移到全部 Hook 之后,判据收进回调内部与选择器;useSyncExternalStore 在 available=false 时照常调用(拿到的是恒定的 MSS_EMPTY_DIRECTORY,合法且稳定)。
    • ② 功能二样式重复注入 + 一个差点酿成回归的耦合:功能二原用独立 <style id="dsh-ui-tools-wc-collapse-style"> 注入 WC_CSS,而共享标签此前漏拼了 WC_CSS(tag.textContent = MSS_CSS + WSC_CSS + SET_CSS + TSP_CSS)——即同一份规则实际由两条路径各自注入、且两个标签由不同 effect 独立拆卸(卸载中间帧折叠条样式会闪断)。现 WC_CSS 并入共享标签统一注入,独立标签与其 teardown effect 一并删除。⚠️ 注意这两处是强耦合的:只删独立标签而不补共享标签,会直接把功能二的样式全部丢失——该耦合已由「共享标签含全部五份 CSS 哨兵」断言钉死。
    • ③ 减少多余请求:功能一的 load() 依赖数组由 [open, available, load] 收窄为 [available, load],不再在每次开合面板时重复拉取目录。
    • ④ 可读性:chooseModel / chooseEffort 里 ...cond === void 0 ? {} : {...} 靠运算符优先级生效的写法(语义正确但极易误读)改为显式条件赋值,行为完全一致。
    • 新增两份守护断言并纳入 npm run check:tools/check-hooks-order.mjs(7/7,含双向负向对照)与 tools/check-profile-drift.mjs(6/6);compat-check.mjs 新增 3 条样式断言(样式标签有且仅有一个、五份 CSS 哨兵齐全、废弃 id 不再出现),断言总数 98 → 101。
    • 修复 profile 安装漂移:~/.dsh/profiles/web/pnpm-lock.yaml 的 specifier 与 version 字段均停在 v0.4.10(而 package.json 已声明 #v0.4.11),下次 pnpm install 会把插件回退;现已同步为 #v0.4.11 / 0a75dde,并把 v0.4.12 构建部署进 desktop 与 web 两个 profile(各自留 client.js.bak-v0411 备份),三处 client.js 字节一致(SHA256 8A1F25BD…)。
    • 订正文档:.scratch/check-client-inject.mjs 头部用法路径由 node tools/... 订正为 node .scratch/...(原文按该行命令跑必然 ENOENT);compat-check.mjs 里「此前漏掉 0.1.6-alpha.2 / 0.1.5-rc.3」的注释与代码不符(列表里本就有),改为只陈述现状。
    • 回归:npm run check 退出码 0 —— 钩子钉 7/7 + 座位钉 19/19 + 漂移钉 6/6 + compat-check 101/101;三条负向对照均实测变红(塞回 Hook 中间的 return、从共享标签删掉 WC_CSS、把 lock specifier 改回 v0.4.10)。package.json 版本 0.4.11 → 0.4.12。只改 lib/client.js、tools/ 与文档(客户端 bundle,无构建步骤),刷新页面即生效。
  • v0.4.11(历史):把功能一(模型双按钮)里两个主按钮的背景色显式指定为透明,并做一次全仓库口径扫尾。

    • 透明背景(用户要求「两个按钮的背景色指定为透明框」):MSS_CSS 里 [data-mss-btn] 的常态 background 由 color-mix(in srgb, var(--dsw-alias-brand-primary, #4176e6) 8%, transparent) 改为 transparent——不再有那层 8% 品牌色底,视觉上是输入框里两个「纯文字 + 箭头」的透明框,与官方 composer 其它无底控件连成一片。保留交互反馈::hover 与 [aria-expanded="true"] 仍是 16% 半透明淡色(按钮若无任何按压/展开反馈就失去可点性提示)。若后续要求连悬停也全透明,把那两条的 background 一并改成 transparent 即可。
    • 新增 5 条守护断言(tools/check-model-seat-hug.mjs,14 → 19 条):这条需求的失败模式是「改完刷新页面看不出差别」,只读源码文本抓不到(写回 color-mix 照样是合法 CSS),所以断言沿用它:求值后的 CSS 按 cascade 取实际生效声明。钉住四面:① 常态背景必须是 transparent;② 没有另设不透明的 background-color 把它盖掉;③ :hover 与 [aria-expanded="true"] 各自仍保留半透明反馈(防「改一处、静默坏另一处」);④ 该规则不得牵连面板内的下拉项([data-mss-row] 不命中)。
    • 扫尾 ① 验证口径数字漂移:README 与 AGENT.md 里的 compat-check 计数/内核路径由 93/93 + 0.2.1-alpha.1 更新为 98/98 + 0.2.1-alpha.2(本机现存内核副本为 0.2.1-alpha.1 / 0.2.1-alpha.2)。
    • 扫尾 ② 已删功能说明残留:删掉「明确不覆盖的范围」里那条仍在讲「修改的文件」tab 取数逻辑的说明(该功能 v0.4.9 已整体移除),改为一句指向变更记录的口径说明。
    • 扫尾 ③ tools/live-smoke.mjs 残留清理:清掉 16 处已删功能痕迹——MARKERS.modifiedFiles([data-mfs-root])、pageerror 过滤里的 mfs、整段「2b 功能三取数」断言、设置页「四个偏好行」与「修改的文件行」断言、demo 段误读的 row-mfs/mfs-toggle/mfs-hint(真实 demo 里这三个 id 早已改为 row-alpha/alpha-toggle/alpha-hint,原样跑会直接抛异常),功能编号由「功能六」校正为「功能五」;文件头过期注释(v0.4.2 验证用、内核 0.1.2-alpha.3)一并更新,并补上 DSH_SMOKE_URL 环境变量入口与 SKIP 口径说明。
    • 扫尾 ④:issue #1(v0.4.7「撤回异常」归因)技术上已在 v0.4.8 闭环(现象真实、归因证伪),本次核对后关闭。
    • 回归:npm run check = 座位钉 19/19 + compat-check 98/98 全绿;live-smoke.mjs 语法与 SKIP 分支实跑通过(无 URL+token 时 exit 0,不阻塞 npm run check)。package.json 版本 0.4.10 → 0.4.11。只改 lib/client.js 的 CSS 段与文档/工具(客户端 bundle,无构建步骤),刷新页面即生效。
  • v0.4.10(历史):把输入框的模型双按钮往右推、紧贴右侧的「听写」按钮,并顺带修掉一个潜伏的真实 bug(该 bug 会让官方模型按钮不再被隐藏、与插件座位同时出现)。

    • 紧贴的实现(lib/client.js 的 MSS_CSS,纯 CSS、不动注册逻辑):官方 composer 右侧动作区的结构是 div.trailing(gap:12px; margin-left:auto)下并列 div.standardControls(座位所在的槽的父容器)、div.activity(听写按钮的槽)、button.primary(发送)。座位在 .standardControls 内,听写按钮在兄弟容器 .activity 内 —— 纯 CSS 没有「叔侄选择器」,:has() 也只能向下或向兄弟,因此无法直接互相定位。可行做法是把座位的父容器连同座位一起推到最右、并把两个容器之间那段来自 trailing 的 gap 收掉:
      div:has(> [data-mss-seat]) { margin-left: auto; }
      div:has(> div > [data-mss-seat]):has(+ div [aria-label="开始录音"]),
      div:has(> div > [data-mss-seat]):has(+ div [aria-label="Start recording"]) { column-gap: 0; }
      div:has(> div > [data-mss-seat]) + div:has([aria-label="开始录音"]),
      div:has(> div > [data-mss-seat]) + div:has([aria-label="Start recording"]) { margin-left: 0; }
      
    • 为什么用 :has() 而不写类名:内核用 CSS Module,类名带哈希(当前是 uV2eYG_trailing / uV2eYG_activity),随每次构建变化;aria-label 是官方文案契约(中文 开始录音 / 英文 Start recording,见 dsh-experimental-client-ui-voice-input),data-mss-* 是本插件自己的,两者都稳定。降级:浏览器不支持 :has() 时整段规则被丢弃,座位回到默认 gap:12px,只是「不够贴近」,不会错位或消失 —— 故不需 @supports 兜底。
    • 途中修掉的真实 bug(真机可见):隐藏官方模型按钮的规则写作 ${OFFICIAL_TRIG} 内联进模板串,而该模板串前面的说明注释里出现了反引号,把 ECMAScript 模板串提前闭合,使 ${OFFICIAL_TRIG} 原样留在生成的 CSS 里 → 选择器里出现一个不存在的 $ 元素、隐藏规则整条失效,插件座位与官方控件会同时出现在输入框上。修法:说明注释里不再出现反引号;同时把含引号的选择器常量改为「拼接出来的两半」(OFFICIAL_TRIG_A/OFFICIAL_TRIG_B),避免模板串里出现裸引号触发的同类解析歧义。(OFFICIAL_TRIG 本身保留原样,供 querySelectorAll 使用。)
    • 新增守护断言 tools/check-model-seat-hug.mjs(14 条,已并入 npm run check):手写 DOM 骨架忠实复制内核 InputBar 层次(含听写按钮被 span.triggerAnchor 包裹这一实情),求值(而非只读取源码文本)MSS_CSS 模板串后用自写选择器匹配器核对:座位父容器拿到 margin-left:auto、横缝收到 0、activity 左边距清 0、中英两套文案都命中、无座位/听写缺席时不误伤、CSS 里无内核哈希类名、且官方模型按钮仍被隐藏。本仓库零依赖,不引入 jsdom。
    • 该断言的价值已被证明:它先是抓出产出 CSS 里的一处真实 bug —— 规则写成 :has(+ div > [aria-label=…])(直接子元素)而真实听写按钮嵌在 span 内,导致贴合规则永远不命中;修正为后代匹配 :has(+ div [aria-label=…])。随后又通过「必须求值模板串」这一改动抓出上面那条 ${OFFICIAL_TRIG} 未求值的真 bug(只读源码文本的写法会让该断言恒红,而求值写法能同时覆盖「模板串提前闭合」这类事故)。
    • 回归:npm run check = 新钉 14/14 + compat-check 93/93,全绿。package.json 版本 0.4.9 → 0.4.10,check 脚本加入新钉。只改 lib/client.js(客户端 bundle,无构建步骤),刷新页面即生效。
  • v0.4.9(历史):移除功能三「修改的文件」会话选项卡(conversation.view / ui-tools-modified-files)。移除原因:① 它的取数是同一份 chat target 快照的又一遍解析,与功能五(速度计)读的是同一个 target,属于重复建设;② 维护成本集中在自己实现的 run_code 内嵌路径提取上——这套静态字符扫描只认字面量字符串路径,模板串、变量拼接、shell 级写操作(pwsh Set-Content/重定向、gitbash)全都解析不了,每次内核调整都要跟着修,收益与风险不成比例。

    • 删除范围:MFS_ZH/MFS_EN 文案与 MFS_NS 命名空间、MFS_MUTATIONS 工具白名单、RUN_CODE_TOOL/RUN_CODE_CALL_RE、全部 mfs* 解析函数(mfsReadQuoted/mfsMatchParen/mfsExtractKeyValues/mfsExtractRunCodePaths/mfsNormalizePath/mfsRecordMutation/collectModifiedFiles/mfsRelativeTo)、ModifiedFilesView 组件与 MFS_CSS、mfsSources WeakMap 与 mfsChatSource、ctx.uiSession.provide({ hooks: ["modifiedFiles"] }) 整块、ctx.slots.inject("conversation.view", ...) 整块注册(含 workspaces.openPath 打开链路)、偏好字段 mfsCompact 与设置页对应开关。
    • alphaFeatures 子 fiber 保留、但收窄:inject 由 ["uiConversation", "uiSession"] 改为 ["uiConversation"]——功能三移除后 uiSession 已无消费者(它只被 provide 用),继续声明只会让子 fiber 在「有 target 体系但没 uiSession」的内核上白白停在 PENDING。跨内核降级机制本身完全未变:旧内核上子 fiber 照旧静默 PENDING、入口 entry 照旧 ACTIVE、boot 审计照旧 0 失败、无横幅。共享常量 MFS_CHAT_TARGET/MFS_EMPTY_CHAT 保留(tspChatSource 仍在用)。
    • 偏好数据无需迁移:normalizePrefs 是逐字段白名单,旧 localStorage 里残留的 mfsCompact 会被自然丢弃,不写迁移代码、不升 key 版本(dsh-ui-tools:prefs:v1 不变)。
    • 回归:compat-check 同步删除功能三的 8 条断言,并把「功能三在旧内核缺席」这类单向断言换成三条逆向钉——「任何内核都不得注册 conversation.view」「uiSession.provide 永不被调用」「workspaces.openPath 永不被调用」——防止残留代码悄悄复活。合计 115 → 113 断言全绿(断言数因删除而减少,覆盖却更严)。负向对照有效:把 alphaFeatures.inject 改回 ["uiConversation", "uiSession"] → 场景 C 出现 2 条 FAIL(子 fiber 停在 PENDING、功能五 pill 未到位),111/113、exit 1。package.json 版本 0.4.8 → 0.4.9;插件由六功能变为五功能。
  • v0.4.8(历史):针对 issue #1(「v0.4.7 未修复撤回异常:DSH 0.2.0-rc.1 上 4 个 loader entry 被级联击落」)做三项加固。核实结论:issue 的现象描述真实(确有撤回异常与同 bundle 级联击落),但归因不成立 —— 其 crash log 采集于我安装 v0.4.7 之前(shell pid 14260 启动于 17:57,我的安装完成于 18:26),两份日志记录的都是 v0.4.6 期间的运行;19:08 之后(已装 v0.4.7)的两次启动未再产生 crash log。我进一步从 app.asar 提取 0.2.0-rc.1 的真实 dsh-client-ui-conversation,用它的真装配器驱动插件 Definition:context.current 在该版本存在且语义未变,v0.4.7 通过。故 issue 假设的「context.current 在 0.2.0 上失效」被证伪。

    • ① 修正矛盾注释:tspLiveDefinition 的 doc 仍写「turn/end 置 ended,buildViewNode 随即返回 null」,与 v0.4.7 新不变量(返回同 key + hidden)直接打架,易误导后续维护者。已改为说明正确行为并标注旧写法为何危险。
    • ② 拆分防御分支(issue #1 的核心洞见,实为真问题):原写法把「内核未提供 context.current」与「确认从未物化」合并成同一个 previous = null —— 一旦内核换了字段形态,防御会静默失效、异常照旧复现而不留痕迹。现拆为 canReadCurrent(能否读到)与 previous(已知时上一帧节点)两个判据:读不到时不敢断言「未物化」,一律保守返回同 key + hidden(撤回异常的代价远大于多挂一帧隐藏节点),并只告警一次。
    • ③ 把 0.2.0-rc.1 纳入验证矩阵:0b 的版本表原只列 21 个,漏掉整条 0.1.7/0.2.0 线,使「两种 semver 语义判决一致」断言假绿(旧区间在该 6 个版本上确有分流);现补全为 29 个已发布版本 + 3 个哨兵。同时 B3 由「只测第一个内核」改为逐内核遍历,并新增 app.asar 来源 —— 桌面版大版本升级后真正生效的 client 包在 asar 内,kernels/ 下可能没有对应版本,不读 asar 就等于让最需要验证的那版内核缺席(0.2.0-rc.1 正是如此)。
    • engines.dsh 同步收紧:… || >=0.1.6-0 → … || ^0.1.6-0 || ^0.1.7-0 || ^0.2.0-0 || >=0.3.0-0(两处值一致)。旧写法对 0.1.7-*/0.2.0-* 预发布版在普通语义下全部漏判。
    • 回归:compat-check 90 → 110 断言全绿(B3 现在对 5 个内核各跑 5 条:0.1.7-rc.2/0.1.7-alpha.1/0.1.6-alpha.2/0.1.5-rc.2/app.asar);新增「读不到 context.current 时保守返回 hidden」断言。负向对照有效:把防御分支退回合并写法 → 该断言 FAIL(109/110)。package.json 版本 0.4.7 → 0.4.8。
  • v0.4.7(历史):修复功能六导致回合结束后界面不再刷新、后续发送的提问不显示(真实内核回归,非渲染样式问题)。

    • 根因:功能六的「生成中估算条」是自建 chat 节点 Definition(token-speed-live)。内核 ConversationNodeAssembler.buildTargetUpserts() 的判定是「上一帧 context.current.get(target) 非 null、本帧 buildViewNode 返回 null」→ 抛 conversation Definition "token-speed-live" withdrew materialized target "chat"; return the same key with hidden visibility instead。该异常从 flush() 冒出后整条 chat 视图快照链停更——界面表现为回合结束后不再刷新、之后每次提问都没有新消息出现(0.1.6-alpha.2 与 0.1.7-alpha.1 均已复现;0.1.6 起该校验即已存在,并非新内核新增行为,而是功能六自 v0.4.6 引入以来一直存在、在「本回合确实产生过可见输出」时触发)。
    • 关键难点(只看 state 修不干净的那一半):真机的流式增量是 transient 记录;回合收尾内核走 settleAssistant(),先 retire 全部 transient、再 replayContext() 重放 —— 状态被倒推回 firstTokenAt=null / estTokens=0(并不是 ended=true)。因此「state.ended / 无输出 → 返回 null」这种写法照样触发撤回异常。
    • 修复:判据改用内核给的 context.current(上一帧已物化的节点)——只要它非空就返回同 key 节点、仅切 visibility;仅「从未物化且当前无数据」才返回 null(安全路径)。
    • 为什么此前没测出来:① 真机流式的 transient + settleAssistant 路径未被任何测试覆盖;② 原有断言只调 live.buildViewNode(...) 纯函数、从不传 context.current,等于绕过了内核唯一的撤回判定依据;③ compat-check 用的是假内核,根本没实现该校验。
    • 回归加固:新增 场景 B3「功能六 × 真内核 ConversationNodeAssembler」——从本机内核副本载入真内核装配器 + 真 bundle 截取的 Definition,按真机事件序列(durable 起止 + transient 增量 + settleAssistant)逐帧驱动,断言全程不抛 withdrew、节点 visible↔hidden 正确切换、多回合连续会话(后续提问)依旧刷新;另补 4 条纯函数断言显式传 context.current。compat-check 78 → 89 断言全绿;负向对照有效:把 buildViewNode 换回只看 state 的写法 → 8 条 FAIL(含真内核 settleAssistant 抛 withdrew、后续提问全程抛错、visible 帧数 3→1)。package.json 版本 0.4.6 → 0.4.7。
  • v0.4.6(历史):新增功能六「输出速度计 tok/s」——回合结束后在官方「用时 xx秒」pill 左侧常驻 ⚡42 tok/s(无需点开「本轮用时和速度」弹窗),生成中在消息流末尾显示 ⚡≈ 25 tok/s 生成中估算。精确值与内核同源同口径(ΣoutputTokens ÷ Σ(firstTokenTime→completedTime),只计 timing 齐备的 step),生成中因内核流式期间不上报 token 数(usage chunk 被 publication:"none" 抑制)而按已输出字符折算并明确标注「≈ / 生成中估算」。位置实现:精确 pill 走 list 槽 conversation.chat.assistant-actions(order: -10 → 紧贴「用时」左侧,渲染位即 MessageIconActions.extraActions);生成中走官方 Definition 通道 uiConversation.events.register 自建 token-speed-live chat 节点 + conversation.chat.node 渲染器(turnTail 槽在生成期间不渲染 —— 其数据源必须匹配到 turn/end,故不能用于实时)。排序锚点用大于任何真实 seq 的常量,避免节点跑到流式正文上方。设置页新增开关 tokenSpeedEnabled(默认开,旧内核下灰显)。实现过程中被自测抓到并修复一处真实缺陷:Definition 通道形状不符时原先直接抛出,会让整个 alphaFeatures 子 fiber FAIL、把「修改的文件」一起拖下水 —— 现改为形状探测 + 只让生成中那段缺席。回归:compat-check 49 → 77 断言全绿(v0.4.7 起 89/89)(新增数值口径、除零/空入参边界、Definition 状态机全流程、两组件 DOM 与开关门控),负向对照有效(改坏锚点 / 去掉形状守卫各触发 FAIL);真机 live-smoke 在 0.1.6-alpha.2 上通过(入口无横幅、功能标记在位、设置页开关正常,新增功能六断言)。历史回合可能无读数属预期:firstTokenTime 来自瞬态流式事件、不进持久化日志,重载页面后历史回合的 TPS 内核自身也不显示(同一口径)。package.json 版本 0.4.5 → 0.4.6。

  • v0.4.5(历史):新增机器可读的内核版本约束 engines.dsh(同时写顶层与 dsh.engines.dsh,两者值一致),并修正 README 里「0.1.2-alpha.1 及更新」这一在真实 semver 下不成立的口径。背景:此前本包只在 README 与 package.json description 里用自然语言描述内核要求,没有任何字段可供工具读取——而生态实际消费的字段是 engines.dsh:dshmarket 的 manifestFacts() 读它(缺失时回落 dsh.engines.dsh),在 Discover 页展示宿主兼容性,并在安装/更新前拒绝判定为不兼容的版本。区间写成 ^0.1.1-0 || ^0.1.2-0 || ^0.1.3-0 || ^0.1.5-0 || >=0.1.6-0:不能写成 >=0.1.1-rc.1 或 >=0.1.2-alpha.1——semver 要求预发布版只匹配同 x.y.z 区间,实测 >=0.1.2-alpha.1 不匹配 0.1.5-rc.2(本仓库当前实测内核),>=0.1.1-rc.1 也会漏掉 0.1.2-* / 0.1.5-* 各线;逐条 ^0.1.N-0 覆盖每条已发布线,末项 >=0.1.6-0 兜住后续新线且不硬顶未来内核(误判未来内核会让市场拒绝合法升级)。-0 后缀用于把下界取到该线最早的预发布。compat-check 新增场景 0b(6 条断言,合计 49/49):用内核自带真实 semver 对 npm 上全部 21 个已发布内核版本 + 2 个未来哨兵逐项断言,并要求普通语义与市场侧 includePrerelease 语义判决一致(偏差即失败)。负向对照有效:把区间换回 >=0.1.2-alpha.1 触发 4 条 FAIL(含 0.1.5-rc.2 未被自身声明覆盖)。未改动任何运行期逻辑(lib/client.js 与 v0.4.4 逐字一致);内核兼容行为与 v0.4.4 相同,当前实测内核 0.1.5-rc.2。

  • v0.4.4(历史):修复功能一在 0.1.5-rc.1 内核上每次挂载会话抛一次 cannot get property "remote.session" without inject。根因:内核 ModelDirectoryResolver.directoryFor() 内部要读 this.ctx.remote.session 构造 ModelDirectory,而 cordis 的 Service tracker 会把 service 的 this.ctx 重绑到调用方上下文——所以调用方 fiber 必须自己声明 remote。本插件功能一的 ctx.inject([...]) 只声明了 slots/modelDirectories/sessions,于是每次冷解析(新会话、会话切换)都抛未捕获错误;只因 directoryFor 开头有「已缓存则早返回」的短路,多数时候命中内核自建目录才没炸出可见故障(竞态相关)。修复:① 在该子作用域补声明 remote + remote.session(两者都要:后者是嵌套追踪服务,只补前者实测错误照旧;刻意都不进 loader entry 的 inject,否则会重新引入 v0.4.2 修掉的旧内核 entry 停 PENDING / 五功能全丢);② 加 mssResolveDirectory() 兜底,解析失败不再冒泡成 pageerror,而是降级为「本座位不渲染 + 经 html[data-ui-tools-model-seat="fallback"] 把官方控件放回来」,避免「官方被 CSS 隐藏 + 插件座位缺席」两头皆空。同类语义对等插件 dsh-vision-router 已有防护代码。回归:compat-check 扩到四场景 43 断言(新增场景 D 用「内核不提供 remote」夹住依赖位置;功能一冷解析正向断言),live-smoke 新增 remote.session 与座位真的渲染两条真机断言(只断言「不报错」不够——兜底会把异常吞成 warn 并静默降级),并修复浏览器探测(原先只认 ms-playwright 写死路径,本机没装 Playwright 时静默 SKIP)。

  • v0.4.3(历史,已被 v0.4.9 移除):「修改的文件」支持 run_code 内嵌工具调用路径提取——当前 DSH agent 环境的文件操作全部包在 run_code 里(工具名恒为 run_code,路径藏在 code 字符串),旧逻辑按工具名白名单匹配导致这类会话一律显示「0 个文件」。新增 mfsExtractRunCodePaths:从 code 静态提取 tools.edit/write/mkdir/move/copy/delete… 内嵌调用的字面量路径(含字符串数组参数),按内嵌工具映射回白名单 ops,复用去重/徽标/打开链路。字符扫描解析(无正则拼接),反斜杠转义原样保留(Windows 路径不因 \t/\n 语义被改写);变量拼接、模板串、shell 级写操作(pwsh Set-Content/重定向、gitbash)不计入。compat-check 新增对应断言(当时 33/33 全绿)。v0.4.9 已随功能三整体删除——正是这类易碎逻辑促成了移除决定。

  • v0.4.2(历史):内核版本自适应(软依赖 + 子 fiber 隔离),修复「切到 0.1.1-rc.x 旧内核后 Web UI 顶部 Failed to load plugins / web boot: 1 entry did not activate 横幅、桌面端反复重载」——根因是入口 inject 硬声明了 0.1.2-alpha.1 才有的 uiConversation / uiSession,旧内核无提供方 → 整个 entry 停在 PENDING 被 boot 审计判失败,其余功能一起丢。现在入口只声明五个跨内核服务,功能五(速度计)的整段注册搬进 ctx.plugin(alphaFeatures) 子 fiber(alphaFeatures.inject 承载该硬依赖;v0.4.9 前为 uiConversation + uiSession,功能三移除后收窄为 uiConversation),旧内核上子 fiber 静默停在 PENDING、其余四项照常;另加能力探测(子 fiber 激活即置位 capability.alphaApi)+ 设置页灰显提示「需内核 0.1.2-alpha.1+」+ 关键调用点形状探测与 try/catch 降级;新增 tools/compat-check.mjs 无头回归(真实 cordis,三场景)与 npm run check。行为与验收见内核兼容矩阵。

  • v0.4.1(历史):按反馈移除 v0.4.0 的「composer 快捷命令条」——删除 conversation.composer.dock 注册、QCB 命名空间/CSS、偏好字段 quickbarEnabled/quickbarItems 及设置页快捷条编辑器;「DSH UI 工具」设置页保留(仅布局偏好:徽章开关 / 启动默认折叠 / 修改文件紧凑显示——后两项里「修改文件紧凑显示」已于 v0.4.9 随功能三移除),插件回归五功能。

  • v0.4.0(历史):新增「composer 快捷命令条」——注册进官方 conversation.composer.dock(list 加性,order 10),输入框下方一排常用命令 chip,点击 = inputActions.setDraft + submit 填入并发送,命令列表可在设置页自定义;新增「DSH UI 工具」设置页——注册进官方 settings.section(root list 槽,id dsh-ui-tools、order 100),集中开关四个既有功能 + 快捷命令条,「修改的文件」新增紧凑单行模式、侧边栏新增「启动默认折叠」偏好;全部偏好经 localStorage 持久化(dsh-ui-tools:prefs:v1,沿用 dsh-better-sidebar 同款社区惯例;官方 settings 命名空间需 host 侧注册 schema,本插件保持纯浏览器故不采用)。v0.4.1 起快捷命令条已移除。

  • v0.3.4(历史):修复 DSH 0.1.2-alpha.1 上「折叠/展开」工具条错位(与余额挤在一行、「展开全部」被裁剪)——旧 CSS 里硬编码的侧边栏哈希类名 .hHd-Xa_footerActions 已失效,且 renderer 给 slot 套的 div[data-slot] 是 display:contents(不参与布局),原 :has 换行规则命中也无效。改用哈希无关的 [class*="footerActions"]{flex-wrap:wrap !important} 命中真实布局容器,内核升级不再失效。

  • v0.3.3:真正修复 DSH 0.1.2-alpha.1 上的加载失败(Failed to load plugins / client-modules: require(...) missed the module table)——client bundle 不再 require @deepseek-ai/dsh-client-runtime/client:resolveWorkspacePath 按官方 dsh-client-ui-chat 的做法内联进 bundle,空快照 MFS_EMPTY_CHAT 代替 EMPTY_CHAT_SNAPSHOT(MFS_EMPTY_CHAT 在 v0.4.9 功能三移除后由速度计继续使用)。该内核的 client 模块表严格校验,runtime 未必是 profile 的 graph 行,v0.3.2 用 dsh.client.inject 登记的方案实测不生效(已回退)。

  • v0.3.2:尝试用 package.json 的 dsh.client.inject 把 @deepseek-ai/dsh-client-runtime 登记进 client 模块表以解决加载失败,实测在 0.1.2-alpha.1 上不生效(runtime 非 graph 行时 inject 是 no-op),已被 v0.3.3 取代。

  • v0.3.1:兼容 DSH 0.1.2-alpha.1 内核——该内核把会话内容迁到 target 体系,useSession 不再提供 nodes/runningCalls;功能三「修改的文件」(v0.4.9 已移除)当时改经 ctx.uiConversation.binding(...).target("chat") 读 ChatSnapshot.legacy 取数,并注册 useModifiedFiles 标准 hook,写法与官方 dsh-client-ui-chat 同构。其余功能所用槽位/服务在内核中未变。

  • v0.3.0:新增功能三「会话标题旁工作区徽章」——注册进官方 conversation.session.header.actions 增量 list 槽,在会话页标题右侧显示所属工作区名,纯 slot 渲染不改源码。(该功能在 v0.3.0~v0.4.8 期间编号为「功能四」,v0.4.9 功能三移除后前移为功能三。)

  • v0.2.0:新增「修改的文件」选项卡——注册进官方 conversation.view 开放槽,会话头部主选项卡区在「对话 / 轨迹」之后多出「修改的文件」,列出本会话改过的所有文件,点击经 Host 打开。v0.4.9 已整体移除(该功能在 v0.2.0~v0.4.8 期间编号为「功能三」)。

  • v0.1.4(2026-08-27):解决与余额插件(dsh-cost-meter)共用 sidebar.footer.action 时的同行冲突——纯 CSS 让容器换行、工具条占满整行,自动排到余额下方自己的行。

  • v0.1.3(2026-08-27):彻底放弃搬动 DOM——v0.1.2 的收窄观察器方案实测仍卡死(搬动 slot 节点与框架互搏)。改为纯 slot 渲染(工具条即官方 sidebar.footer.action 位置,紧贴工作区列表下方)+ CSS 对齐列表内边距。

  • v0.1.2(2026-08-27):尝试用收窄的 footer 观察器恢复 v0.1.0 的位置,实测仍卡死,回退。

  • v0.1.1(2026-08-27):修复 v0.1.0 卡死——移除全局 document.body MutationObserver 与 40ms 定时搬节点(工具条当时因此回到 footer 位置)。

  • v0.1.0:合并 dsh-model-select-style(模型选择双按钮)+ dsh-workspace-collapse(侧边栏折叠/展开)。

安装

dsh plugin --profile web add github:qgx1992/dsh-ui-tools

或手动在 ~/.dsh/profiles/web/package.json:

  • dependencies 加 "dsh-ui-tools": "github:qgx1992/dsh-ui-tools"
  • dsh.profile.bundles 加 "dsh-ui-tools"
  • 在 profiles/web 目录执行 pnpm install,然后重启 DSH。

注意:首次登记进 profile 后需要重启一次 DSH;之后调整 lib/client.js 只需刷新页面。

从旧插件迁移

本包合并了 dsh-model-select-style 与 dsh-workspace-collapse。迁移时从 ~/.dsh/profiles/web/package.json 移除这两个旧条目(dependencies 与 bundles),只保留 dsh-ui-tools,然后 pnpm install 并重启。

停用 / 卸载

  • 临时停用(推荐,无需卸载):在 ~/.dsh/profiles/web/cordis.patch.yml 追加下面两行,重启 DSH 即可(要恢复就删掉这两行再重启):
    - id: ui-tools
      disabled: true
    
  • 设置 → 插件 中的停用同样有效(七个功能同时关闭)。
  • 彻底卸载:从 bundles 与 dependencies 移除条目、删除 node_modules 内链接与本地插件目录。

明确不覆盖的范围

  • 输入框里输 /model 弹出的命令面板选择器走的是另一套 popupSelect 组件,本插件未涉及。
  • 侧边栏折叠条只影响工作区分组展开状态,不改变其他侧边栏 UI。
  • 偏好只存当前浏览器 localStorage,不会跨浏览器/跨设备同步。
  • 插件只覆盖上面列出的七个功能;此前的「修改的文件」会话选项卡已在 v0.4.9 整体移除,其取数与速度计重复且维护成本过高(详见变更记录)。
—/ 5

No ratings yet

Verified DSH bundle

Commit 340b01b66933

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