packages/dsh-task-board
zhu1090093659/dsh-web侧边栏多列任务看板:卡片交给真实 DSH 智能体会话执行,支持 cron 定时(Host 侧到点执行,关浏览器也生效)。
一个插件装五个 Web UI 工具:供应商 + 模型双按钮选择器(含推理等级调节)、侧边栏工作区折叠/展开全部、会话「修改的文件」选项卡(需内核 0.1.2-alpha.1+,旧内核上静默缺席)、会话标题旁的工作区徽章,以及集中开关这些功能的设置页。
DSH Web 插件:七个 UI 工具合并成一个包,只动对应控件,不改 DSH 任何一行源码。
把输入框官方的「模型选择」控件替换为两个独立按钮,两级联动:
模型名 · 推理等级(如 deepseek-chat · 高),面板内带「推理等级」区可调节思考强度。选择逻辑完全复用官方组件(同一份模型目录、同一套选择提交、错误 Toast),只是把入口从单个按钮拆成两个。
在侧边栏工作区列表下方渲染「折叠全部 / 展开全部」工具条,一键折叠/展开所有工作区分组(纯 slot 渲染 + CSS 对齐,不搬动 DOM)。
在会话页头部标题右侧显示一个圆角徽章,标出当前会话所属工作区名(与侧边栏分组标题同名),一眼看出当前对话跑在哪个工作区:
useWorkspaces,从 workspaces.items 按 sessionIds 反查当前会话所属工作区,取 title;title/aria-label 带可读文案。实现原理:本功能注册进官方纯增量 list 槽 conversation.session.header.actions(session 作用域)——它是头部标题 cluster 内的动作行,位于面包屑标题右侧,正好是「标题旁」。list 槽是加性的(绝不替换标题、无占用冲突,当前该槽无其他插件占用),我们用负数 order: -100 让徽章排在其它交互动作之前、紧贴标题。纯 slot 渲染,不搬 DOM、不改源码。
在官方设置中心新增一个「DSH UI 工具」页面,集中控制本包功能的偏好:
dsh-ui-tools:prefs:v1),刷新/重启后保持;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() 里注入同一偏好仓库:徽章/速度计组件渲染时读开关,侧边栏折叠条在插件加载时按「默认折叠」偏好执行一次。
在可随意拖动的胶囊悬浮窗里显示输出速度读数(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 / 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-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 起改为「解码优先、墙钟回退」两级口径:历史回合显示的是整步墙钟速度(含首字延迟,通常略低于纯解码速度),而不是不显示。
在侧边栏的会话行与工作区行上按右键,弹出上下文操作菜单。实现参考社区插件 @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 项):复制所选文本、发送所选文本到输入框(填入输入框,不自动发送)。
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()(重拉列表基线,行才会消失)。TypeError。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的,只同步文件不重启,菜单会出现但点了只会提示「宿主半边未加载」。
右键工作区行 → 「删除工作区全部会话…」。它是「归档工作区全部会话」的彻底版:归档只记账,这里真删磁盘。
/api/ui-tools/sessionsDelete,与单会话端点共用同一个 registerDeleteRoute(path) 工厂(一次删一个 = 只含一项的批量,两条路径的行为不可能分家)。之所以不在客户端 for 循环调单删端点:几十个会话就是几十个 HTTP 往返,而且中途失败时说不清「停在哪一个」,用户只能看到列表莫名其妙少了一半。{ok:false, error:{code:"partial", details:{deleted, failed}}},客户端拼成「已删除 N 个,M 个失败:<真实原因>」;全失败时则直接透出第一条失败的业务码与原因。绝不因为「一条失败」就把整批显示成「删除失败」而让人以为一个都没删掉。readDeleteBatchPayload():先看 sessions 数组,没有才退回顶层 {sessionId, cwd}。这样只重启了一半(包更新了但进程还是旧的)也不会打通了一个、坏了一个。条目上限 200 条,超了直接 400 而不是让宿主跑十分钟。⚠ 与单会话删除同一条已知边界:正在生成中的会话、以及会话分支的子会话不会被级联处理。
「创建会话分支」建出来的子会话,单独删父会话会留下孤儿分支——它们仍在列表里、仍占磁盘,但已经失去了上游上下文。v0.4.23 起删父会话时会连整棵子树一起删。
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) 就干这件事:多根、多兄弟、血缘成环(脏数据)不死循环、快照读不到时返回 [](级联是附加能力,绝不能因为它而弄坏删除本身)。/api/ui-tools/sessionsDeleteStream。没有子树时载荷逐字不变(仍只发 {sessionId, cwd}),v0.4.20 的老契约一个字没改。sessionIds 里是看不到的——只按 sessionIds 删就会漏。所以工作区删除同样先做一次级联展开再取并集,确认弹窗上的数字是展开后的真实条数。origin 字段不参与判据。 内核 dsh-session:1053 里 origin 只允许 "subagent",但用户手动 fork 出来的分支 origin 是空的;只认 origin 会漏掉手动分支,故判据只用 parentId。删 20 个会话在真机上要好几秒(每个都要先停 agent、再动工作区记账、最后递归删目录)。这期间如果界面只有一句「正在删除…」,用户会以为卡死并反复点击。
/api/ui-tools/sessionsDeleteStream 返回 NDJSON(application/x-ndjson,一行一个 JSON),deleteSessions() 每删完一个就 onProgress({done, total, sessionId, ok, code?, message?}),宿主随即写出 {"type":"progress", …};全部结束后写 {"type":"done", …}(内容是和非流式端点完全一致的 deleteEnvelope())。EventSource 只能发 GET、带不了请求体,而这条端点必须收 sessions 数组;所以用 fetch + ReadableStream 手切行。响应头带 x-accel-buffering: no,防止中间层把流攒起来一次性吐出(那样进度就成了假的)。done 逐次为 1,2,3,并附带静态钉「宿主 deleteSessions 里必须有 onProgress({done: 调用」+ 文本变异负向对照。controller.enqueue 会抛错;宿主把它吞掉并继续把剩余的删完——中止到一半留下的半删状态比多删几秒更难收拾。v0.4.20 ~ v0.4.23 的二次确认是「菜单原地换成一条确认条」——样式复用右键菜单、按钮竖排成菜单项。真机用下来两个问题:
window.innerWidth / 2 当成左上角传给 ctxPositionMenu(root, x, y),而那个函数收的正是左上角坐标(left = max(6, min(x, innerWidth - width - 6)))——于是整个框从屏幕正中开始往右铺,再被视口右边夹住,看起来就是贴着右半边。重做后的做法:
position: fixed; inset: 0 + display: flex; align-items: center; justify-content: center,卡片 width: min(380px, 100%)。ctxConfirmDialog 里再也没有 ctxPositionMenu / innerWidth —— 这两条都有静态钉 + 文本变异负向对照(把坐标计算塞回去必须报红)。.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。md + outline 规格。 36px 高、0.5px solid var(--dsw-alias-border-l3) 描边、14px/22px 字、圆角 --dsw-radius-md;顺序与官方 footer 一致:取消在左、危险动作在最右。role="alertdialog" 在卡片上而非根上)、遮罩存在、不再复用 dsh-ctx-menu、按钮顺序(取消在左 + 危险在最右)、关闭键/遮罩/卡片正文三种点击行为、以及「弹窗里用到的每个类名都必须在 CTX_CSS 里有定义」(防止改了 JS 忘了改 CSS 导致掉回无样式观感)。负向对照 2 条:抽掉 justify-content: center 后居中钉必红;把坐标计算塞回 ctxConfirmDialog 后「不再算坐标」两条钉必红。connection.fetch 而不是 connection.rpcv0.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) 依次尝试:
data-row-key 命中,最可靠;aria-selected="true" 时取 store 的 current;aria-label / title 在库中匹配,只在唯一命中时采用。任一级无法唯一确定就返回 null,调用方据此保留浏览器原生菜单 —— 绝不猜。 尤其第 3 级:标题命中多个会话时同样返回 null,宁可不管,也不操作错会话。
contextmenu、pointerdown、keydown;菜单节点 appendChild 到 body;alphaFeatures 子 fiber);用到的 sessions / workspaces 本就在入口 inject 里,官方 uiWorkspace 走可选获取(ctx.get("uiWorkspace"),缺席时返回 undefined 并降级)—— 绝不把可选服务写进入口 inject(见下方说明,守 R3);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.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):
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} 隐藏官方本体,避免双层;useEffect cleanup 摘掉属性,官方待办面板原样恢复;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,仍是同级合理宽度。useProjection 缺席或 todos 非数组时不渲染镜像、不打属性(官方面板照常);<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 起彻底不搬)。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 一并释放。渲染方式完