Five web UI tools in one plugin: a provider + model two-button selector with reasoning-level control, collapse/expand-all for sidebar workspace groups, a session tab listing the files the session changed (needs kernel 0.1.2-alpha.1+, silently absent on older kernels), a workspace badge next to the session title, and a settings page that toggles them.
Install
# from GitHub (first run asks for allowBuilds approval — follow the hint, retry)
dsh plugin --profile web add github:qgx1992/dsh-ui-tools
Any plugin you install runs third-party code with your own permissions — it can read your files, use your credentials, and reach the network, and tool approvals don’t sandbox it. GitHub-sourced plugins also run build scripts at install time — pnpm blocks those until you allow them, so an install can stop with ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED or ERR_PNPM_IGNORED_BUILDS; dsh prints the exact key to add under allowBuilds in your profile’s pnpm-workspace.yaml, and the install works on the next run. Allowing a build is a trust decision: only install sources you trust, and pin a commit (github:owner/repo#sha).
README
This plugin publishes its README in Chinese only.
DSH Web 插件:七个 UI 工具合并成一个包,只动对应控件,不改 DSH 任何一行源码。
功能一:模型选择双按钮(原 dsh-model-select-style)
把输入框官方的「模型选择」控件替换为两个独立按钮,两级联动:
- 「供应商」按钮 —— 点击弹出供应商列表(如 DeepSeek / SiliconFlow / OpenRouter);
- 选中某供应商后,「模型」按钮亮起;
- 「模型」按钮 —— 点击只列出当前所选供应商的模型,点选即切换;
- 模型支持推理时,模型按钮显示
模型名 · 推理等级(如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 起):
- 解码口径(首选,与内核
deriveTurnMetrics/assistantStepReading完全一致):只累计同时带timing.firstTokenTime与usage.outputTokens的 step,按ΣoutputTokens ÷ Σ(completedTime − firstTokenTime)求值;只要该回合存在这类 step,就只用这批算,不让墙钟 step 污染分母。 - 整步墙钟口径(回退):真机实测发现内核
legacy快照里的 assistant 节点timing.firstTokenTime恒为null(连刚流式生成完的回合也是 —— 内核只在 durable log /sessionStatsprojection 里保留首字时间,客户端窗口的 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-livechat 节点 +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:2017fork 时写入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 的二次确认是「菜单原地换成一条确认条」——样式复用右键菜单、按钮竖排成菜单项。真机用下来两个问题:
- 观感一眼外挂。 内核自己的确认框(「停止并归档此会话?」「删除工作区」)用的是同一个 Modal primitive:遮罩 + 居中卡片 + 标题 + 正文 + 右下角横排按钮。插件的确认条长得像菜单,跟它并排看就是两套东西。
- 位置偏右。 旧代码把
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/拆卸纪律。约束有三条:
- 路径必须以
/api/开头(assertFetchRoute→endpointFromPath("/api", path)),且挂在/api那条 prefix 路由上按 pathname 精确匹配; methods非空不重复;- 处理函数是标准
(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) 依次尝试:
- DOM 行 id ——
data-row-key命中,最可靠; - 当前选中行 ——
aria-selected="true"时取 store 的current; - 标题兜底 —— 按
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):
- 组件从标准 props 读
useProjection("todos")(待办清单)与useProjection("sessionStats").steps(助手步数); - 用
tswSignature认出「同一份列表」,记下它首次出现时的步数基线,之后按tswAdvanceFromSteps(stepsSince, TSW_STEPS_PER_ITEM=2)每 2 步推进一项(回合收尾至少 +1); tswDeriveStatuses(todos, advance, running)把推断结果叠加到内核状态上,三条硬约束: 内核已判completed的项绝不降级、运行中不把最后一项也判完成、空闲时不新造in_progress;- 面板始终展开,每项渲染
[data-status]+ 状态点 + 文案,折叠态看不到的问题从根上消失; 头部给「N 已完成 · N 进行中 · N 待处理」汇总,推断生效时带「按步数推断」小徽标; - 渲染时给
<html>打data-ui-tools-todo-mirror="on",共享样式标签的TSW_CSS单条规则html[data-ui-tools-todo-mirror] [data-testid="todo-panel"]{display:none !important}隐藏官方本体,避免双层; - 卸载 / 关开关时
useEffectcleanup 摘掉属性,官方待办面板原样恢复; - 宽度与输入框卡片严格对齐(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暴露的useModifiedFileshook、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(greptools/*.mjs零命中),故不影响回归。 - 文档与版本:
package.json0.4.31 → 0.4.32;本条替代此前「description 追加本条」的惯例。
- 根因:真机定位到 DSH「插件」页点开某插件后的详情面板,是把该包
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-smoke2d 增加真机宽度实测 (镜像面板与输入框卡片的getBoundingClientRect宽度差 ≤ 2px、左右边缘差 ≤ 2px)。 - 文档与版本:
package.json0.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-smoke2d 改为「镜像面板 CSS 契约 + 属性与可见性双向翻转」。 - 文档与版本:
package.json0.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高频读最新坐标 +dragref 存按下偏移),拖动结束写 localStoragedsh-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.mousedown/move/up)后坐标变化且写入 localStorage,再双击复位;文件头功能条目与MARKERS.tokenSpeedPill注释同步。 - 文档与版本:
package.json0.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 /sessionStatsprojection 里保留首字时间,客户端窗口的 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 装进 desktop profile 后,输入框下方统计行只有官方 activity / usage 两个 pill,插件的
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-livechat 节点 +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.json0.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。
- 新功能一:子会话级联删除。 删父会话时把整棵分支子树一起删掉,确认条提前说明「会一并删除 N 个子会话分支」。血缘链不需要新 RPC:
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」,属未发版前的
…
Links
More in this category
zhu1090093659/dsh-web#packages/dsh-task-board★ 9673
Task board for the dsh web GUI: a sidebar multi-column kanban whose cards run in real DSH agent sessions and can also be scheduled with cron expressions, executed host-side even with the browser closed.
zhu1090093659/dsh-web#packages/dsh-web-all★ 9673
Plugin and skin collection for the DSH Web UI: task board, Git graph, right-side panel, remote mobile UI, pet, live token stats, and a skin center.
MeteorNOX/DeepSeek-Balance-Whale-Widget★ 6324
A fixed-corner whale widget for the DSH web GUI — balance, today's usage and per-turn cost with peak/off-peak pricing, editable balance-alert and daily-budget bubbles, a module-based custom bubble queue with A/B weighted choices and random lines or images, 30+ vendor templates (OpenAI, OpenRouter, Kimi, SiliconFlow, Ark, Zhipu, MiniMax and more) with per-model balance and subscription quota, plus task-end sound, imported audio, custom roles and a resource manager. Local-only, no telemetry.
ccch1mneyyy/dsh-TUI★ 6018
Claude Code-style full-screen terminal UI: pixel-whale header, live status line, and streaming thought expansion.
omdsh-dev/DSH-better-sidebar★ 4645
Full sidebar workbench with file rendering and editing, terminal, Git, and subagents; third-party plugins can register new tabs.
Devin-AXIS/deepseek-design#deepseek-idesign★ 1607
Visual design studio for websites, app prototypes, posters, cards, reports, and magazines, with templates, direct element editing, selection-aware AI draft handoff, and export.
Community comments
Comments are public GitHub Discussions. Loading them connects to GitHub and Giscus; a GitHub account is required to post.