dsh-url-router 0.4.1 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +137 -5
- package/README.md +66 -5
- package/lib/client.js +2313 -104
- package/package.json +4 -3
package/AGENTS.md
CHANGED
|
@@ -25,7 +25,9 @@
|
|
|
25
25
|
- **段位**:`#/session/<id>`(会话,`uiWorkspace.openSession`)、`#/panel/<panelId>`(主面板,`ctx.layout.selectPanel`)。输入侧接受复数与百分号编码,规范化成单数形式;**不认识的段位一律不碰**,只留一条诊断。
|
|
26
26
|
- **轮次归属必须可证明** ✓:助手行自带轮次 ✓;工具行只有调用 id ✗ → 靠 `[data-turn-start]`(值 = 轮号 ✓)反推 ✓,**但只有"行之前和之后各有一个已渲染边界"时才写** ✓(`turn/<n>/row/…` ✓)。虚拟化下边界常常只渲染一侧 ✗ → 那就**不写轮次** ✓(绝不猜 ✗)。假 DOM 的 `compareDocumentPosition` 用"根的后代序号"实现 ✓(不要用 `ownerDocument` ✗,假件没有 ✓)。
|
|
27
27
|
- **行级路线**:`#/session/<id>/row/<类>/<作用域>/<id>` ✓(身份 = `data-trajectory-row-key` ✓,值是 `%00` 分隔的三元组 ✓ → 拆成路径段 ✓);家系视图的 `data-dshts-turn`(轮号 ✓)按 `turn/<n>` 写 ✓。**点击处理器必须"行优先"** ✓:轨迹行本身就是按钮 ✓,而处理器开头的 `closest([data-chat-turn])` 会直接把非轮次元素挡掉 ✓(历史 bug ✓)。反映**纯被动** ✓(不 preventDefault ✓)。驱动 = 尽力 `scrollIntoView` ✓(虚拟化未渲染的行**不假装** ✓,返回 false ✓)。canonical 里位置的优先级 **行 > 视图 > 轮次** ✓。
|
|
28
|
-
- **会话视图路线**:`#/session/<id>/view/<viewId>`(有工作区时跟在会话 id 之后 ✓)。**`uiConversation` 服务在根插件里读不到** ✗(实测 `ctx.get('uiConversation')` = undefined ✓,它是会话作用域内的 ✓)—— **视图 id 从槽位读** ✓:`ctx.slots.entries('conversation.view')` 的 `options.id`
|
|
28
|
+
- **会话视图路线**:`#/session/<id>/view/<viewId>`(有工作区时跟在会话 id 之后 ✓)。**`uiConversation` 服务在根插件里读不到** ✗(实测 `ctx.get('uiConversation')` = undefined ✓,它是会话作用域内的 ✓)—— **视图 id 从槽位读** ✓:`ctx.slots.entries('conversation.view')` 的 `options.id` ✓,`options.label` 就是页签上印的字 ✓(可能是函数 ✓)。头部页签容器是 **`[data-conversation-tabs]`** ✓、页签是 `button[role="tab"][aria-selected]` ✓(**没有 id 写在 DOM 里** ✗)。
|
|
29
|
+
- **id 与页签按「文字 ↔ label」对齐** ✓(2026-10-27 修):官方头部只渲染 `visibleTabs` ✓,**放不下的视图进「…」菜单** ✗(`visibleTabs = preferred.slice(0, visibleViewLimit)` ✓)—— 所以「页签数 = 注册数」**不成立** ✗(用户 2026-10-27 截图实测:4 个页签 + 「…」,地址栏**不再写视图段** ✗)。现在:选中页签的 `textContent` 去槽位 label 里**唯一匹配** ✓ → 得到 id ✓;匹配不到才退回**位置对齐**,且**只在数量完全相等时** ✓(宁可没有,也不写错 ✗)。
|
|
30
|
+
- **还没做的**:选中视图被折叠进「…」时,DOM 里没有 `aria-selected` 的页签 ✗ → 反映拿不到它(少写视图段 ✓ 不写错 ✓);要修得读「…」按钮的激活态 ✗。默认视图(第一个 ✓)不写进链接 ✓。
|
|
29
31
|
- **视图驱动必须排在工作区/会话分支之前** ✓,否则那两条会先 `return` 把它吞掉 ✓(历史 bug ✓);且**不能被 `source === 'hash'` 挡住** ✓(粘贴视图链接就是要切过去 ✓)。
|
|
30
32
|
- **工作区路线**:`#/workspace/<工作区名>/session/<会话id>`(侧栏形状 ✓)。工作区行是 `data-row-key="workspace:<uuid>"` ✓、**行文本就是工作区名** ✓(无 `data-workspace-*` ✗,实测 ✓);会话所属工作区 = 树里**它之前最近的那个 workspace 行** ✓(`workspaceNameOf()` ✓,行锚点与反映共用 ✓)。名字是**装饰** ✓:`sameRoute` 对 workspace 路线只比**会话 id** ✓(名字过期也能落地 ✓);读不到工作区行就退回 `#/session/<id>` ✓(不猜 ✓)。
|
|
31
33
|
- **行锚点的 href 也要带工作区** ✓(右键"复制链接"直接给出带归属的链接 ✓)。
|
|
@@ -60,9 +62,26 @@
|
|
|
60
62
|
- **观察者的 `attributeFilter` 必须列全所有已发布的页面钩子**:只列 `data-plugin-detail` 曾让任务看板的重试通知饿死(历史 bug,别删名单项)。
|
|
61
63
|
- **未落地的链接要有定时重试**(`LINK_RETRY_MS` = 500ms):只靠 DOM 通知不够,启动后界面会安静下来,一次被吞掉的重试就永远不来了。
|
|
62
64
|
- **切换主面板前先 `beginNavigation()`**(`ctx.layout` 的配套动作,可选面,缺失即跳过)。
|
|
63
|
-
- **设置面板是覆盖层**:`#/settings` ✓(裸段位 ✓,`ROUTE_PATTERN` 支持 `#/<kind>` ✓)与 `#/settings/<分区>` ✓。反映读 `settingsOverlayOf()` ✓(**官方 `data-shortcut-modal="settings"` 优先** ✓,见上条
|
|
65
|
+
- **设置面板是覆盖层**:`#/settings` ✓(裸段位 ✓,`ROUTE_PATTERN` 支持 `#/<kind>` ✓)与 `#/settings/<分区>` ✓。反映读 `settingsOverlayOf()` ✓(**官方 `data-shortcut-modal="settings"` 优先** ✓,见上条 ✓)。
|
|
66
|
+
- **座位是单占的:入口可能是「菜单里的一项」** ✓(2026-10-27 起,2026-10-28 补全):`[data-slot="sidebar.settings"]` 里放的是 `settings.launcher` 的**占位者** ✓ —— 空位时是外壳自己的齿轮(`button[aria-haspopup="dialog"]` ✓,一点就开浮层 ✓);被占位时可能是**账号行**(实测桌面观察器:`haspopup: menu` ✓、标签就是账号名 ✓、座位里**根本没有** dialog 触发器 ✗)→ 一点只弹菜单 ✗,浮层要再点菜单里的「设置」项 ✓。**只点齿轮 = 菜单开着、浮层没开** ✗ → 所有设置链接 8 秒后过期 ✗(用户实测"打不开 `#/settings/账号与余额`" / "打不开 `#/settings/内置插件`" ✓)。
|
|
67
|
+
- **驱动分步、每次调用只走一步、从 DOM 重新判断** ✓(`openSettings`):① 浮层开着 → 点分区(原有逻辑 ✓);② 屏上已经有「设置」项 → 点它 ✓;③ 否则**按一次**启动器 ✓(优先座位里的 `[aria-haspopup="dialog"]` ✓,否则座位里第一个按钮 ✓)。收敛:按启动器 → 点设置项 → 点分区 ✓。
|
|
68
|
+
- **绝不重复点启动器** ✗:它是开关 ✓,重试循环会把菜单开了又关 ✗(与模型页卡片同一个陷阱 ✓)。判据是**每个链接一份状态** ✓(`settingsLauncherPressed` ✓,`dropLink()` 复位 ✓)—— **别用「页面上有没有可见 popover」来决定要不要按** ✗:页面里常驻别的 popover ✓,那样会让驱动**永远不按** ✓ → 链接全部打不开 ✓(2026-10-28 实测踩到,用户当场报"更差了"✓)。
|
|
69
|
+
- **屏上的设置项** ✓:优先在**可见的 `[role="menu"]`** 里找 ✓;不在菜单里时**只认 `role="menuitem"`** ✓(随便一枚叫「设置」的按钮不是它的 ✓)。标签 `/设置|settings/i` **包含**匹配 ✓(`Settings⌥⌘,` ✓ 前缀/后缀都行 ✓)。
|
|
70
|
+
- **「可见」的判据** ✓(`onScreen`):不在 `[hidden]` 祖先里 ✓、`getClientRects().length > 0` ✓ —— popover 关掉后**仍挂在 DOM 里** ✗,`[role="menuitem"]` 照样查得到 ✓,点隐藏项什么也不会发生 ✓,还会让启动器再也按不到 ✗。**别用 `offsetParent` 判断** ✗:`position:fixed` 的弹层它恒为 null ✓(而弹层正是 fixed ✓)。
|
|
71
|
+
- **设置项按标签匹配,不限于 `[role="menuitem"]`** ✓:实测两种菜单都有(`Settings⌥⌘,` ✓ / 账号菜单里的「设置」✓)→ 逐个选择器查(`[role="menuitem"]` / `button` / `[role="option"]` / `a` ✓,**不用逗号列表** ✗ 小 DOM 桩不支持 ✓),标签 `/设置|settings/i` **包含**匹配 ✓(不要求开头 ✗:标签可能带快捷键尾巴或前缀 ✓)。
|
|
72
|
+
- **启动器要用「指针按下」按,不是裸 `click()`** ✓(2026-10-28 实测):座位上的账号行是 popover 触发器 ✓,`element.click()` 对它**没有反应** ✗ —— 观察器日志里那个菜单的内容(`设置⌘,` / `意见反馈` / `退出登录` ✓)**只在读者手点时出现过** ✗,驱动按了却从没开过 ✗ → 链接永远打不开 ✗。现在 `pressElement()` 派发 `pointerdown` / `mousedown` / `pointerup` / `mouseup`(带元素中心坐标 ✓)+ `click` ✓;构造器缺失就跳过 ✓(最小 DOM 仍能跑 ✓)。
|
|
73
|
+
- **`openSettings` 以浮层优先** ✓:浮层开着 → 只点分区 ✓(裸 `#/settings` 什么都不点 ✓);浮层关着才走「屏上的设置项 → 启动器」✓。**旧结构会在裸链接上反复按启动器** ✗(把菜单开了又关 ✗,单测当场抓到 ✓)。
|
|
74
|
+
- **首选:走座位的 store,一次点击都不用** ✓(2026-10-28):`ctx.slots.entries('sidebar.settings')[0].store` → `store.create().actions.openSection(id)` ✓(`dsh-usage` 同款用法 ✓,`actions` 实测是 `{open, close, select, openSection}` ✓,`openSection(id)` 同时置 `activeId` 与 `open = true` ✓)。分区的 **id** 从 `settings.section` 槽位按**标签**映射 ✓ —— 外壳自己就是 `entries('settings.section').map(e => ({id: e.options.id, label: …}))` ✓,所以这是同一个列表 ✓。**store 不在 / 标签映射不上才退回 DOM 重放** ✓。**为什么必须优先**:座位被账号行占位时,驱动**能把菜单按开** ✓(日志实测 `popovers: 设置⌘,/意见反馈/退出登录` ✓)却**点不动那个菜单项** ✗ → 链接永远不落地 ✗(用户实测:overlay 只在他手点时开过 ✓,链接在飞时一次都没开 ✓)。
|
|
75
|
+
- **驱动辅助函数注意作用域** ✗:**模块作用域的函数不能用宿主作用域里的 `attempt`** ✗(`attempt is not defined` ✗ → 被 `guard` 吞掉 → 整段驱动静默中断 ✗ —— 2026-10-28 自己踩到 ✓,单测抓住 ✓)。模块作用域一律 `attemptValue` ✓。
|
|
76
|
+
- **「打不开」那行不能拿原始字符串比** ✓(2026-10-28 修):读者交上来的是**他读到的字** ✓(设置分区就是原始中文 ✓),而 canonical 是**百分号编码** ✓ → 字面比较会把**已经落地**的链接报成打不开 ✗(实测:浮层已经开在那个分区上 ✓,行里却写着「当前 settings:内置插件」✗ —— 用户当场指出"能打开"✓)。现在 `landedFragment()` 先比 `readableFragment()` 两边 ✓(解码后相等即落地 ✓),再比**路由形状** ✓:设置分区按**前缀** ✓(裸 `#/settings` 被任意分区满足 ✓、命名分区必须同一个 ✓)、面板页按**前缀** ✓、会话/工作区仍按会话 id ✓(工作区名是装饰 ✓)。
|
|
64
77
|
- **设置分区的身份 = 显示名**(实测无稳定 id ✗:导航项只有 `aria-current="true"` ✓,无 `id`/`data-*`/`aria-controls` ✗,右侧内容区无 `data-*` ✗)。反映:在覆盖层里找 `aria-current="true"` **且 `closest('nav') !== null`** 的按钮 ✓,取它的标签文字 ✓;驱动:按标签(先精确、再忽略大小写)点 ✓。**换语言/改文案会让旧分区链接失效** ✗(重新点一次即写出新链接 ✓)。
|
|
65
|
-
-
|
|
78
|
+
- **没有 ARIA 的行:一条很窄的类名兜底** ✓(2026-10-28):实测有的行**只有样式类** ✗(`dsh-client-ui-market` 的过滤 chip:`…_filterChip` / `…_filterChipOn` ✓,一个 `aria-*`、`data-*` 都没有 ✗ —— 上面那排 `Web 插件/皮肤/创意工坊` 反而有 `role="tab"` + `aria-selected` ✓,所以只有它写进了链接 ✓)。兜底规则:类名**以** `On` / `Active` / `Selected` / `Current` / `Checked` **结尾** ✓、同一父元素下**恰好一个**命中 ✓、该行至少两个控件 ✓ —— 命中不了就不算一行 ✓(**别把这条放宽** ✗:它是"猜",只配在完全没有契约时兜一下 ✓;哪天真有了 `aria-pressed` 就该删掉 ✓)。
|
|
79
|
+
- **控件的标签只取它自己的文字** ✓(`controlLabelOf` ✓):实测 chip 读作 `宠物 12` ✓,`12` 是嵌套徽标里的计数 ✗ —— 计数会变 ✗,不是身份 ✗。所以只拼控件**自己的文本节点** ✓(没有就退回 `textContent` ✓),链接写成 `…/宠物` ✓,数字变了也不失效 ✓。
|
|
80
|
+
- **AI 给链接 = 一条可用的入口** ✓(2026-10-29 实测确认):模型没有 DOM ✗,但它可以把链接**写进回复** ✓ —— 读者点一下就等价于"粘进地址栏" ✓:点击改的是 `location.hash` ✓ → `hashchange` ✓ → `reconcile('hash')` ✓ → **驱动照样发生** ✓(用例:`a settings link that arrives as a plain hash change opens the panel` ✓,实测**当场**就打开 ✓,连重试都不用 ✓)。所以「让 AI 提供链接来触发行为」**今天就有** ✓,不需要工具 ✗。
|
|
81
|
+
⇒ **只有"AI 自己动手、读者一下都不点"才需要宿主半区** ✗:本插件宿主半区是空操作 ✓(零依赖 ✓),要做工具得先找到一条**宿主→页面**的通道 ✓ —— 扫过 app 包清单(有 `dsh-client-connection` ✓ / `dsh-client-modules` ✓)但**没有公开文档** ✗,属于"要先逆向确认"的活 ✓。**别为了这个打破"只有浏览器半区"** ✗,除非确认通道存在 ✓。
|
|
82
|
+
- **查询结果不是数组** ✗(2026-10-28 血的教训):`querySelectorAll` 给的是 **NodeList** ✗,而**假 DOM 当年返回真数组** ✗ → 于是一整类 bug 在测试里全绿 ✓、在真机上整段静默跳过 ✗。**`attemptValue(() => x.querySelectorAll(...))` 后面接 `Array.isArray` 的写法一律是死的** ✗ —— 要么在 attempt **里面**就展开 ✓(`[...x.querySelectorAll(...)]` ✓),要么直接 `for...of` 迭代 ✓。这一轮一次抓出**三处** ✓:设置行的扫描(`settingsRowsOf` ✗ —— 它正是"连页签层也没了"的真凶 ✓,`.24` 用 `for...of` 所以是好的 ✓)、`visibleMenuOf` ✗、`settingsMenuItemOf` ✗(后两处 = "设置项按不动"的另一半原因 ✓)。**假 DOM 现在返回 NodeList-like** ✓(`nodeListOf` ✓)—— **别改回真数组** ✗,它就是为了让这类 bug 跑得出来 ✓。
|
|
83
|
+
- **设置路线是任意层的** ✓(2026-10-28):`#/settings/<分区>[/<行 1 的选中>[/<行 2 的选中>…]][/provider/<id>]` ✓。**一行** = 同一个父元素下、都带同一个标记(`aria-selected` ✓ 或 `aria-pressed` ✓)且**恰好一个为 `true`** 的一组控件 ✓(实测:`Web 扩展 / 创意工坊` 里还有一行 `编辑推荐 / 皮肤 / 宠物 / 插件 / 预设` ✓,再下面还有 `全部 / 角色扮演` ✓)。反映 = 按**文档顺序**把每行的选中标签拼起来 ✓(`compareDocumentPosition` 排序 ✓);驱动 = **逐行点** ✓,且**每一行只在前一行匹配到的那一行之后找** ✓ —— `皮肤` 既是分区页签又是子行标签 ✗,游标把它们分开 ✓;**每次调用只推进一层** ✓(点开的那一行要等下一次渲染 ✓,重试接上 ✓)。
|
|
84
|
+
- **设置分区的身份 = 显示名**,页签/子行/筛选同理 ✓(`[role="tab"]` 的 `aria-selected` ✓、开关行的 `aria-pressed` ✓),身份都是**标签文字** ✓;`sameRoute` 与 `landedFragment` 都按**前缀**比 ✓(少写几层也算落地 ✓、多出的层是细化 ✓)。
|
|
66
85
|
- **`aria-selected` 也必须观察** ✓(与 `aria-current` 同理:切换页签只改它 ✓)。
|
|
67
86
|
- **设置链接按"前缀"落地** ✓:裸 → 分区 → 页签逐级 ✓;命名了页签的链接必须看到那个页签 ✓,只命名到分区的链接被该分区任意页签满足 ✓。
|
|
68
87
|
- **`aria-current` 必须在 DOM 观察者的 `attributeFilter` 里** ✓:切换分区只改这一个属性 ✓,漏了它地址栏就永远停在第一次看到的分区(历史 bug ✓)。
|
|
@@ -98,11 +117,121 @@
|
|
|
98
117
|
- **定位 = 量空白段,不是探矩形** ✓:`macLeaves()` 走一遍行内**所有真正占位的元素** ✓(有自己文字、或 `button/a/input/[role=button]/[role=tab]/img/svg`、或没有子元素 ✓)→ 只留和电源按钮同一行的 ✓ → 按左边界排序并**合并重叠** ✓ → 求所有空白段 ✓ → 取**最宽**那段 ✓ → `fitBar()` 右对齐(贴着头部按钮)+ 两侧 `MAC_BAR_INSET = 25` 内缩 ✓。
|
|
99
118
|
- **宽度必须封顶** ✓(`MAC_BAR_MAX_WIDTH = 1150`):空白段的左边界就是**会话标题文字的右边界** ✓,不封顶的话每换一个会话框就变一次宽 ✓。
|
|
100
119
|
- **「探测矩形 + 一撞就隐藏」那套别再写** ✗:它把候选框放在电源按钮左侧 ✓,必然横跨头部那排按钮 ✓,于是每次都被判"不空"然后整框 `display:none` ✓ —— 这就是"框怎么都不出来"的历史 bug ✓。
|
|
101
|
-
- **没有电源按钮的界面**(插件页 / 设置页 / 面板页
|
|
120
|
+
- **没有电源按钮的界面**(插件页 / 设置页 / 面板页 ✓):**按当前屏幕那一行重新量** ✓,不再沿用会话页的空白段 ✗(用户 2026-10-26 的截图:沿用旧空白段正好横压在自动化任务详情的「规则 / 任务运行记录」页签上 ✓)。做法:记住上一行的 `top` ✓,在 `[top, top + 26]` 这条带里扫**控件与标题**(`button` / `[role=tab]` / `[role=button]` / `a` / `input` / `textarea` / `select` / `h1..h3` ✓,`bandSpans()` ✓),把交点合并成占用段 ✓,**取最宽的空段**放框 ✓(`fitBar()` 右对齐 + 内缩 ✓);一段都不够宽就**隐藏** ✓(宁可不画,也不压在可点的东西上 ✓)。
|
|
121
|
+
- **窗口宽度仍要一起存** ✓(`{top, viewport, gap}` ✓):头部按钮是**右对齐**的 ✓,窗口一变它们就移动 ✓。宽度差超过 `MAC_BAR_RESIZE_SLACK = 8` 就**不猜** ✓,先隐藏,等回会话页重新量 ✓。
|
|
122
|
+
- **`MutationObserver` / `requestAnimationFrame` 一律从 `window` 取** ✓(`window.MutationObserver` / `window.requestAnimationFrame` ✓):假 DOM 与置换过 document 的宿主里,裸全局读会静默拿不到 ✓(踩过 ✓)。
|
|
102
123
|
- **只有一个框** ✓:挂载前先 `getElementById` 摘掉同 id 的旧 bar 和旧样式表 ✓ —— effect 重跑(热重载 / 重复 apply)不会多一个 ✓。
|
|
103
124
|
- **调试不在产品里** ✓:`dev/inspect/` 是一个**独立观察器插件** ✓(`private: true`,靠根 `files` 白名单不进 npm ✓),只读公开契约(`#dsh-url-router-titlebar`、`data-dsh-url-router-platform`、`[class*="titleRow"]`、`.dshpm-trigger`)✓。判据三条:`bars` 恒为 1 ✓、`onTop` ✓、`leaves` 有没有被压住 ✓。**别再把调试代码写回 `lib/client.js`** ✗ —— 发布物必须是跑过测试的那一份 ✓。
|
|
104
125
|
- **假 DOM 不支持 `#id` 选择器** ✗:mac 侧的新代码对 `getElementById` / `querySelectorAll` 全部用了可选调用 ✓,测试里缺失即跳过 ✓。
|
|
105
126
|
|
|
127
|
+
## 电源菜单路线(2026-10-27 起)
|
|
128
|
+
|
|
129
|
+
用户明确要求(2026-10-27):`#/power` = **打开电源菜单**,只开不关 ✓。
|
|
130
|
+
|
|
131
|
+
- **段位** ✓:`#/power`(无身份 ✓,一个应用只有一个菜单 ✓)。
|
|
132
|
+
- **反映** ✓:`powerMenuOpenOf()` —— 契约属性 `data-dsh-power-menu`(值 `open` ✓ 或空 ✓)优先 ✓,其次是这个插件的 `.dshpm-menu` / `.dshpm-trigger[aria-expanded="true"]`(`getElementsByClassName` ✓,假 DOM 没有就跳过 ✓)。菜单开着时 canonical **就是它** ✓(盖在所有表面之上 ✓),关掉后自动回到底下那个界面 ✓。
|
|
133
|
+
- **驱动** ✓:点 `.dshpm-trigger`(或 `data-dsh-power-trigger`)**一次** ✓;**已经开着就不点** ✗ —— 它是开关 ✓,重试循环会把菜单开了又关(与模型页卡片同一个陷阱 ✓)。
|
|
134
|
+
- **动作段:`#/power/shutdown` / `#/power/restart`** ✓(2026-10-29 用户明确要求 ✓)。**动作是命令,不是地方** ✓ —— 所以它有一条**全仓唯一的来源闸** ✓:只认**读者亲手敲进地址栏**的 fragment ✓(`typedClaim` ✓,由 `driveTypedFragment` 升旗 ✓、**每一次 hash 读都降旗** ✓)→ 聊天消息 / README / **AI 回复**里的链接点了**不会关掉应用** ✓(用例 `a power action that arrives as a hash change is not run` ✓)。**一次 claim 只发一次** ✓(`powerActionSent` ✓,`dropLink` 复位 ✓);**boot 的 hash 不触发** ✓(boot 走 `safeReadLink` ✗ 不是 typed ✓)→ 重启后残留的 `#/power/shutdown` 不会变成重启循环 ✓。
|
|
135
|
+
- **动作的 id 必须能过 canonical** ✓(2026-10-29 自己踩到 ✓):`routeHash('power', …)` 原来**无条件**返回 `#/power` ✗ → 地址栏拿"规范化后的 wanted"(= 裸 `#/power` ✗)去和屏幕比 ✗ → 一条**已经发出**的命令被判「打不开」✓。现在 `routeHash` 保留动作段 ✓(`#/power/restart` ✓),并且 `arrived()` **先**判"这是不是一条命令" ✓(是就直接算落地 ✓)—— 因为屏幕上永远不会有东西等于一条命令 ✓(应用正在退出 ✓)。
|
|
136
|
+
- **先走它自己的 UI,端点只做兜底** ✓(2026-10-29 用户要求 ✓):`#/power/<动作>` 的驱动是**两级** ✓ —— ① 开它自己的菜单 ✓ → 点它发布的**那一项** ✓;② **菜单给 `POWER_ACTION_MENU_LIMIT = 4` 次驱动机会**(≈2 秒 ✓)都没交出那一项 → 走端点 ✓。这样它的流程会跑 ✓(它自己的遮罩 ✓,将来它若加二次确认也会经过 ✓),而且**永远不会静默 no-op** ✓(这是最重要的性质 ✓)。
|
|
137
|
+
- **绝不按标签点不可逆的项** ✗(2026-10-29 血的教训 ✓):`dsh-power-menu-mac` 的两项只有标签 `关闭DSH` / `重启DSH` ✓,**差一个字** ✗ —— 用户报"`#/power/restart` 激活的是关闭" ✓。猜标签点一个**会结束应用**的按钮,猜错不可恢复 ✗ → **只认契约属性** ✓(`data-dsh-power-action="<动作>"` ✓,限定在 `powerMenuOf()` = `[data-dshpm-menu]` ✓ 或 `.dshpm-menu` ✓ 里 ✓),**没有契约属性就整条走端点** ✓ —— 端点 URL 里带着动作名 ✓,**结构上不可能串** ✓。要给 A 路径加契约:给它的两个 `MenuItem` 各加一行 `'data-dsh-power-action': 'shutdown' | 'restart'` ✓。
|
|
138
|
+
- **焦点:真机制在插件那边,修法也在那边** ✓(2026-10-29 ✓):`dsh-power-menu-mac` 原来有个 `useEffect(…, [open])` 在菜单打开时调 **`first.focus()`** ✓ —— 那是**「关闭DSH」** ✗,读者的地址栏**失焦** ✗,他**再按一次 Enter**(以为没反应 ✓)就由浏览器**原生激活焦点上的按钮** ✓ → **关闭** ✓。日志佐证 ✓:`warnings: []` ✓(我们两条路都会先写一行 ✓,一行都没有 ✓ → **不是我们点的** ✓),`attempts.inner` 停在 2 ✓(我们没走到动作 ✓)。
|
|
139
|
+
⇒ **我们在自己这边抢过一次焦点,撤了** ✗:`.35` 里 `openPowerMenu()` 之后调 `host.focusAddressBar()` ✓ —— 插件那边改好之后(删掉 `first.focus()` ✓、把 `onKeyDown` 挂到**触发按钮**上 ✓、开菜单时 `triggerRef.current?.focus()` ✓),**再抢就会把它的方向键导航弄死** ✗(`onMenuKeyDown` 靠 `document.activeElement` 找下标 ✓,焦点不在触发按钮上就收不到键 ✓)。所以 `.36` 撤掉 ✓,并留一条用例盯着"**打开菜单后不动焦点**" ✓(防止以后又去抢 ✓)。
|
|
140
|
+
**教训**:别人的插件修好之后,自己的"绕过/对抗"要**跟着撤** ✗ —— 否则两边的修法会互相打架 ✓。
|
|
141
|
+
- **跑之前拿地址栏对一次,但只拒绝"写着另一个动作"** ✓(2026-10-29 ✓):判据是 `typed.kind === 'power' && typed.id !== '' && typed.id !== link.id` ✓ —— **不能**要求"地址栏必须写着这个动作" ✗:菜单一开 ✓,屏上的 canonical 就是 `#/power` ✓(裸的 ✓),严格版会把**我们自己刚打开的菜单**当成 claim 过期 ✓ → 拒绝 ✓ → 读者要**按两次回车** ✓(用户实测 ✓)。现在:claim `restart` + 地址栏 `shutdown` → **仍然拒绝** ✓(这才是"要重启却关机"那条要防的 ✓);claim `restart` + 地址栏 `#/power` 或别的界面 → **放行** ✓(那是菜单自己的状态 ✓ 或菜单已关 ✓)。用例:`… is not refused by the bar showing its own menu` ✓ 与 `… is still refused when the bar names another action` ✓。
|
|
142
|
+
- **每条命令留一行记录** ✓:动作名 + 走的哪条路 ✓。**必须同时进 `report().warnings`** ✓(`note()` 助手 ✓)—— 只 `host.warn` 等于**没有记录** ✗:桌面窗口没有控制台 ✓,观察器读的是 `report()` ✓(2026-10-29 实测:`host.warn` 的行在日志里**一条都看不到** ✗,当时只能靠用户回报 ✓)。这是**不可逆**动作 ✓,必须有据可查 ✓。
|
|
143
|
+
- **自己写入的回声必须认出来** ✓(2026-10-29 第三次踩到,这次是真凶 ✓):`driveTypedFragment` 写完 hash ✓ → **同步**读一次 ✓(claim 是"手打" ✓)→ 但**命令型链接立刻落地** ✓(`arrived()` 对命令恒真 ✓)→ `dropLink()` 丢掉 claim ✓ → **一个 task 之后**浏览器送来**我们自己写入的回声** `hashchange` ✓ → 老代码在这里**无条件降旗** ✓ → 新建的 claim 是"非手打" ✗ → 动作分支静默早退 ✓ → **读者要按两次回车** ✓✓✓(用户实测 ✓;第二次因为 hash 没变 ✗ → 没有回声 ✗ → claim 干净 ✓)。修法 ✓:`driveTypedFragment` 记下 `typedEcho` ✓,`onHashChange` 里 `typedClaim = (当前 hash 的可读形态 === typedEcho 的可读形态)` ✓ —— **自己写入的回声仍是读者的** ✓。
|
|
144
|
+
⇒ **这个分支以前完全不留痕迹** ✗ → 这一轮只能靠"两次回车"的现象反推 ✓。现在它记一行 `ignoring power action "x": this fragment did not come from the address bar` ✓(`powerIgnored` 每 claim 一次 ✓)✓。**教训**:静默早退的分支必须留痕 ✓,否则下一次还是靠猜 ✓。
|
|
145
|
+
- **「手打」要按 claim 记** ✗(2026-10-29 自己踩到 ✓):`typedClaim` 只活一次读取 ✓ → 重试时已经降旗 ✓ → 命令**失去权威** ✓ → 兜底永不触发 ✓(单测当场抓到 ✓)。现在另有 `linkedTyped` ✓:**claim 建立时**记下它是不是手打的 ✓,整个 claim 期间有效 ✓。
|
|
146
|
+
⇒ **但按 claim 记会漏掉一种情况** ✗(同日第二次踩到 ✓,用户当场报"没反应"✓):同一条链接**先以 hash 形式来过**(读者点过一条聊天里的链接 ✓)→ claim 是"非手打" ✓;随后读者**手打同一条** ✓ → `linked.id === parsed.id` ✓ → **不建新 claim** ✗ → `linkedTyped` 保持 false ✗ → **手打的命令被自己拒了** ✓。修法:`driveTypedFragment()` **重新授权当前 claim** ✓(`if (linked !== undefined) linkedTyped = true` ✓,并重置 `powerActionSent` / `powerActionAttempts` ✓)—— **手打就是权威** ✓,不管同一个 fragment 之前干过什么 ✓。用例 `a typed power action re-arms a claim that arrived from somewhere else` ✓(**把修复关掉它确实会红** ✓,验过 ✓)。
|
|
147
|
+
- **端点有两个包、两条路径,必须都试** ✓(2026-10-29 血的教训 ✓):桌面版装的是 **`dsh-power-menu-mac`** ✓,它的 base 是 **`/api/dsh-power-menu-mac`** ✓(`lib/index.js` 里 `const BASE = '/api/dsh-power-menu-mac'` ✓,另有 `GET …/health` 与 `GET …/plan` ✓);而普通包 `dsh-power-menu` 用 `/api/dsh-power-menu` ✓。**打错那条 = 静默 404 = 什么都不发生** ✓ —— 用户看到的就是"不能重启" ✓(我自己踩到 ✓:读了 `dsh-power-menu` 就写死了路径 ✓,**没先确认他装的是哪个包** ✗)。现在 `POWER_ACTION_BASES` 按序尝试 ✓(mac 优先 ✓,非 ok 就换下一个 ✓),全失败才记一条诊断 ✓(桌面窗口没有控制台 ✓,静默 no-op 是最坏的答案 ✓)。
|
|
148
|
+
- **实现 = 电源插件自己公开的端点** ✓:`POST <base>/{shutdown,restart}` ✓(读两个包的 `lib/client.js` 得到 ✓)—— **一次 `fetch`,不碰 DOM、不看语言、不等菜单渲染** ✓。比"开菜单再点那一项"轻得多 ✓,也**不需要它改任何东西** ✓(早先提的 `data-dsh-power-action` 建议**作废** ✓)。
|
|
149
|
+
- **菜单项本身仍然永不点击** ✓(裸 `#/power` 照旧只开菜单 ✓,一条老硬约束 ✓)—— 只有**显式动作段 + 手工输入**这一条路会执行 ✓。
|
|
150
|
+
- **代价(用户已知并接受)** ✓:浮层是瞬时状态 ✓ —— 地址栏会随开合来回跳 ✓,最近列表会记一条 `power:` ✓。
|
|
151
|
+
- **它读的是别的插件的 DOM** ✓(第一个这种)✓:所以契约属性优先、类名兜底 ✓。要往电源插件里加契约就加 `data-dsh-power-menu` / `data-dsh-power-trigger` ✓,别改类名 ✗。
|
|
152
|
+
- 用例:`an open power menu names itself in the address bar` ✓、`a power link opens the menu, and never presses anything inside it` ✓、`a power link on an already open menu presses nothing` ✓。
|
|
153
|
+
|
|
154
|
+
## 地址栏偏好契约(2026-10-26 起,改之前先读)
|
|
155
|
+
|
|
156
|
+
- **三个偏好在 `localStorage`** ✓:`dsh-url-router-settings` = `{selectAllOnFocus, hoverReveal, recentLimit}` ✓(默认 `true` / `false` / `8` ✓);`dsh-url-router-recent` = `[{hash, at, source}]`,最新在前 ✓,`source` 是 `visit` | `paste` ✓。它们**不属于 profile** ✓(是"这台机器这个窗口"的偏好 ✓),所以没有进 `dsh.profile`,也没有宿主接口 ✓。
|
|
157
|
+
- **悬浮才显示** ✓(2026-10-27):`hoverReveal` 为开时,bar 带 `data-dsh-url-router-hover` ✓,**只降透明度**(`opacity:0` ✓,**别动 `pointer-events`** ✗ —— 元素还得能被悬浮到,那正是"传感器" ✓)。`:hover` 不用 CSS 判定:JS 在 bar 的 `mouseenter` / `mouseleave` 上写 `data-dsh-url-router-shown` ✓(列表是 bar 的子元素 ✓,指针进列表不会触发父级 `mouseleave` ✓);输入框 `focus` 也点亮 ✓;`blur` 走 **0ms 超时**再收 ✓(列表项的 `mousedown` 会 `preventDefault`,但同一次任务里布尔的焦点状态还没落定 ✓);列表开着不收 ✓。偏好切换通过 `subscribeSettings` 实时应用 ✓(不必重启 ✓)。**mac 与 Windows 两套样式表都要加** ✓(两个常量各自一份 ✓)。
|
|
158
|
+
- **首次点击全选** ✓:未聚焦时的 `mousedown` 里 `preventDefault` + `focus()` + `select()` ✓ —— 只 preventDefault 不 select 没用 ✓,只 select 不 preventDefault 也没用 ✓(按下落光标那条默认行为会把选区抹掉 ✓)。**已聚焦时再点必须是普通光标行为** ✓(否则拖选与定位全废 ✓)。`focus` 事件那条路也要选 ✓(Tab / 脚本聚焦 ✓)。
|
|
159
|
+
- **当前链接不能当过滤词** ✗:框里平时就写着当前界面 ✓ —— 按 `input.value` 过滤会在聚焦瞬间把列表清空 ✓(踩过 ✓)。**只有读者真的编辑过(`input` 事件)才过滤** ✓(`narrowed` 标志 ✓)。
|
|
160
|
+
- **最近列表的身份 = 界面** ✓,不是坐标 ✓:`recentIdentity()` 把 `turn` / `row` / `view` / `pane` / 面板内部页都归并到所在表面 ✓(`workspace` 形状取里面的会话 id ✓)。否则在一个对话里点几十行就把列表刷满 ✓。**当前屏幕不列出** ✓。
|
|
161
|
+
- **条数 = 下拉框里能看到的条数** ✓,**当前屏幕不占名额** ✓(用户 2026-10-26 明确纠正过:按存储条数算会永远少一条 ✓)。做法:磁盘多留一个缓冲位(`recentKeep() = limit + 1` ✓,当前屏幕最多占一条 ✓),`recentEntries()` 先砍掉当前屏幕再取 `limit` ✓。默认 8 ✓、**0 = 关闭** ✓(不写盘 ✓、不弹列表 ✓)、**无上限** ✓(`normaliseSettings` 只做非负取整 ✓,别加"顺手"的上限 ✗)。调小要**立刻**裁掉 ✓(`trimRecent()` ✓),不能等下一次写入 ✓;**`recentEntries()` 纯读** ✓(`report()` 会调它 ✓,绝不能写盘 ✗)。
|
|
162
|
+
- **最近列表的两种点击** ✓:左键 = `driveTypedFragment(hash)` + `settle`(照旧 ✓);**右键 = 复制链接** ✓。右键必须 `preventDefault`(吃掉浏览器菜单 ✓),复制的是**绝对 URL**(`origin + pathname + search + hash` ✓ —— 只复制 fragment 在新标签页里打不开 ✓),路径与启动 `?token=…` 一起带上 ✓。反馈两处 ✓:行内文本临时变「已复制 ✓」✓、bar 右端的 `[data-dsh-url-router-toast]` 闪「已复制链接」✓(列表关了也看得见 ✓)。
|
|
163
|
+
- 复制优先 `navigator.clipboard.writeText` ✓(**它是异步的,失败时再走 fallback** ✓);没有该服务(`dsh-app:` 这类非常规协议可能没有 ✓)或它拒绝时,用**临时 textarea + `execCommand('copy')`** ✓ —— 这条会抢焦点 ✗,所以用 `quietly()` 把 `focus`/`blur` 事件屏蔽掉 ✓(否则 `blur` 会把列表关掉 ✗、`focus` 会重选整条链接 ✗),复制完再把焦点还给输入框 ✓。
|
|
164
|
+
- **别把 fallback 当实参提前求值** ✗:`attemptValue` 只有一个参数 ✓,写成 `attemptValue(fn, fallback())` 会让 fallback **每次都执行** ✓(真机表现:每次右键都闪一下、焦点跑到 body、列表被关掉 ✗ —— 2026-10-26 实测踩到 ✓)。
|
|
165
|
+
- `copyBySelection` 也会临时 `input.focus()` 回来 ✓,卸载时清掉 toast / copied 的定时器 ✓。
|
|
166
|
+
- **粘贴先记,落地再记一次** ✓:`paste` 事件里只要 `titlebarFragmentOf()` 认得出就先记 ✓(已经在屏上的链接不会触发 `reflect` 写入 ✗,否则它永远记不上 ✓)。`visit` 记在 `publishRoute()` ✓ —— 那是"地址栏真的被写了"的唯一入口 ✓。
|
|
167
|
+
- **假 DOM**:`input.select` / `input.focus` / `document.activeElement` / `removeChild` 全部可选调用或判型 ✓,假 DOM 已补齐这几个 ✓(`select()` 记次数、`focus()` 写 `activeElement` ✓)。
|
|
168
|
+
- **设置页 = 插件管理器里那一行** ✓(不是独立的设置分区 ✓):`ctx.slots.inject('plugins.row.config', …)` + `ctx.slots.register({name:'plugins.row.config', key: ROW_CONFIG_KEY}, Card)` ✓,`ROW_CONFIG_KEY = 'dsh-url-router#dsh-url-router'` ✓ —— 键的形状是 `<包名>#<row id>` ✓(官方 `rowConfigKey()` 实测 ✓),本插件包名与行 id 恰好同名 ✓,于是那个页面的 URL 正好是 `#/panel/plugins/dsh-url-router/dsh-url-router` ✓。
|
|
169
|
+
- 组件收到 `{view}` ✓:`'summary'` 只要一行字 ✓(但行页的写法是 `description ?? renderSlot('…', {view:'summary'})` ✓ —— **有 description 的行根本不显示 summary** ✓,本插件有 description ✓,所以 summary 目前看不到 ✓,别以为注册失败 ✗);其余(页面传 `'page'`)画设置本身 ✓,容器是 `[data-plugin-config]` ✓。
|
|
170
|
+
- 页面 DOM 标记:`data-plugin-row-detail` = 那个 key ✓(反映侧读的就是它 ✓)。
|
|
171
|
+
- **没有 slot 服务 / 没有 React / 没有 DOM 就跳过** ✓(`startSettingsCard()` 逐项判型 ✓):组合里缺插件管理器不算失败 ✓。
|
|
172
|
+
- **驱动组件页时绝不能点启用开关** ✓(2026-10-26 实测踩到 ✓):插件行的**第一个 `<button>` 可以是 `role="switch"` 的启用开关** ✗ —— 旧的 `row.querySelector('button').click()` 把插件**关掉**了 ✓(probe profile 里真的被写成 `disabled: true` ✗)。现在走 `openControlOf()` ✓:跳过 `role="switch"` ✓,找不到就**不点** ✓,让链接按时过期 ✓。**以后任何"按行打开某页"的代码都得走它** ✓。
|
|
173
|
+
|
|
174
|
+
## 自动化任务面板(2026-10-26 起,改之前先读)
|
|
175
|
+
|
|
176
|
+
- **段位** ✓:`#/panel/schedules[/filter/<all|active|inactive>][/task/<任务 id>[/<tab>]]` ✓。默认的 `filter/all` 与默认的 `rule` 页签**不写进链接** ✓,但 `filter/all` 作为**输入**要归一化掉(`normalisePanelInner()` ✓),否则那条链接永远等不到一个会写 `all` 的页面 ✓。
|
|
177
|
+
- **筛选项没有值,只有位置** ✓:官方源码把 `["all","active","inactive"]` 映射成三个按钮 ✓,按钮上只有 `aria-pressed` ✓(实测 ✓)—— 所以链接里的值就是**位置对应的固定 token** ✓,与语言无关 ✓。识别那组按钮:`[role="group"]` 下**恰好三个按钮、每个都有 `aria-pressed`** ✓,且不在右栏里 ✓。
|
|
178
|
+
- **模型页里展开的编辑卡** ✓:`#/settings/<分区>/provider/<provider id>` ✓(id 是配置里的 key ✓,不是显示名 ✓)。卡片**没有 data 钩子** ✗(官方源码里就是 `<li class=rowCard>` + 哈希类名 ✓),所以:**展开的判定 = 这个 `li` 里出现了 `input` / `textarea`** ✓;**身份来自动作按钮的无障碍名** ✓ `providerCopy(t('editProvider'), target)` = `<本地化标签> <显示名> (<provider id>)` ✓ → 只取**括号里的 id** ✓(与语言无关 ✓)。驱动 = 找 `aria-label` 末尾是 `(<id>)` 的**第一个**按钮 ✓(卡片动作里编辑在删除之前 ✓);落地:命名了 provider 的链接必须看到同一张卡展开 ✓,只命名分区的链接照旧被任意卡片满足 ✓。
|
|
179
|
+
- **`aria-pressed` 必须在 DOM 观察者的 `attributeFilter` 里** ✓:按下筛选只改这一个属性 ✓,漏了它地址栏就停在第一次的筛选上 ✓(实际踩到 ✓)。
|
|
180
|
+
- **任务详情** ✓:行是 `li > button`(`aria-label` = 任务名 ✓,`aria-expanded` = 是否展开 ✓,`aria-describedby` 末尾是 `-metadata-<任务 id>` ✓ 实测 ✓);详情是 `<aside aria-label="Task details">` ✓,页签是 `button[data-detail-tab="rule|records"]` + `aria-selected` ✓(实测 ✓)。**任务 id 只能从 `aria-describedby` 的末段取** ✓(`useId` 前缀不稳定 ✓)。
|
|
181
|
+
- **筛选先于任务** ✓:筛完才可能有点得到的行 ✓,所以驱动顺序固定 ✓。
|
|
182
|
+
|
|
183
|
+
## 子代理路线(2026-10-26 起)
|
|
184
|
+
|
|
185
|
+
子代理会话**也是会话** ✓(能打开、有轮次、有链接 ✓),但只有 id 的链接看不出它是谁的子代理 ✗(用户 2026-10-26 原话)✓。
|
|
186
|
+
|
|
187
|
+
- **段位** ✓:`#/session/<子会话 id>/subagent-of/<父>` ✓(工作区形状同样适用:`#/workspace/<名>/session/<子>/subagent-of/<父>` ✓)。`<父>` **默认写父会话的标题** ✓(目录里的 `title` ✓,读起来就是「谁的子代理」✓),没有标题 / 标题恰好是保留字时**退回父会话 id** ✓。
|
|
188
|
+
- **身份 = 子会话** ✓(打开的是它 ✓);**父是装饰** ✓ —— 落地只比子会话 id ✓,父写错/过期/改成标题都照样落地 ✓(和 workspace 名一个道理 ✓)。**代价**:URL 里没有父 id 了,不能靠改 URL 跳到父会话 ✗(要跳就点聊天里的子代理卡片 ✓)。
|
|
189
|
+
- **父段的两种形态要分得清** ✓:`isSessionIdLike(父段)`(uuid,可带 `session-` 前缀)→ 按**地址对象**打开 ✓;否则当它是**标题** ✓ → 用子会话自己的 id 打开 ✓(实测裸 id 也能开 ✓)。**别把标题当 `parentSessionId` 传** ✗。
|
|
190
|
+
- **标题不能变成位置** ✓:`ROUTE_RESERVED_TOKENS`(turn / row / view / pane / subagent-of / provider / filter / task / rule / records / session / workspace / settings / panel)里的标题一律退回 id ✓ —— 下游解析器按**段名**找位置(`indexOf`),标题恰好叫 `turn` 就会被当成轮次 ✗。标题还会 `trim` + 折叠空白 + 截到 `SUBAGENT_TITLE_LIMIT = 80` ✓。
|
|
191
|
+
- **反映** ✓:父 id 先从目录取 ✓(`list.byId[子].parentSessionId` ✓,`SessionListEntry` 自带 ✓),再兜底 `ctx.sessions.subagentAddress(子 id)` ✓;标题从 `list.byId[父].title` ✓(**别读侧栏行文本** ✗ —— 行文本带着相对时间尾巴,实测是「测试子代理回复数字18h」✓)。拿不到就是普通会话路线 ✓(不猜 ✓)。
|
|
192
|
+
- **驱动** ✓:`uiWorkspace.openSession(target)` 的 `target` 是 **`SessionTarget = SessionId | SubagentAddress`** ✓ —— 链接带了父,就传**地址对象** ✓ `{ parentSessionId, childSessionId, mode }` ✓,`mode` 从目录回读(`one-shot` / `continuable` / `unknown`)✓,回读不到就 `unknown` ✓(官方在读取子历史时解析 ✓)。**别用裸 id 打开子代理** ✗。
|
|
193
|
+
- **最近列表的身份仍是子会话** ✓(`recentIdentity` 已经是 `session:<id>` ✓,装饰段不参与 ✓)。
|
|
194
|
+
- **子代理不出现在侧栏** ✓(`origin === 'subagent'` 的会话被列表过滤 ✓)→ 它没有行锚点 ✓,链接来自聊天里的子代理卡片或手写 ✓。
|
|
195
|
+
- 用例:`a subagent conversation says whose subagent it is, by the parent's title` ✓、`a parent with no title (or a reserved one) falls back to its id` ✓、`a subagent link naming the parent id opens the child through its address` ✓、`a subagent link whose decoration is a title opens the child by its id` ✓。真机(隔离宿主 + 真实数据)实测 ✓:粘裸子会话 id → 子会话打开(7 个轮次)✓、地址栏**自己补回** `/subagent-of/测试子代理回复数字1` ✓;粘带父 id 的链接 → 地址栏落地成同一条 ✓。
|
|
196
|
+
|
|
197
|
+
## 右栏 pane 的两条修正(2026-10-26 起)
|
|
198
|
+
|
|
199
|
+
- **收起的右栏不算 pane** ✓:收起来时页签仍挂在 DOM 里 ✗(实测:关掉右栏后 `active` 仍返回上一个页签 ✗),所以 canonical 先问 `[data-rightbar-collapsed]` ✓(值为 `true` 或空即收起 ✓),收起时**不写 `pane/…`** ✓。会话与面板两条分支都要判 ✓(只改一条会漏 ✓)。
|
|
200
|
+
- **kind 优先读控制器** ✓:`sidebarRight.active()` 返回的是活的 tab 描述符 `{ id, kind, contentId, title }` ✓(实测 ✓),而 DOM 上只有标题 ✗ —— Files 页签在打开文件后标题会变成**文件名**(`client.js` ✗),拿标题当 kind 就会写出 `pane/client.js` ✓(用户截图里就是这个 ✓)。所以顺序是**控制器 → DOM 标签 → 注册表** ✓;DOM 标签匹配不到注册表时,只有在**没有注册表**的情况下才把它当 kind ✓,有注册表就当它是标题、返回 undefined ✓。
|
|
201
|
+
- **面板自己的页盖过 pane** ✓(用户 2026-10-26 明确要求"让位"✓):canonical 先取 `panelPage()` ✓,**只有它为空时才写 `pane/<kind>`** ✓(旧版把 pane 接在页后面 ✗,用户看到的仍然是 `…/task/<id>/pane/client.js` ✗)。**驱动面板页时要把 `pane/<kind>` 尾巴摘掉** ✓,否则会把面板页当成 `files` 页去点 ✓(路由器自己写出的链接就会踩到 ✓)。**代价**:手写"页 + pane"的链接会因为 canonical 永远不写那条尾巴而按时过期 ✓(路由器自己已经不会再写这种链接 ✓)。
|
|
202
|
+
- **面板链接按前缀落地** ✓(`sameRoute` 的面板分支 ✓):链接可以比屏幕上的页**更宽**(`plugins/<包>` 被 `plugins/<包>/<组件>` 满足 ✓),但不能更窄 ✓;链接没写 pane 就忽略 pane ✓,写了就要对上 ✓。精确比较会让"页面已经打开"的链接一直等下去 ✗。
|
|
203
|
+
|
|
204
|
+
## 性能契约(2026-10-26 起)
|
|
205
|
+
|
|
206
|
+
- **DOM 通知一帧一次** ✓:`onDomChange` 的处理器被 `requestAnimationFrame` 合并 ✓(一个任务里几十次 mutation 只读一次状态 ✓),没有帧的宿主(测试 / 极简 DOM)**同步执行** ✓ —— 假 DOM 的断言依赖这一点 ✓。实测(隔离宿主):20 个连发任务从 **20 次 reconcile → 8 次**(= 跨了 8 帧,每帧最多一次)✓。
|
|
207
|
+
- **地址栏摆放的短路** ✓:有电源按钮的界面(会话页)先比一个形状签名(行矩形 + 按钮矩形 + 视口宽 + 行文本长度)✓,没变就**不重新走一遍行内元素** ✓(那一遍每个叶子一次 `getBoundingClientRect`,流式回答时每帧都在跑 ✗)。**无电源按钮的界面不缓存** ✓(那条带里放的是页面自己的内容,行不动它也会变 ✓)。
|
|
208
|
+
- **最近列表一次扫描** ✓:`sessionTitles()` 一次建 `data-row-key → 标题` 的 Map ✓,`recentLabel(hash, titles)` 复用 ✓ —— 别按条目各扫一遍侧栏 ✗(以前 8 条 × 2 次 ✓)。
|
|
209
|
+
- **`report().work`** ✓:`{ reconciles, placements }` 两个计数器,用来区分"被合并"和"每批都跑" ✓(对照实验就靠它 ✓)。
|
|
210
|
+
- **别在 mutation 回调里同步做重活** ✗:所有"读整个页面"的动作都要走这个合并路径 ✓,或像摆放那样先做形状短路 ✓。
|
|
211
|
+
- **`characterData` 不在观察范围** ✓(流式文本更新本来就不触发 ✓),`attributeFilter` 只列真正改变路由的属性 ✓。
|
|
212
|
+
|
|
213
|
+
## 链接驱动的小护栏(2026-10-26 起)
|
|
214
|
+
|
|
215
|
+
- **已经被隐藏的行** ✓:自动化任务的链接若指向被当前筛选藏起来的任务,`clickScheduleTask()` 找不到行时会先按回「全部」✓(下一轮重试就能找到 ✓),而不是干等到 8 秒过期 ✓。
|
|
216
|
+
- **已经展开的卡** ✓:`clickSettingsProvider()` 发现目标卡片**已经展开**就**不再点** ✓ —— 官方的编辑按钮是开关 ✓,重试循环会把卡片开了又关 ✗。
|
|
217
|
+
- **卸载时取消排队的帧** ✓(`cancelAnimationFrame(placeFrame)` ✓),别让已摘掉的 bar 在下一帧写样式 ✗。
|
|
218
|
+
|
|
219
|
+
## 兼容自检(#6,2026-10-26 已实现)
|
|
220
|
+
|
|
221
|
+
官方改契约不会抛错,只会让某一层**静默失去名字** ✗ —— 所以渲染期要做一次"屏幕在、钩子没了"的检查 ✓。
|
|
222
|
+
|
|
223
|
+
- **判据 = 弱信号 + 强钩子** ✓:弱信号证明"那个界面确实在屏上"(别的、还认得的东西 ✓),强钩子是本插件真正要读的东西 ✓。两者同时成立才算不兼容 ✓ —— 只用强钩子会误报(界面本来就没开 ✓)。
|
|
224
|
+
- **五项检查**(`compatibilityProblems()` ✓,全部只读 ✓):
|
|
225
|
+
1. `settings-overlay`:`role="dialog"` + `aria-modal="true"` + `nav` 里 ≥3 个按钮 ✓,但没有 `data-shortcut-modal="settings"` ✓(这个标记历史上就挪过一次 ✓)。
|
|
226
|
+
2. `plugin-manager`:`[data-plugin-panel]` 在 ✓,但六种钩子(`data-plugin-package` / `data-plugin-item` / `data-plugin-row` / `data-plugin-detail` / `data-plugin-item-detail` / `data-plugin-row-detail`)一个都没有 ✓。
|
|
227
|
+
3. `schedules-filter`:自动化面板在(`activePanelId === 'schedules'` ✓)且页面上有 `[role="group"]` ✓,但没有哪一组是"三个 `aria-pressed` 按钮" ✓。
|
|
228
|
+
4. `conversation-tabs`:**注册的会话视图 ≥2 个** ✓(单视图组合没有页签 ✓)、屏上有 `[data-chat-turn]` ✓、但没有 `[data-conversation-tabs]` ✓。
|
|
229
|
+
5. `sidebar-rows`:`[data-slot="sidebar.settings"]` 在(真侧栏 ✓)、目录里有会话 ✓、但 `[data-row-key^="session:"]` 为 0 ✓。
|
|
230
|
+
- **每项只报一次** ✓(`observed.compat` 记名字 ✓),**每秒最多扫一次** ✓(`COMPAT_EVERY_MS = 1000` ✓,在 `reconcile` 里跑 ✓)。
|
|
231
|
+
- **措辞统一** ✓:`与当前 DSH 版本不兼容:<name> —— 期望 <expected>,实际 <found>。这一层的链接会静默失效,请升级 DSH 或本插件` ✓(`console.warn` ✓,桌面版没有控制台,所以同时进 `report().compat` ✓)。
|
|
232
|
+
- **只告警、绝不改行为** ✓(检查里不写任何东西、不影响 canonical ✓)。
|
|
233
|
+
- **新增一层路由时同步加一条检查** ✓:弱信号选"这一层旁边还稳的东西" ✓,不要拿待检查的钩子自己当弱信号 ✗(那样它一坏就静默跳过 ✓,等于没查 ✓)。
|
|
234
|
+
|
|
106
235
|
## 跨机开发日志
|
|
107
236
|
|
|
108
237
|
改动记录在私有库 [DDDMUC/repo-devlogs](https://github.com/DDDMUC/repo-devlogs) 的 **`dsh-url-router/`** 文件夹(`HANDOFF.md` 最新一轮在最上面;macOS 端写 `WORKLOG-macos.md`,条目以 `[macOS]` 开头)。按该库规矩,每条先写 `**运行环境**`(设备 / 应用 / 服务商与模型),再写做了什么、动了哪些文件、怎么验证、遗留问题。
|
|
@@ -110,7 +239,7 @@
|
|
|
110
239
|
## 待办设计(用户 2026-10-10 提的一批,未实现,设计已定)
|
|
111
240
|
|
|
112
241
|
- **外部 fragment 写者共存(#4)**:`dsh-milestone` 之类的插件会在同一条 URL 的**同一个 fragment** 里写 `#msg=…` ✓。一个 fragment 只能有一个写者 ✗ → 现在的行为是"互不认账、静默覆盖" ✗。要做:① 自己的写入**只碰自己的段位** ✓(已经是这样 ✓);② 读到**不属于本插件语法**的 fragment 时,**留一条明确诊断**(点出对方形态 + 说明互斥),不要静默 ✓;③ 真共存需要**统一裁决**(见下条的接缝)✓。
|
|
113
|
-
-
|
|
242
|
+
- ~~**兼容边界自检(#6)**~~:**已实现(2026-10-26,见「兼容自检」小节)** ✓。
|
|
114
243
|
- **路由体检(#7)**:一个 `window.__dshUrlRouter.report()` ✓(无 UI 依赖 ✓,绝不抛错 ✓),列出:当前 canonical 路由 ✓、正在落地的链接与重试次数 ✓、每一层界面**读到多少个钩子** ✓(会话行 / 工作区行 / 视图 tab 与 id 列表 / 轮次 / 轨迹行 / 轮次边界 / 插件行 / 官方项 / 设置覆盖层与分区 / 任务看板 / 右栏 pane ✓)、已发出的告警 ✓、以及被"不碰"的段位 ✓。
|
|
115
244
|
- **对外接缝(#8)**:`ctx.urlRouter`(服务)或同名窗口事件 ✓,让第三方把"当前界面"写成链接 ✓,并由本插件**统一裁决** fragment 所有权 ✓ —— 这同时也是 #4 的真正解法 ✓。
|
|
116
245
|
- **面板内层页面(#5)**:技能中心 / SSH 需要各自发契约 ✓(`data-*` + 接受同名窗口事件 ✓);任务看板是现成先例 ✓,可作为 PR 往上游推 ✓(需要各仓 owner 同意 ✓)。
|
|
@@ -123,6 +252,9 @@
|
|
|
123
252
|
- **座位/视口判据用官方同款选择器** ✓:`[data-chat-turn]:not([hidden])` ✓,并自己排除 `closest('[hidden]')` ✓ —— **虚拟列表会把未载入的轮次也渲染进 DOM** ✗(只查"存在"会假报就位 ✗,实测 ✗)。
|
|
124
253
|
- **`sameRoute` 对 turn 的落地判据 = 目标真在视口** ✓(`routeKey` 比法会让 canonical 一带上 turn 就判等 → 链接永不驱动 ✗,死锁实测 ✓)。
|
|
125
254
|
- **drive 会重入 reconcile** ✓,重入可能已 `dropLink()` → **守门后立刻 `const link = linked`,体内一律用快照** ✗(否则 `reading 'inner'` ✓,实测 ✗)。
|
|
255
|
+
- **官方方法绝不拆出来单独调用** ✗(2026-10-26 rc.3 实测):`loadThrough` 是**原型方法** ✓,内部读 `this.openState` ✓ —— 旧代码 `const load = session.loadThrough; load(seq)` 让 `this` 丢了 ✓ → 每次抛 `Cannot read properties of undefined (reading 'openState')` ✓,被 `attempt` 吞掉 → **轮次跳转静默失效** ✗(没有诊断位 ✓,只有"链接 8 秒后过期" ✓)。现在一律 `session.loadThrough(seq)` ✓。**同类风险**:`openTab` / `openTabIn` / `setActive` / `toggleExpanded` 这些也都要带接收者 ✓。
|
|
256
|
+
- **翻页之后要自己滚** ✓(2026-10-26 补):官方聊天是 `loadThrough(seq).then(() => scrollToTurn(turn))` 两步 ✓(`ChatNavigation.request()` ✓),它那个 navigation 对象外部拿不到 ✗ → 我们只做前半步时,座位确实加载了 ✓,但虚拟列表**不会自己滚过去** ✓,紧接着就把刚加载的轮次裁掉 ✓(实测:座位在 1.8 秒出现、`top=-43325`、之后消失 ✗)。现在 `loadThrough` 的 promise 一 resolve 就用**最多 25×50ms** 的耐心找座位并 `scrollIntoView({block:'center'})` ✓(座位要等聊天下一次 commit 才渲染 ✓)。实测:跳 60 轮前的轮次 **2.8 秒**落地 ✓、可见 ✓。
|
|
257
|
+
- **已经满足的轮次链接要保留轮号** ✓:座位本来就在屏上时链接会立刻落地 ✓,旧代码落地后写回的是"纯会话"canonical ✗ → 粘贴的 `turn/<n>` 一落地就把轮号丢了 ✗。现在落地时先把轮次记成 anchor ✓ 再写 canonical ✓。
|
|
126
258
|
- `loadThrough` 是**公开 slot 契约** ✓(`session-controller` 1885 注释 "page backwards until the window covers seq" ✓)。官方 `uiChat.scrollToTurn(sessionId, turn)` 那条请求(讨论 #9339 ✓)**已降级为锦上添花** ✓(不阻塞 ✓)。
|
|
127
259
|
|
|
128
260
|
## 已实现:#4 外部 fragment 共存 / #7 路由体检(2026-10-11)
|
package/README.md
CHANGED
|
@@ -38,10 +38,18 @@ dsh plugin --profile web add "link:/path/to/dsh-url-router"
|
|
|
38
38
|
| `#/panel/plugins/<包名>/<组件 id>` | 插件页面里的某个组件页 | 面板自己的组件行控件 |
|
|
39
39
|
| `#/panel/task-board/task/<任务 id>` | 任务看板里某个任务的详情 | 任务看板自己的 `dsh-taskboard-open-task` 窗口事件 |
|
|
40
40
|
| `#/panel/task-board/new` | 任务看板的新建任务表单 | 任务看板自己的 `dsh-taskboard-new-task` 窗口事件 |
|
|
41
|
+
| `#/panel/schedules/filter/<all\|active\|inactive>` | 自动化任务的状态筛选(已开启 / 已结束;默认的 `all` 不写进链接) | 面板自己的筛选按钮(按位置,语言无关) |
|
|
42
|
+
| `#/panel/schedules/task/<任务 id>` | 自动化任务里某条任务的详情(规则页) | 面板自己的任务行 |
|
|
43
|
+
| `#/panel/schedules/task/<任务 id>/records` | 同一条任务的「运行记录」页(默认的 `rule` 不写) | 面板详情里的页签 |
|
|
44
|
+
| `#/settings/<分区>/provider/<提供商 id>` | 模型页里点了「编辑」展开的那张提供商卡片(id 是配置里的 key,不是显示名) | 卡片自己的「编辑」按钮 |
|
|
45
|
+
| `#/power` | 电源菜单(浮层)。链接只能**打开**它,关闭/重启永远要人点 | 电源按钮 |
|
|
46
|
+
| `#/session/<子会话 id>/subagent-of/<父标题>` | 子代理会话:一眼看出它是谁的子代理(父是装饰,打开的是子会话;父没标题时退回父 id) | 聊天里的子代理卡片 |
|
|
41
47
|
|
|
42
48
|
输入侧宽容:`sessions`/`panels` 复数、百分号编码都接受,一律规范化成上面的形式。**不认识的段位不碰**,只留一条诊断(留给以后的路由器,或者别的插件写的深链形态 ✓ —— 一个 fragment 只能有一个写者 ✓,所以读到不属于本插件的形态时会**明确说一句**,而不是静默 ✗)。
|
|
43
49
|
|
|
44
|
-
面板内部页是**面板自己的词汇表**,本插件不发明:插件管理器发布 `data-plugin-detail` / `data-plugin-item-detail` / `data-plugin-row-detail`,任务看板发布 `data-dsh-taskboard-open-task` / `data-dsh-taskboard-new-task
|
|
50
|
+
面板内部页是**面板自己的词汇表**,本插件不发明:插件管理器发布 `data-plugin-detail` / `data-plugin-item-detail` / `data-plugin-row-detail`,任务看板发布 `data-dsh-taskboard-open-task` / `data-dsh-taskboard-new-task`,并接受同名窗口事件;自动化任务面板的三态筛选只有 `aria-pressed`(按钮上没有值),所以链接里写的是官方那三个固定值的**位置**(`all` / `active` / `inactive`),换语言不影响;任务详情用行上的 `aria-expanded` 与 `aria-describedby` 末段的 id 认,页签用它自己的 `data-detail-tab="rule|records"`。
|
|
51
|
+
|
|
52
|
+
**右栏 pane 是页面旁边的一层,不是页面本身** ✓:面板一旦有自己的页(任务详情、插件页…),地址栏就**只写那一页**(`#/panel/schedules/task/<id>/records`),旁边开着右栏也不会多出 `pane/…`;只有面板没有自己的页时才写 `pane/<kind>` ✓。右栏收起时那一层**直接不写**(收起的右栏会把页签留在 DOM 里,旧版因此把 `pane/client.js` 这种已经不在屏上的东西写进了地址栏);pane 的 kind 优先读右栏控制器里的 `{kind}`,因为 Files 页签在打开文件后标题会变成文件名(`client.js`),拿标题当 kind 就是错的。
|
|
45
53
|
|
|
46
54
|
### 每一行都是真链接
|
|
47
55
|
|
|
@@ -66,18 +74,42 @@ macOS 的标题栏是**原生**的(左边红黄绿三个灯),页面里没
|
|
|
66
74
|
- 位置:先量这一行里**所有真正占位的东西**(有自己文字的元素、按钮、图标),按左边界排序并合并重叠 → 得到若干空白段 → 取**最宽的那一段**,右端贴着头部按钮、左端跟着走,两边各留 25px 内缩 ✓。
|
|
67
75
|
- 宽度封顶 **1150px**:空白段的左边界是跟着**会话标题长度**跑的 ✓,不封顶的话换个会话框就变一次宽 ✓;放不下时按实际空白缩 ✓。
|
|
68
76
|
- 和电源按钮**同一行**(垂直居中于它 ✓),`position: fixed` —— 所以既不遮挡也不挤压任何东西 ✓。
|
|
69
|
-
-
|
|
77
|
+
- 没有电源按钮的界面(插件页、设置页、面板页):**按当前屏幕那一行重新量** ✓ —— 扫描这一行上的控件与标题,取**最宽的空段**放框(自动化任务详情的「规则 / 任务运行记录」页签不会再被压住 ✓);一段都不够宽就**隐藏** ✓,宁可不画也不压在可点的东西上 ✓。窗口尺寸变过(> 8px)时旧数据一律不信 ✓,等回到会话页重新量 ✓。
|
|
70
78
|
- **只有一个框** ✓:挂载前先按 id 摘掉旧的 bar 和样式表 ✓,所以 effect 重跑多少次(热重载、重复 apply)都只有一个 ✓。
|
|
71
79
|
- Windows 那条路径完全不变,`data-windows-titlebar` 优先,两种形态互斥 ✓。
|
|
72
80
|
- 输入、反射、拒识、Escape 恢复的逻辑与 Windows 版共用同一份代码 ✓。
|
|
73
81
|
- 输入什么 ✓:任何 `#/…` 路由(会话 / 工作区 / 视图 / 轮次 / 行 / 面板 / 设置三层 / 右栏 pane)、**裸会话 id**、或**整条链接**(只取它的 fragment)✓;回车写 hash → 浏览器触发 `hashchange` → 落地走的就是"粘贴链接"那条已验证的路 ✓。
|
|
74
82
|
- 它同时是**反映** ✓:本插件每次写地址栏都会同步进这个框 ✓,所以它显示的就是"现在在哪" ✓。Escape 还原 ✓;认不出的文本不动地址栏 ✓。
|
|
75
83
|
|
|
84
|
+
### 设置页:地址栏的三个偏好
|
|
85
|
+
|
|
86
|
+
在**插件管理器**里找到本插件那一行 → 右侧的配置箭头(也就是 `#/panel/plugins/dsh-url-router/dsh-url-router`)就是本插件的设置页。三个偏好跟着这台机器走(`localStorage`),改完立刻生效,不用重启:
|
|
87
|
+
|
|
88
|
+
| 偏好 | 默认 | 说明 |
|
|
89
|
+
| --- | --- | --- |
|
|
90
|
+
| 首次点击地址栏时全选 | 开 | 像浏览器地址栏:**第一次**点进去,框里原来的链接整条选中,直接输入就替换它;已经聚焦时再点,才是放光标。关掉后就是一个普通文本框。 |
|
|
91
|
+
| 悬浮时才显示地址栏 | 关 | 平时隐去(只降透明度,位置和尺寸都不变),鼠标移到地址栏所在的位置才出现;输入框有焦点、或最近列表开着时不会消失。 |
|
|
92
|
+
| 记住最近链接的条数 | 8 | **下拉框里能看到的条数**(地址栏里正显示的那个界面不算 —— 它已经在你眼前了,离开之后才会回到列表)。最低 **0**(= 不记忆,地址栏也不再弹最近列表),**没有上限**。 |
|
|
93
|
+
|
|
94
|
+
最近列表的用法与浏览器一致:点地址栏就弹出来,输入即过滤,Esc 关掉;条目有两个来源 —— **用过的界面**(本插件写进地址栏的每一个界面 ✓)和**粘贴过的链接**(在地址栏里粘贴任何本插件认得的链接或裸会话 id ✓,落地与否都先记住 ✓)。
|
|
95
|
+
|
|
96
|
+
**左键 = 跳过去,右键 = 复制链接** ✓:右键那一下会吃掉浏览器菜单 ✓,直接把**完整 URL**(路径 + 启动 `?token=…` + fragment ✓)放进剪贴板 ✓,粘到新标签页或发给别人就能开 ✓;被复制的那一条会短暂显示「已复制 ✓」✓,地址栏右端也会闪一下「已复制链接」✓(列表不会因此关掉、光标也不会跑 ✓)。
|
|
97
|
+
|
|
98
|
+
- 身份是**界面**,不是坐标 ✓:`#/session/<id>/turn/3/row/…` 这类点位链接归并到它所在的会话 ✓,不会用同一个对话把列表刷满 ✓;同一界面再次出现只是**提到最前** ✓,不重复 ✓。
|
|
99
|
+
- 当前屏幕**不列出来** ✓(它不是一个"回去"的地方)。
|
|
100
|
+
- 条数就是下拉框里能看到的条数 ✓:磁盘上多留一个缓冲位给"当前界面" ✓,所以设 8 就真能看到 8 条 ✓(不是 7 条 ✗)。调小会**立刻**裁掉多余的 ✓;调到 0 会清空并不再记录 ✓。
|
|
101
|
+
- 会话条目显示侧栏里的**对话标题** ✓(读不到标题时才只显示链接 ✓);面板 / 设置等段位显示中文类别 + 链接本身 ✓。
|
|
102
|
+
- 页面里还有「清空记录」按钮 ✓。
|
|
103
|
+
- 存在这两个键里:`dsh-url-router-settings` 与 `dsh-url-router-recent` ✓ —— 换浏览器配置就没了,符合"这台机器、这个窗口"的语义 ✓。
|
|
104
|
+
- **粘链接不会按到开关** ✓:插件行的启用开关就在配置控件旁边,两个都是 `<button>` ✓ —— 驱动组件页时只点**非 `role="switch"`** 的控件 ✓,所以开不到的组件页链接会按时过期并留一条诊断 ✓,**不会**顺手把插件关掉 ✓(实测踩到过 ✓)。
|
|
105
|
+
|
|
76
106
|
### 它守的规矩
|
|
77
107
|
|
|
78
108
|
- **只动 fragment**:路径与查询串从不改写(启动 `?token=…` 能活下来)。
|
|
79
109
|
- **唯一写者**:本插件拥有 fragment,请**不要**和别的写 fragment 的插件同时启用(例如 `dsh-session-url`),两者会互相覆盖。
|
|
80
110
|
- **只用 `replaceState`**:浏览面板不污染后退栈。
|
|
111
|
+
|
|
112
|
+
- **`#/power` 是唯一的浮层链接** ✓:菜单是瞬时状态(点外面就关),所以它只承诺“能打开”,不承诺“能停留” ✓;开着时地址栏写它 ✓,关掉后回到底下那个界面 ✓。**关闭/重启永远不驱动** ✗。
|
|
81
113
|
- **链接落地前,挡在前面的表面先让开** ✓:主面板选中时地址栏读到的就是面板 ✓,设置浮层在屏时读到的就是 `#/settings/<分区>` ✓ —— 这两种情况下会话**其实已经在背后打开了** ✗,屏幕上却像"没反应" ✓。所以驱动任何非设置链接之前 ✓,先 `beginNavigation()` + `selectPanel(null)` 让面板退场 ✓,再用**官方自己那条 document 级 Escape** 把设置浮层请走 ✓(模态栈是官方内部的私有注册表 ✗ 外部拿不到 ⇒ Escape 是浏览器侧唯一可用的官方关闭路径 ✓,也就是你按 Esc 走的那条 ✓)。**一条链接只按一次** ✓:不会在你正在操作页面时乱按键 ✓,代价只是万一那次没关掉、你再粘一次 ✓。设置自己的链接**不动**浮层 ✓。
|
|
82
114
|
- **轮次信息:能证明才写** ✓。助手行自己的身份里就带轮次(`row/assistant/80/32` = 第 80 轮第 32 项 ✓);**工具行不带** ✗(只有调用 id ✓)→ 于是我从视图的**轮次边界** `[data-turn-start]` 反推 ✓,**但只在行夹在两个已渲染边界之间时才写** ✓(`turn/82/row/tool/call/…` ✓)。视图是虚拟化的 ✓,边界可能只渲染了一侧 ✗ → 那时**宁可不写轮次** ✓,也绝不猜一个错的轮次 ✗。
|
|
83
115
|
- **轨迹里的每一行都有自己的链接** ✓:`#/session/<id>/row/<类>/<作用域>/<id>`(例:`row/tool/call/call_ce3a…`、`row/assistant/80/32` = 第 80 轮第 32 项 ✓;有工作区时跟在会话 id 之后 ✓)。身份来自视图自己发的 **`data-trajectory-row-key`** ✓(值是 `<类>%00<作用域>%00<id>` ✓,我把它拆成可读路径 ✓);你家 Trajectory Plus 发的是 **`data-dshts-turn`**(轮号 ✓)→ 那种行按轮次写(`turn/<n>` ✓)。点一行就反映进地址栏 ✓(**纯被动**:不 preventDefault ✓)。粘贴行链接会打开那个对话并**尽力滚到那一行** ✓ —— 视图是虚拟化的 ✓,**没渲染出来的行不会被假装滚到** ✗(不会骗你 ✓)。
|
|
@@ -92,7 +124,7 @@ macOS 的标题栏是**原生**的(左边红黄绿三个灯),页面里没
|
|
|
92
124
|
|
|
93
125
|
- **轮次链接是"位置",而且现在真能跳** ✓:点某一轮,地址栏记下 `#/session/<id>/turn/<n>` ✓;**粘贴它会打开那个对话、把历史翻页回那一轮、再滚到它** ✓(真机实测:贴 `/turn/118` → 窗口变成 118→121、目标在视口 true、最顶可见正是 118 ✓)。用的是**官方已有公开契约** ✓:`sessions.retainScope(id, source)` 拿到会话控制器 ✓ → `session.projections.faceOf('turnOutline')` 读到那一轮的 `seq` ✓ → `session.loadThrough(seq)` 翻页回去 ✓(完事 `reference.release()` ✓)→ 再按官方视口同款选择器 `[data-chat-turn]:not([hidden])` 滚到它 ✓。座位高度/相交都读不到时**不算到位** ✓,宁可继续重试也不假报 ✗。
|
|
94
126
|
- **右栏 pane 已闭环** ✓:`#/session/<id>/pane/<kind>` 与 `#/panel/<面板>/pane/<kind>` ✓(真机验证:贴链接开出对应页签 ✓、地址栏跟着切换 ✓)。页签种类由右栏自己注册 ✓(实测 7 种:`guide` / `subagentchat` / `changes-review` / `plan` / `files` / `text` / `terminal` ✓)。
|
|
95
|
-
- **设置面板已纳入**:`#/settings` ✓、**`#/settings/<分区>`** ✓、**`#/settings/<分区>/<页签>`** ✓(页签靠标准 `role="tab"` + `aria-selected` ✓,分区内任何页签都吃这一套 ✓)。分区**没有稳定 id** ✗(实测:导航项上只有 `aria-current="true"` ✓,父链是 `nav` ✓,右侧内容区一个 `data-*` 都没有 ✗;官方内部的 `openSection(id)` 也不对外 ✗)→ 所以**分区的身份就是它的显示名** ✓:反映读「`aria-current="true"` 且属于 `nav`」的那个按钮的**标签文字** ✓,驱动按标签点
|
|
127
|
+
- **设置面板已纳入**:`#/settings` ✓、**`#/settings/<分区>`** ✓、**`#/settings/<分区>/<页签>`** ✓(页签靠标准 `role="tab"` + `aria-selected` ✓,分区内任何页签都吃这一套 ✓)。分区**没有稳定 id** ✗(实测:导航项上只有 `aria-current="true"` ✓,父链是 `nav` ✓,右侧内容区一个 `data-*` 都没有 ✗;官方内部的 `openSection(id)` 也不对外 ✗)→ 所以**分区的身份就是它的显示名** ✓:反映读「`aria-current="true"` 且属于 `nav`」的那个按钮的**标签文字** ✓,驱动按标签点 ✓。**入口可能是「齿轮 → 菜单 → 设置」两级** ✓:`settings.launcher` 座位是单占的 ✓,空位时齿轮一点就开浮层 ✓,被账号行占位时一点只弹菜单 ✗(实测桌面:座位里没有齿轮 ✗)——现在分步走 ✓(浮层 → 屏上的设置项 → **按一次**启动器 ✓),**只点真正在屏上的项** ✓(关掉的菜单还挂在 DOM 里 ✗)、**启动器每个链接只按一次** ✓(它是开关 ✗,重复按会把菜单关掉 ✗)。含义:**换语言或官方改文案后,旧的分区链接会失效** ✗(但你重新点一次,地址栏就会写出新链接 ✓ —— 自洽、不过期 ✓)。裸 `#/settings` 只要面板开着就算落地 ✓,随后地址栏自动升级为具体分区 ✓。
|
|
96
128
|
- **设置页分区的身份只有显示名**(上一条的含义,别当成 bug 去找官方要 id ✗):官方那个 `openSection(id)` 里的 id **不对外** ✗,DOM 上也没有任何 `data-*` ✓ —— 所以链接里写的就是用户读到的那行字 ✓,官方改文案或换语言后旧链接失效是**设计如此** ✓。
|
|
97
129
|
- **会话内的轮次与行锚点已纳入** ✓:`#/session/<id>/turn/<n>` ✓(**反映 + 翻页 + 滚动 ✓ 全链路已闭环**)、`#/session/<id>/row/<类>/<作用域>/<id>` ✓(反映 + 尽力滚动 ✓,含可证明时的轮次归属 ✓)。轮次跳转用的是官方公开 API ✓,**没有等待任何新接缝** ✓(`uiChat.scrollToTurn` 那条请求已降级为锦上添花 ✓,讨论见 <https://github.com/deepseek-ai/deepseek-harness/discussions/9339> ✓)。
|
|
98
130
|
- **技能中心 / SSH 的内部页**:需要它们各自发布"当前实体 id + 按 id 打开"的契约(任务看板就是这么做的)。
|
|
@@ -101,11 +133,13 @@ macOS 的标题栏是**原生**的(左边红黄绿三个灯),页面里没
|
|
|
101
133
|
|
|
102
134
|
### 开发
|
|
103
135
|
|
|
104
|
-
手写 JS 直接进 `lib/`(无构建步骤),`npm test` 直接跑交付产物(
|
|
136
|
+
手写 JS 直接进 `lib/`(无构建步骤),`npm test` 直接跑交付产物(107 条用例,`node --test`)。改之前请读 [AGENTS.md](AGENTS.md) 里的路由与行链接契约。
|
|
105
137
|
|
|
106
138
|
### 排障
|
|
107
139
|
|
|
108
|
-
浏览器 Console 里调用 `window.__dshUrlRouter.report()` ✓,它会列出:当前地址栏 ✓、本插件认为屏幕在哪个界面 ✓、正在落地的链接与重试次数 ✓、**每一层读到了多少个钩子** ✓、右栏 pane 的**真实 kind 列表**
|
|
140
|
+
浏览器 Console 里调用 `window.__dshUrlRouter.report()` ✓,它会列出:当前地址栏 ✓、本插件认为屏幕在哪个界面 ✓、正在落地的链接与重试次数 ✓、**每一层读到了多少个钩子** ✓、右栏 pane 的**真实 kind 列表** ✓、它**拒绝认领**的段位 ✓、以及 `compat`(与当前 DSH 版本不兼容的层,见下)✓ 和 `work`(reconcile / 摆放次数,看性能用)✓。它绝不出错、也不写任何东西 ✓。
|
|
141
|
+
|
|
142
|
+
**兼容自检** ✓:官方一改 DOM 契约,插件不会报错,只会**静默不再给某一层写链接** ✗。所以渲染期会做五项检查(设置浮层标记、插件管理器钩子、自动化筛选组、会话页签容器、侧栏会话行)✓:屏幕上确实出现了那一层、但本插件要读的钩子没了 → **每个界面只警告一次** ✓,措辞统一为「与当前 DSH 版本不兼容」✓,同时记进 `report().compat` ✓。它**只告警、不改行为** ✓。
|
|
109
143
|
|
|
110
144
|
## English
|
|
111
145
|
|
|
@@ -127,6 +161,12 @@ Listed in the community plugin index: [zhu1090093659/dsh-community-plugins](http
|
|
|
127
161
|
| --- | --- |
|
|
128
162
|
| `#/session/<id>` | the conversation the main view shows |
|
|
129
163
|
| `#/panel/<panelId>` | a main-column panel |
|
|
164
|
+
| `#/panel/schedules/filter/<all\|active\|inactive>` | the automation list's status filter (the default `all` is not written) |
|
|
165
|
+
| `#/panel/schedules/task/<id>` | one automation task's detail (its rules tab) |
|
|
166
|
+
| `#/panel/schedules/task/<id>/records` | that task's records tab (the default `rule` is not written) |
|
|
167
|
+
| `#/settings/<section>/provider/<provider id>` | the provider card whose editor is open in Models (the id from the config, not the display name) |
|
|
168
|
+
| `#/power` | The power menu (a popover). A link can only **open** it, shutdown and restart stay human actions | the power button |
|
|
169
|
+
| `#/session/<child id>/subagent-of/<parent title>` | a subagent conversation, naming whose subagent it is (the parent is decoration and falls back to its id when untitled; the child opens) |
|
|
130
170
|
| `#/panel/plugins/<package>` | an installed plugin's page |
|
|
131
171
|
| `#/panel/plugins/<item>` | a built-in plugin's page (`shell`, `agent-loop`, `subagent`, `web-search`) |
|
|
132
172
|
| `#/panel/plugins/<package>/<component>` | one component page inside that plugin's page |
|
|
@@ -149,6 +189,27 @@ The Windows desktop window draws its own title bar **inside the page**: the prel
|
|
|
149
189
|
- Accepts any `#/…` route, a bare conversation id, or a whole link (only its fragment is used); Enter writes the hash, the browser fires `hashchange`, and the link lands through the very same path a pasted link takes.
|
|
150
190
|
- It also **reflects**: every write this plugin makes to the address bar is mirrored into the box, so it always names what is on screen. Escape restores it; text that names no route is refused.
|
|
151
191
|
|
|
192
|
+
### Settings: the address bar's three preferences
|
|
193
|
+
|
|
194
|
+
Find this plugin's row in the **plugin manager** and press its configure control (or open `#/panel/plugins/dsh-url-router/dsh-url-router` directly) — that page is this plugin's settings. All three preferences live on this machine (`localStorage`) and take effect the moment they change, no restart:
|
|
195
|
+
|
|
196
|
+
| Preference | Default | Meaning |
|
|
197
|
+
| --- | --- | --- |
|
|
198
|
+
| Select the whole link on the first click | on | Like a browser's address bar: the **first** click selects everything already in the box, so typing replaces the old link; a further click once the box is focused places the caret. Turned off, it is an ordinary text field. |
|
|
199
|
+
| Show the bar on hover | off | Hidden until the pointer is over it — only the opacity drops, so the spot it occupies and its size stay the same. A focused field or an open recent list keeps it visible. |
|
|
200
|
+
| Remembered links | 8 | How many links the drop-down lists (the screen already in the address bar does not count — it is where you are; it returns to the list once you leave it). The floor is **0** (no memory, and no recent list), and there is **no ceiling**. |
|
|
201
|
+
|
|
202
|
+
The recent list behaves like a browser's: focusing the bar opens it, typing narrows it, Escape closes it. Entries come from two places — **the screens this plugin named** (every view it wrote into the address bar) and **the links pasted into the bar** (anything it can open, remembered whether or not it lands).
|
|
203
|
+
|
|
204
|
+
**Left click opens it, right click copies it.** The right click swallows the browser menu and puts the **whole URL** on the clipboard — path, launch query (`?token=…`) and fragment — so it opens in a new tab or on another machine's window. The copied row briefly says "已复制 ✓" and the bar flashes "已复制链接"; the list stays open and the caret stays put.
|
|
205
|
+
|
|
206
|
+
- Identity is the **screen**, not the position: a link into `#/session/<id>/turn/3/row/…` folds into its conversation, so one conversation cannot fill the list; seeing the same screen again moves it to the front instead of adding it twice.
|
|
207
|
+
- The screen on display is not offered (it is not somewhere to go back to).
|
|
208
|
+
- The count is what the drop-down lists: one spare entry is kept on disk for the screen on display, so 8 really shows 8 links (not 7). Lowering the count trims immediately; 0 clears the list and stops remembering.
|
|
209
|
+
- A conversation entry shows the conversation title from the sidebar when the sidebar has it; other surfaces show their category and the link itself.
|
|
210
|
+
- The page also has a **clear** button.
|
|
211
|
+
- **A pasted link never presses a switch.** A plugin row's enable switch sits right beside its configure control and both are `<button>`s, so the driver clicks only controls that are not `role="switch"`: a component page that cannot be opened expires with a diagnostic instead of silently disabling the plugin.
|
|
212
|
+
|
|
152
213
|
### Rules it keeps
|
|
153
214
|
|
|
154
215
|
- **Fragment only.** Path and query are never rewritten.
|