dsh-url-router 0.2.0 → 0.3.0

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 CHANGED
@@ -13,6 +13,10 @@
13
13
 
14
14
  - **本插件是地址栏的唯一写者**。任何其它写 fragment 的插件都不得同时启用(`dsh-session-url` 就是这样一个插件)。
15
15
  - **段位**:`#/session/<id>`(会话,`uiWorkspace.openSession`)、`#/panel/<panelId>`(主面板,`ctx.layout.selectPanel`)。输入侧接受复数与百分号编码,规范化成单数形式;**不认识的段位一律不碰**,只留一条诊断。
16
+ - **轮次归属必须可证明** ✓:助手行自带轮次 ✓;工具行只有调用 id ✗ → 靠 `[data-turn-start]`(值 = 轮号 ✓)反推 ✓,**但只有"行之前和之后各有一个已渲染边界"时才写** ✓(`turn/<n>/row/…` ✓)。虚拟化下边界常常只渲染一侧 ✗ → 那就**不写轮次** ✓(绝不猜 ✗)。假 DOM 的 `compareDocumentPosition` 用"根的后代序号"实现 ✓(不要用 `ownerDocument` ✗,假件没有 ✓)。
17
+ - **行级路线**:`#/session/<id>/row/<类>/<作用域>/<id>` ✓(身份 = `data-trajectory-row-key` ✓,值是 `%00` 分隔的三元组 ✓ → 拆成路径段 ✓);家系视图的 `data-dshts-turn`(轮号 ✓)按 `turn/<n>` 写 ✓。**点击处理器必须"行优先"** ✓:轨迹行本身就是按钮 ✓,而处理器开头的 `closest([data-chat-turn])` 会直接把非轮次元素挡掉 ✓(历史 bug ✓)。反映**纯被动** ✓(不 preventDefault ✓)。驱动 = 尽力 `scrollIntoView` ✓(虚拟化未渲染的行**不假装** ✓,返回 false ✓)。canonical 里位置的优先级 **行 > 视图 > 轮次** ✓。
18
+ - **会话视图路线**:`#/session/<id>/view/<viewId>`(有工作区时跟在会话 id 之后 ✓)。**`uiConversation` 服务在根插件里读不到** ✗(实测 `ctx.get('uiConversation')` = undefined ✓,它是会话作用域内的 ✓)—— **视图 id 从槽位读** ✓:`ctx.slots.entries('conversation.view')` 的 `options.id` ✓,按 `options.order` 排序 ✓(chat=0 / trajectory=10 / surface=20 / plus2=21 ✓)。头部页签容器是 **`[data-conversation-tabs]`** ✓、页签是 `button[role="tab"][aria-selected]` ✓(**没有 id 写在 DOM 里** ✗)→ **位置↔注册顺序** 对齐出 id ✓。默认视图(第一个 ✓)不写进链接 ✓。
19
+ - **视图驱动必须排在工作区/会话分支之前** ✓,否则那两条会先 `return` 把它吞掉 ✓(历史 bug ✓);且**不能被 `source === 'hash'` 挡住** ✓(粘贴视图链接就是要切过去 ✓)。
16
20
  - **工作区路线**:`#/workspace/<工作区名>/session/<会话id>`(侧栏形状 ✓)。工作区行是 `data-row-key="workspace:<uuid>"` ✓、**行文本就是工作区名** ✓(无 `data-workspace-*` ✗,实测 ✓);会话所属工作区 = 树里**它之前最近的那个 workspace 行** ✓(`workspaceNameOf()` ✓,行锚点与反映共用 ✓)。名字是**装饰** ✓:`sameRoute` 对 workspace 路线只比**会话 id** ✓(名字过期也能落地 ✓);读不到工作区行就退回 `#/session/<id>` ✓(不猜 ✓)。
17
21
  - **行锚点的 href 也要带工作区** ✓(右键"复制链接"直接给出带归属的链接 ✓)。
18
22
  - **作用域由视图决定,不靠猜**:主面板是应用级界面(列的是本机插件,不是某个对话),所以它的链接**不带**会话 id;只有会话级视图才写会话。
@@ -41,7 +45,14 @@
41
45
  - **`aria-current` 必须在 DOM 观察者的 `attributeFilter` 里** ✓:切换分区只改这一个属性 ✓,漏了它地址栏就永远停在第一次看到的分区(历史 bug ✓)。
42
46
  - **裸 `#/settings` 立即可落地**(`sameRoute` 的定向例外 ✓):分区是那个表面的细化、不是另一个表面 ✓;但**两个命名分区之间仍然算不同** ✓(否则分区链接就不驱动了 ✓)。
43
47
  - **轮次锚点是"位置",只做反映**:点击聊天里的一轮(`[data-chat-turn]` ✓)把轮号写进地址栏 ✓;**绝不 preventDefault** ✗、**绝不碰交互元素**(button/a/input/textarea/select/[role=button]/[contenteditable] ✓ —— 逐个 `closest`,不用逗号选择器 ✓,因为小 DOM 桩可能不支持 ✓)。驱动侧只开会话 + 一条说明 ✓(官方没有对外跳转接缝 ✗)。
44
- - **右栏 pane 走 `sidebarRightTabs` 服务**:`activeTabId` 读 ✓、`openTab(kind)` 驱动 ✓;DOM 兜底必须**只认真正属于右栏**的标记 ✓(`data-dockkit-pane-active` 是主区共用的 ✗,历史踩过 ✓)。该服务已加进 `inject` ✓ 与包声明的官方模块 id ✓。
48
+ - **右栏根上有 `data-sidebar-right-session="<会话 id>"`** ✓(右栏是**按会话**的 ✓,实测与路由里的会话 id 一致 ✓);**页签只读 `[data-dockkit-strip-tabs]`** ✓ —— 空态那层也有 `data-dockkit-pane="pane1"` ✗,读"任意 pane-active"会把空态当页签 ✗(历史 bug ✓)。
49
+ - **官方源码级契约(读 dsh-web-frontend / sidebar-right 得到)** ✓:页签是 `role="tab"` ✓,活动的是 `aria-selected="true"` ✓,身份在 **`data-dockkit-tab`** ✓ —— 而**该值就是 `pageAddress(kind)` = `sidebar://<kind>[/<uuid>]`** ✓(`multiple: true` 的 kind 才拼 uuid ✓),所以 **kind 直接含在 tabId 里** ✓(`kindOfTabId()` 解 ✓)。`data-dockkit-pane` 的值是 **paneId**(`pane1` ✗),而且**主区也有同一套属性** ✗ → 读必须**限定在 `[data-sidebar-right-session]` 根内** ✓(否则读成主区的 pane ✗)。
50
+ - **控制器的两条路** ✓:`openTab(kind)` 要求**已挂载**(`require()` 抛 `no session surface is mounted` ✗);**`openTabIn(sessionId, kind, options)`** 才是显式带会话的公开路径 ✓(**参数顺序是 session 优先** ✗ 传反就成了"查一个叫 files 的会话" ✗ 历史 bug ✓)。`actionsFor(sessionId)` = `adopted.get(sessionId)?.store.actions` ✓,而 **adopt 发生在右栏被呈现时** ✓(`layout.openRightbar(track, fullscreen)` ✓,track = `session` | `panel` ✓)→ 所以驱动**先呈现右栏** ✓ 再 `openTabIn` ✓。
51
+ - **`isExpanded()`** = `mountedSurface().layout.expanded` ✓;`toggleExpanded()` 内部同样走 `require()` ✗(未挂载会抛 ✓,要 try ✗)。
52
+ - **旧守卫(已删)**:两条 pane 分支原来都有 `source !== 'hash'` ✗ → **粘贴 pane 链接不驱动** ✗(视图那次修了、pane 漏了 ✓)。
53
+ - **驱动三级链(旧记录)**:`sidebarRight.openTab(kind, { sessionId })` → `openTabIn(kind, sessionId)` → **guide 动作**(空态卡片背后那一步 ✓,`sidebarRightTabs.guideEntries` ✓)。**真机全部无效** ✗(零异常零诊断 ✓)→ 与 `uiConversation` 同一结论:**根插件拿到的是根控制器** ✗,会话内的那一个够不着 ✓ → 需要**会话作用域接缝** ✓。
54
+ - **右栏有两个服务,别混** ✓(实测两个面):**`sidebarRightTabs` = 注册表** ✓(`kinds` / `ids` / `entries` / `register` / `active` / `candidates` / `guide` ✓);**`sidebarRight` = 控制器** ✓(`openTab(kind, options)` / `openTabIn` / `close` / `closeIn` / `toggleExpanded` / `isExpanded` / `active` / `split` / `focus` / `screen` ✓)。读活动页签:控制器 `active` 优先 ✓(可能是值或方法 ✓)→ 注册表 `active` ✓ → 再 DOM 兜底 ✓。开页签:**必须走控制器** ✓(调注册表**静默失败** ✗,历史 bug ✓)。**真机仍未打开** ✗(9 个 kind 全试、零异常 ✓)→ 疑似**根控制器 vs 会话内控制器**(与 `uiConversation` 同类 ✓),需要会话作用域接缝 ✓。`paneKinds()` 已备好(供体检/预校验 ✓),暂未接进认领路径 ✓。
55
+ - **旧记录(仍在)**:DOM 兜底必须**只认真正属于右栏**的标记 ✓(`data-dockkit-pane-active` 是主区共用的 ✗,历史踩过 ✓)。该服务已加进 `inject` ✓ 与包声明的官方模块 id ✓。
45
56
  - **链接落地即交权**:视图一旦等于链接描述的界面,链接立刻失去权威(只留"还没落地"的窗口期)。否则用户在面板里继续点进去时,地址栏会被拽回链接那一层(真机踩过:点组件行后又被改回包详情)。
46
57
  - **面板行同样是链接**:`data-slot="sidebar.panellist"` 所在的那个 `<button>`(官方侧栏渲染的面板行)会挂上 `href="#/panel/<id>"` 的锚点。**行里没有 id**,id 只能从注册表取:`ctx.slots.entries('sidebar.panellist')` 的 `options.id` / `options.order` / `options.label`;label 可能是**函数**(按语言解析),所以优先按可访问名匹配、数量一致时按 order 对齐,匹配不上一律不碰。
47
58
  - **`slots` 必须留在 `inject` 里**:cordis 下读未声明的服务会抛错,而插件激活期的抛错会让整个插件 `failed`(真机事故:面板链接那次就是这么挂的——假 ctx 无条件给服务,单测抓不到,只有真机能抓)。其余可选面(如 `ctx.layout`)一律 `try/catch` 读取。
@@ -49,3 +60,44 @@
49
60
  ## 跨机开发日志
50
61
 
51
62
  改动记录在私有库 [DDDMUC/repo-devlogs](https://github.com/DDDMUC/repo-devlogs) 的 **`dsh-url-router/`** 文件夹(`HANDOFF.md` 最新一轮在最上面;macOS 端写 `WORKLOG-macos.md`,条目以 `[macOS]` 开头)。按该库规矩,每条先写 `**运行环境**`(设备 / 应用 / 服务商与模型),再写做了什么、动了哪些文件、怎么验证、遗留问题。
63
+
64
+ ## 待办设计(用户 2026-10-10 提的一批,未实现,设计已定)
65
+
66
+ - **外部 fragment 写者共存(#4)**:`dsh-milestone` 之类的插件会在同一条 URL 的**同一个 fragment** 里写 `#msg=…` ✓。一个 fragment 只能有一个写者 ✗ → 现在的行为是"互不认账、静默覆盖" ✗。要做:① 自己的写入**只碰自己的段位** ✓(已经是这样 ✓);② 读到**不属于本插件语法**的 fragment 时,**留一条明确诊断**(点出对方形态 + 说明互斥),不要静默 ✓;③ 真共存需要**统一裁决**(见下条的接缝)✓。
67
+ - **兼容边界自检(#6)**:官方一改契约(侧栏行结构、插件管理器的 `data-plugin-*`、设置导航的 `aria-current`、会话视图的 `[data-conversation-tabs]`、槽位 id)就会**静默失效** ✗。要做"屏幕形态 vs 期望形态"的**渲染期检测** ✓,每类只报一次 ✓,措辞统一为"与当前 DSH 版本不兼容,请升级插件" ✓。**只告警、不改行为** ✓。
68
+ - **路由体检(#7)**:一个 `window.__dshUrlRouter.report()` ✓(无 UI 依赖 ✓,绝不抛错 ✓),列出:当前 canonical 路由 ✓、正在落地的链接与重试次数 ✓、每一层界面**读到多少个钩子** ✓(会话行 / 工作区行 / 视图 tab 与 id 列表 / 轮次 / 轨迹行 / 轮次边界 / 插件行 / 官方项 / 设置覆盖层与分区 / 任务看板 / 右栏 pane ✓)、已发出的告警 ✓、以及被"不碰"的段位 ✓。
69
+ - **对外接缝(#8)**:`ctx.urlRouter`(服务)或同名窗口事件 ✓,让第三方把"当前界面"写成链接 ✓,并由本插件**统一裁决** fragment 所有权 ✓ —— 这同时也是 #4 的真正解法 ✓。
70
+ - **面板内层页面(#5)**:技能中心 / SSH 需要各自发契约 ✓(`data-*` + 接受同名窗口事件 ✓);任务看板是现成先例 ✓,可作为 PR 往上游推 ✓(需要各仓 owner 同意 ✓)。
71
+ - **跳转到某一轮(官方)**:已发讨论 <https://github.com/deepseek-ai/deepseek-harness/discussions/9339> ✓(请求一个 `scrollToTurn(turn)` 服务或 `dsh-chat-scroll-to-turn` 窗口事件 ✓;退路是驱动 rail 刻度点击 ✗ 虚拟化下不可靠 ✓)。
72
+
73
+ ## 已实现:#4 外部 fragment 共存 / #7 路由体检(2026-10-11)
74
+
75
+ - **#4 一个 URL 只有一个 fragment** ✓:两个写者只能轮流拥有 ✓,本插件**绝不假装合并** ✗。
76
+ - `foreignFragmentOf(hash)` ✓:不以 `#/kind…`(或 `#<alias>`)开头的 fragment 即外来 ✓。
77
+ - 读到外来 fragment:**命名一次** ✓(`… was written by another plugin … a URL has one fragment, so this router leaves it alone — enable only one writer` ✓)并退开 ✓。
78
+ - `reflect` 遇到外来 fragment:**不覆盖** ✓ + 一条说明 ✓(`not writing a route while … is in the address bar` ✓)。
79
+ - unknown kind 记入 `observed.ignored` ✓ 供体检 ✓。
80
+ - **#7 `window.__dshUrlRouter.report()`** ✓(挂在 `startUrlRouting` 内部作用域 ✓ 才能读到 `linked`/`attempts` ✗ 放外面必抛 ✓):
81
+ 版本 / 当前 fragment / `canonical` / 正在落地的链接与重试次数 / **16 项钩子计数**(会话行·工作区行·视图 tab 与 id 列表·轮次·轨迹行·轮次边界·插件行·官方项·设置覆盖层与分区·任务看板两项·右栏 pane 与 **kinds 列表**)/ 兼容告警 / 外来 fragment / 被不碰的段位。
82
+ **它绝不出错、绝不写任何东西** ✓(整段 `guard` 包裹 ✓)。
83
+ - **`ROUTER_VERSION`** 由测试断言等于 `package.json` ✓(发布时忘改会被测试抓住 ✓)。
84
+ - **实测顺带发现(下次可用)** ✓:`paneKinds()` 走 `entries()` 能拿到**全量 7 个 kind**:
85
+ `guide / subagentchat / changes-review / plan / files / text / terminal` ✓
86
+ —— 上一轮"根作用域只有 guide"的结论**下早了** ✗(那次读的是 `kinds` getter ✗)。⇒ **右栏 pane 的驱动很可能真能做** ✓,用这些 kind 值 ✓。
87
+
88
+ ## 右栏 pane 驱动已闭环(2026-10-11,真机验证 ✓)
89
+
90
+ **用官方 7 个真实 kind 做完了驱动** ✓(`guide / subagentchat / changes-review / plan / files / text / terminal` ✓)。
91
+
92
+ **真机实测** ✓:贴 `#/session/<id>/pane/terminal` → 右栏展开 ✓、**只开一个 Terminal** ✓、地址栏写 `…/pane/terminal` ✓;再贴 `…/pane/files` → 开出 Files ✓、地址栏跟着切 ✓;**零异常零诊断** ✓。
93
+
94
+ **本轮修掉的 5 个真 bug** ✓:
95
+ 1. **`sameRoute` 把 pane 当"细化"** ✗:`pane/terminal` vs 屏幕上 `pane/Start` 被判"已落地" → 链接被丢、驱动不跑 ✓。且它用 `routeKey` 精确比 ✗(含 kind:`session` vs `workspace` ✗ 永不相等 ✓)→ 改为**只比 pane kind** ✓(形状是装饰 ✓,跟工作区别名一样 ✓)。
96
+ 2. **canonical 的 workspace 分支先 return** ✗ → pane 永远读不到 ✓(workspace 吞掉 pane ✓,与"视图被工作区吞掉"同一类 ✓)→ pane 读取**提前** ✓,两种形状都带 ✓。
97
+ 3. **`openRightbar` 薄面丢参数** ✗(`() => openRightbar()` ✓)→ track 永远 undefined ✗ → 改成转发 ✓。
98
+ 4. **`multiple: true` 的 kind 每轮重试多开一个页签** ✗(实测开出 40 个 ✗)→ 加护栏:**同一 claim 只驱动一次** ✓(`paneOpenedFor` 放机器级作用域 ✓,放 reconcile 内每次都会重建 ✗)。
99
+ 5. **kind 白名单** ✓:驱动前用注册表校验 ✓,未知 kind 明确拒绝并列出已知 kind ✓(不留"挂着什么都不做"的链接 ✓)。
100
+
101
+ **读侧同步升级** ✓:注册表条目实测有 **`{kind, title(): () => label, multiple, guide, patterns, priority}`** ✓ → 用 `title()` 把页签**标签映射回 kind** ✓,于是地址栏写 `pane/files` ✓(而不是 `pane/Files` ✗)。
102
+
103
+ **坑(老熟人 ✓)**:诊断用 `console.info` 而 harness 的 console 只有 `warn` ✗ → 抛错被 `guard` 吞 → `canonical` 中断 → 地址栏写空 ✗(**测试里别用 console.info** ✓)。
package/README.md CHANGED
@@ -37,7 +37,7 @@ dsh plugin --profile web add "link:/path/to/dsh-url-router"
37
37
  | `#/panel/task-board/task/<任务 id>` | 任务看板里某个任务的详情 | 任务看板自己的 `dsh-taskboard-open-task` 窗口事件 |
38
38
  | `#/panel/task-board/new` | 任务看板的新建任务表单 | 任务看板自己的 `dsh-taskboard-new-task` 窗口事件 |
39
39
 
40
- 输入侧宽容:`sessions`/`panels` 复数、百分号编码都接受,一律规范化成上面的形式。**不认识的段位不碰**(例如 `#/settings/…`),只留一条诊断,留给以后的路由器。
40
+ 输入侧宽容:`sessions`/`panels` 复数、百分号编码都接受,一律规范化成上面的形式。**不认识的段位不碰**,只留一条诊断(留给以后的路由器,或者别的插件写的深链形态 ✓ —— 一个 fragment 只能有一个写者 ✓,所以读到不属于本插件的形态时会**明确说一句**,而不是静默 ✗)。
41
41
 
42
42
  面板内部页是**面板自己的词汇表**,本插件不发明:插件管理器发布 `data-plugin-detail` / `data-plugin-item-detail` / `data-plugin-row-detail`,任务看板发布 `data-dsh-taskboard-open-task` / `data-dsh-taskboard-new-task`,并接受同名窗口事件。
43
43
 
@@ -54,6 +54,9 @@ dsh plugin --profile web add "link:/path/to/dsh-url-router"
54
54
  - **只动 fragment**:路径与查询串从不改写(启动 `?token=…` 能活下来)。
55
55
  - **唯一写者**:本插件拥有 fragment,请**不要**和别的写 fragment 的插件同时启用(例如 `dsh-session-url`),两者会互相覆盖。
56
56
  - **只用 `replaceState`**:浏览面板不污染后退栈。
57
+ - **轮次信息:能证明才写** ✓。助手行自己的身份里就带轮次(`row/assistant/80/32` = 第 80 轮第 32 项 ✓);**工具行不带** ✗(只有调用 id ✓)→ 于是我从视图的**轮次边界** `[data-turn-start]` 反推 ✓,**但只在行夹在两个已渲染边界之间时才写** ✓(`turn/82/row/tool/call/…` ✓)。视图是虚拟化的 ✓,边界可能只渲染了一侧 ✗ → 那时**宁可不写轮次** ✓,也绝不猜一个错的轮次 ✗。
58
+ - **轨迹里的每一行都有自己的链接** ✓:`#/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 ✓)。粘贴行链接会打开那个对话并**尽力滚到那一行** ✓ —— 视图是虚拟化的 ✓,**没渲染出来的行不会被假装滚到** ✗(不会骗你 ✓)。
59
+ - **会话视图(对话 / 轨迹 / 轨迹plus …)也进地址栏** ✓:`#/session/<id>/view/<viewId>`(有工作区时是 `#/workspace/<名>/session/<id>/view/<viewId>` ✓)。**视图 id 是真的 id** ✓(来自槽位 `conversation.view` 的 `id` 字段 ✓,按 `order` 排序后与头部页签一一对应 ✓):实测本机是 `chat` / `trajectory` / **`surface`**(Trajectory Plus ✓)/ **`plus2`**(Trajectory Plus 2 ✓)。默认视图(第一个 ✓)**不写进链接** ✓,所以普通对话链接保持短 ✓。**粘贴视图链接会真的切过去** ✓(与其它覆盖层不同:这是链接存在的意义 ✓)。
57
60
  - **工作区是"它住在哪",所以写在链接最前面** ✓:`#/workspace/<工作区名>/session/<会话id>`(侧栏本身就是这个形状 ✓)。工作区名是**给眼睛看的** ✓ —— 决定打开哪个会话的永远是会话 id ✓,所以**名字过期/写错也照样打开对的会话** ✓(地址栏随后自己纠正成真实工作区 ✓)。读不到工作区行(没有侧栏 / 树里没有)就**不猜** ✓,退回 `#/session/<id>` ✓。
58
61
  - **作用域由视图决定,不靠猜**:主面板是应用级界面(列的是本机插件,不是某个对话),所以它的链接不带会话 id。
59
62
  - **链接落地即交权**:视图一旦变成链接描述的那个界面,链接立刻失去权威——这样你在面板里继续点进去时,地址栏不会被拽回上一层。
@@ -62,17 +65,22 @@ dsh plugin --profile web add "link:/path/to/dsh-url-router"
62
65
  ### 已知限制
63
66
 
64
67
  - **轮次链接是"位置"不是"界面"**:点某一轮,地址栏记下 `#/session/<id>/turn/<n>` ✓;但**粘贴它只会打开那个对话,不会滚到那一轮** ✗ —— 官方把"跳到某轮"(`scrollToTurn` / `JUMP_PAGE_OPTIONS`)留在 chat 包**内部**,没有对外接缝 ✗,逐页翻也不可靠 ✗(实测「Load earlier」按钮在两个会话上都没加载出更早的轮次 ✗)。要真跳转,需要官方开一个服务或槽位 ✓。
65
- - **右栏 pane 已接入但未真机验证**:`#/panel/<面板>/pane/<页签 kind>` ✓(读 `sidebarRightTabs` 的 `activeTabId` ✓,驱动用它的 `openTab` ✓)。**注意**:本机组合里右栏没有任何页签注册 ✗,所以只跑过单测 ✓;页签 kind 由注册方决定 ✓(例如官方 `-files` / `-terminal` / `-browser` / `-documentpreview` 包注册的那些 ✓)。
68
+ - **右栏 pane 已闭环** ✓:`#/session/<id>/pane/<kind>` 与 `#/panel/<面板>/pane/<kind>` ✓(真机验证:贴链接开出对应页签 ✓、地址栏跟着切换 ✓)。页签种类由右栏自己注册 ✓(实测 7 种:`guide` / `subagentchat` / `changes-review` / `plan` / `files` / `text` / `terminal` ✓)。
66
69
  - **设置面板已纳入**:`#/settings` ✓、**`#/settings/<分区>`** ✓、**`#/settings/<分区>/<页签>`** ✓(页签靠标准 `role="tab"` + `aria-selected` ✓,分区内任何页签都吃这一套 ✓)。分区**没有稳定 id** ✗(实测:导航项上只有 `aria-current="true"` ✓,父链是 `nav` ✓,右侧内容区一个 `data-*` 都没有 ✗;官方内部的 `openSection(id)` 也不对外 ✗)→ 所以**分区的身份就是它的显示名** ✓:反映读「`aria-current="true"` 且属于 `nav`」的那个按钮的**标签文字** ✓,驱动按标签点 ✓。含义:**换语言或官方改文案后,旧的分区链接会失效** ✗(但你重新点一次,地址栏就会写出新链接 ✓ —— 自洽、不过期 ✓)。裸 `#/settings` 只要面板开着就算落地 ✓,随后地址栏自动升级为具体分区 ✓。
67
70
  - **设置页分区暂未纳入(旧描述)**:设置包没有"打开某个分区"的接缝(它们是 keyed list slot),只能等它自己发布类似契约。
68
- - **右栏 pane 与会话内轮次/消息锚点暂未纳入**。
71
+ - **会话内的轮次与行锚点已纳入** ✓:`#/session/<id>/turn/<n>` ✓(只做反映 ✓)、`#/session/<id>/row/<类>/<作用域>/<id>` ✓(反映 + 尽力滚动 ✓,含可证明时的轮次归属 ✓)。**跳转仍然做不到** ✗(官方把 `scrollToTurn` 留在包内部 ✓,已向官方提 <https://github.com/deepseek-ai/deepseek-harness/discussions/9339> ✓)。
69
72
  - **技能中心 / SSH 的内部页**:需要它们各自发布"当前实体 id + 按 id 打开"的契约(任务看板就是这么做的)。
73
+ - **和别的 fragment 写者互斥**(例如写 `#msg=` 的"跳到消息"插件):一个 URL 只有一个 fragment ✓,两个写者只能轮流拥有 ✗。本插件**不假装合并** ✓ —— 它认出外来 fragment ✓、**绝不覆盖** ✓,并明确说"只能启用一个写者" ✓(而不是互相静默擦掉对方的链接 ✗)。
70
74
  - 链接**同名不跨机**:会话 id 只在同一台机器、同一个 `DSH_HOME` 下有意义。
71
75
 
72
76
  ### 开发
73
77
 
74
78
  手写 JS 直接进 `lib/`(无构建步骤),`npm test` 直接跑交付产物(35→45 条用例,`node --test`)。改之前请读 [AGENTS.md](AGENTS.md) 里的路由与行链接契约。
75
79
 
80
+ ### 排障
81
+
82
+ 浏览器 Console 里调用 `window.__dshUrlRouter.report()` ✓,它会列出:当前地址栏 ✓、本插件认为屏幕在哪个界面 ✓、正在落地的链接与重试次数 ✓、**每一层读到了多少个钩子** ✓、右栏 pane 的**真实 kind 列表** ✓、以及它**拒绝认领**的段位 ✓。它绝不出错、也不写任何东西 ✓。
83
+
76
84
  ## English
77
85
 
78
86
  **A URL router for the DSH Web GUI — every surface gets its own link.** Click a conversation and the address bar becomes `#/session/<id>`; click a panel (Plugins, Task Board, …) and it becomes `#/panel/<panelId>`; walk into a plugin, a component, or a task and the bar follows. Paste the link into another tab, or right-click any sidebar row for the browser's own "open in new tab / copy link".
@@ -98,7 +106,7 @@ dsh plugin --profile web add dsh-url-router
98
106
  | `#/panel/task-board/task/<taskId>` | one task's detail on the task board |
99
107
  | `#/panel/task-board/new` | the task board's new-task form |
100
108
 
101
- A kind it does not know (`#/settings/…`) is left alone with a single diagnostic. Panel inner pages are the panel's own vocabulary: the plugin manager publishes `data-plugin-detail` / `data-plugin-item-detail` / `data-plugin-row-detail`, the task board publishes `data-dsh-taskboard-open-task` / `data-dsh-taskboard-new-task`.
109
+ A kind it does not know is left alone with a single diagnostic (a fragment has exactly one writer, so a foreign shape is named rather than silently ignored). Panel inner pages are the panel's own vocabulary: the plugin manager publishes `data-plugin-detail` / `data-plugin-item-detail` / `data-plugin-row-detail`, the task board publishes `data-dsh-taskboard-open-task` / `data-dsh-taskboard-new-task`.
102
110
 
103
111
  ### Every row is a real link
104
112
 
@@ -115,4 +123,4 @@ Conversation rows and both plugin-list groups carry a transparent full-row ancho
115
123
 
116
124
  ### Limits
117
125
 
118
- Settings sections (no open seam yet), the right-hand panes, and in-conversation turn anchors are not routed. Skill Center / SSH inner pages need their own "publish the current id + accept an open request" contract, the way the task board did. Session links are meaningful on the same machine and `DSH_HOME` only.
126
+ Still open: jumping to a named turn (the official chat keeps `scrollToTurn` inside its own package — asked for a seam in discussion #9339), the right-hand panes (wired, but the right bar starts empty in this composition so they are not verified end to end yet), and Skill Center / SSH inner pages, which need their own "publish the current id + accept an open request" contract the way the task board did. Session links are meaningful on the same machine and `DSH_HOME` only.
package/lib/client.js CHANGED
@@ -90,6 +90,22 @@ window.__ModuleLoader__.load({
90
90
  const TASK_BOARD_NEW_EVENT = 'dsh-taskboard-new-task'
91
91
  /** Sidebar row key prefix for a workspace (the value is its id; the row text is its name). */
92
92
  const WORKSPACE_ROW_PREFIX = 'workspace:'
93
+ /** Stable container the conversation header renders its view tabs into. */
94
+ const CONVERSATION_TABS_ATTRIBUTE = 'data-conversation-tabs'
95
+ /** Attribute each trajectory row carries: `<kind>%00<scope>%00<id>`. */
96
+ const TRAJECTORY_ROW_ATTRIBUTE = 'data-trajectory-row-key'
97
+ /** Attribute the family trajectory view puts on a row's turn cell. */
98
+ const TRAJECTORY_TURN_ATTRIBUTE = 'data-dshts-turn'
99
+ /** The right bar's own root: it carries the session the bar belongs to. */
100
+ const RIGHT_BAR_ATTRIBUTE = 'data-sidebar-right-session'
101
+ /** The right bar's tab strip: only *tabs* live here (the empty state has a bare pane). */
102
+ const RIGHT_BAR_STRIP_ATTRIBUTE = 'data-dockkit-strip-tabs'
103
+ /** Attribute the trajectory view puts on the first row of a turn. */
104
+ const TRAJECTORY_TURN_START_ATTRIBUTE = 'data-turn-start'
105
+ /** Inner segment that names one row inside a conversation view. */
106
+ const ROW_SEGMENT = 'row'
107
+ /** Inner segment that names which conversation view is showing. */
108
+ const VIEW_SEGMENT = 'view'
93
109
  /** Inner segment that marks the conversation inside a workspace route. */
94
110
  const WORKSPACE_SESSION_SEGMENT = 'session'
95
111
  /** Attribute the chat view puts on every rendered turn node (the turn number). */
@@ -103,8 +119,53 @@ window.__ModuleLoader__.load({
103
119
  /** Panel id of the official plugin manager, whose list rows and detail pages are routed. */
104
120
  const PLUGINS_PANEL_ID = 'plugins'
105
121
  /** Log prefix for every diagnostic. */
122
+ /**
123
+ * The router's own version, for the report a human files. A test keeps it equal to the
124
+ * package version, so a release cannot forget to bump it.
125
+ */
126
+ const ROUTER_VERSION = '0.3.0'
127
+
106
128
  const TAG = '[url-router]'
107
129
 
130
+ /**
131
+ * The conversation view ids the registry publishes, in registration order — the same order
132
+ * the header renders its tabs in, which is what lets a tab position map back to an id.
133
+ * @param {object} faces - the probed host faces.
134
+ * @returns {string[]|undefined} the ids, or undefined when the registry is not readable.
135
+ */
136
+ function conversationViewIdsOf(faces) {
137
+ // The conversation's own `uiConversation` registry is scoped to the conversation and is
138
+ // not visible from a root plugin (measured: undefined), but the view tabs are contributed
139
+ // through the `conversation.view` slot, and that *is* readable — with an id and an order.
140
+ const entries = attemptValue(() => faces.slots?.entries?.('conversation.view'))
141
+ if (!Array.isArray(entries)) return undefined
142
+ const rows = []
143
+ for (const entry of entries) {
144
+ if (entry === null || typeof entry !== 'object') continue
145
+ const options = entry.options ?? entry
146
+ const id = options.id ?? entry.id
147
+ if (typeof id !== 'string' || id === '') continue
148
+ const order = typeof options.order === 'number' ? options.order : 0
149
+ rows.push({ id, order })
150
+ }
151
+ if (rows.length === 0) return undefined
152
+ rows.sort((left, right) => left.order - right.order)
153
+ return rows.map(row => row.id)
154
+ }
155
+
156
+ /**
157
+ * Read a value, or undefined when reading it throws.
158
+ * @param {Function} read - the reader.
159
+ * @returns {*} the value, or undefined.
160
+ */
161
+ function attemptValue(read) {
162
+ try {
163
+ return read()
164
+ } catch {
165
+ return undefined
166
+ }
167
+ }
168
+
108
169
  /**
109
170
  * The workspace a sidebar row belongs to: the nearest workspace row before it, read from the
110
171
  * row text. Returns undefined when the tree says nothing, so no caller has to guess.
@@ -129,6 +190,135 @@ window.__ModuleLoader__.load({
129
190
  return undefined
130
191
  }
131
192
 
193
+ /**
194
+ * The kind inside a tab id: the bar builds one as `sidebar://<kind>` and appends a uuid for
195
+ * kinds that may be opened more than once, so the kind is right there in the address.
196
+ * @param {string} value - a tab id.
197
+ * @returns {string|undefined} the kind, when the value is one of these addresses.
198
+ */
199
+ /**
200
+ * The right bar's registry, normalised: one row per page type with its kind, the label
201
+ * the tab shows, whether more than one may be open, and its empty-state card (measured
202
+ * live: `{kind, title(): () => string, multiple?: true, guide?: [{id, order}]}`).
203
+ * @param {object} faces - the probed host faces.
204
+ * @returns {{kind: string, label: string|undefined, multiple: boolean}[]|undefined} the rows.
205
+ */
206
+ function paneRegistryOf(faces) {
207
+ const rows = attemptValue(() => faces.sidebarRightTabs?.entries?.())
208
+ if (!Array.isArray(rows)) return undefined
209
+ const out = []
210
+ for (const row of rows) {
211
+ if (row === null || typeof row !== 'object') continue
212
+ const kind = row.kind ?? row.options?.kind ?? row.id
213
+ if (typeof kind !== 'string' || kind === '') continue
214
+ const label = attemptValue(() => (typeof row.title === 'function' ? row.title() : row.title))
215
+ out.push({ kind, label: typeof label === 'string' && label !== '' ? label : undefined, multiple: row.multiple === true })
216
+ }
217
+ return out.length === 0 ? undefined : out
218
+ }
219
+
220
+ /**
221
+ * A tab label compared without the noise a visible label can carry (padding, an underline
222
+ * marker, a shortcut hint), so a registry label can be matched to what the tab shows.
223
+ * @param {string} value - the text to flatten.
224
+ * @returns {string} the flattened text.
225
+ */
226
+ function flatLabel(value) {
227
+ return String(value).replace(/\s+/g, ' ').trim()
228
+ }
229
+
230
+ function kindOfTabId(value) {
231
+ if (typeof value !== 'string' || value === '') return undefined
232
+ const match = /^sidebar:\/\/([^/?#]+)/.exec(value)
233
+ return match === null ? undefined : decodeURIComponent(match[1])
234
+ }
235
+
236
+ /**
237
+ * The session the right bar belongs to: the bar is per session and says so on its own root.
238
+ * @param {object} document - the document to read.
239
+ * @returns {string|undefined} the session id.
240
+ */
241
+ function rightBarSessionIdOf(document) {
242
+ const root = attemptValue(() => document?.querySelector?.(`[${RIGHT_BAR_ATTRIBUTE}]`))
243
+ const id = root === null || root === undefined ? null : attemptValue(() => root.getAttribute?.(RIGHT_BAR_ATTRIBUTE))
244
+ return typeof id === 'string' && id !== '' ? id : undefined
245
+ }
246
+
247
+ /**
248
+ * The right bar's active tab kind, read from its *strip*: the empty state also renders a
249
+ * `data-dockkit-pane`, so any pane-active marker would report a tab that is not there.
250
+ * @param {object} document - the document to read.
251
+ * @returns {string|undefined} the active kind.
252
+ */
253
+ /**
254
+ * Notes already said, across reconciles: a compatibility note is useful the first time, and
255
+ * a plugin that repeats it every reconcile is a log spammer.
256
+ */
257
+ const paneNotes = new Set()
258
+ /**
259
+ * Say a pane diagnostic once.
260
+ * @param {Function} warn - the host's warning sink.
261
+ * @param {string} key - deduplication key.
262
+ * @param {string} message - what to say.
263
+ */
264
+ const notePaneShape = (warn, key, message) => {
265
+ if (paneNotes.has(key)) return
266
+ paneNotes.add(key)
267
+ warn(`${TAG} ${message}`)
268
+ }
269
+
270
+ function activePaneTabOf(document, registry) {
271
+ // Scoped to the right bar's own root: the same dockkit attributes dress the main area
272
+ // too, and reading them unscoped reports the main area's panes, not this bar's tabs.
273
+ const right = attemptValue(() => document?.querySelector?.(`[${RIGHT_BAR_ATTRIBUTE}]`))
274
+ const scope = right === null || right === undefined ? document : right
275
+ const strip = typeof scope?.querySelector === 'function' ? attemptValue(() => scope.querySelector(`[${RIGHT_BAR_STRIP_ATTRIBUTE}]`)) : undefined
276
+ if (strip === null || strip === undefined) return undefined
277
+ // A tab is `role="tab"` with its id in `data-dockkit-tab` — and that id *is* the page
278
+ // address, so the kind sits inside it. Pane markers belong to the content area.
279
+ // Remember what the strip holds, *before* trying to read it: this is what a compatibility
280
+ // note has to name when nothing can be read, so it must be recorded either way.
281
+ const described = attemptValue(() => {
282
+ const child = strip.children?.[0]
283
+ if (child === undefined) return undefined
284
+ // An Attr list and a Map both show up (the DOM makes the former, a stub the latter).
285
+ const pairs = [...child.attributes].map(attribute => Array.isArray(attribute) ? { name: attribute[0], value: attribute[1] } : attribute)
286
+ return pairs.map(attribute => `${attribute.name}="${String(attribute.value).slice(0, 24)}"`).join(' ')
287
+ })
288
+ if (typeof described === 'string') activePaneTabOf.observed = described
289
+ const tabs = typeof strip.querySelectorAll === 'function' ? attemptValue(() => [...strip.querySelectorAll('[role="tab"]')]) : undefined
290
+ if (!Array.isArray(tabs) || tabs.length === 0) return undefined
291
+ const chosen = tabs.find(tab => attemptValue(() => tab.getAttribute?.('aria-selected')) === 'true') ?? tabs[0]
292
+ // The tab's own name is the reliable source: measured live, `data-dockkit-tab` and
293
+ // `data-dockkit-content` both carry the strip's tab id (`tab3`), which says nothing about
294
+ // the kind — while the visible label *is* the kind's name (`Files`, `Start`, `Terminal`).
295
+ const nameOfTab = tab => {
296
+ const labelled = attemptValue(() => tab.getAttribute?.('aria-label')) ?? attemptValue(() => tab.getAttribute?.('title'))
297
+ if (typeof labelled === 'string' && labelled.trim() !== '') return labelled.trim()
298
+ const text = attemptValue(() => (tab.textContent ?? '').trim())
299
+ return typeof text === 'string' && text !== '' && text.length < 40 ? text : undefined
300
+ }
301
+ // 1) the tab's name, resolved to a kind through the registry whenever it can be: the
302
+ // registry publishes the very label each tab shows, so a link can carry the kind
303
+ // (`pane/files`) instead of a sentence in whatever language is on screen.
304
+ const named = nameOfTab(chosen)
305
+ if (named !== undefined) {
306
+ const wanted = flatLabel(named).toLowerCase()
307
+ const match = Array.isArray(registry) ? registry.find(row => typeof row.label === 'string' && flatLabel(row.label).toLowerCase() === wanted) : undefined
308
+ return match === undefined ? named : match.kind
309
+ }
310
+ // 2) an address-shaped id, for a future build that puts one there
311
+ const fromTab = kindOfTabId(attemptValue(() => chosen.getAttribute?.('data-dockkit-tab')))
312
+ if (fromTab !== undefined) return fromTab
313
+ // 3) the tab host section, likewise
314
+ const hosts = typeof scope.querySelectorAll === 'function' ? attemptValue(() => [...scope.querySelectorAll('[data-dockkit-content]')]) : []
315
+ for (const host of hosts) {
316
+ const kind = kindOfTabId(attemptValue(() => host.getAttribute?.('data-dockkit-content')))
317
+ if (kind !== undefined) return kind
318
+ }
319
+ return undefined
320
+ }
321
+
132
322
  /**
133
323
  * Read a route out of a location fragment.
134
324
  * @param {string} hash - the raw `window.location.hash`, including the leading `#`.
@@ -142,6 +332,24 @@ window.__ModuleLoader__.load({
142
332
  }
143
333
  }
144
334
 
335
+ /**
336
+ * The fragment, when it belongs to some other writer.
337
+ *
338
+ * A URL has exactly one fragment, so two plugins that both write it can only take turns:
339
+ * whichever wrote last owns it, and the other one's link is gone. This router never
340
+ * pretends otherwise — it names the fragment it did not write and leaves it alone.
341
+ * @param {string} hash - the current fragment.
342
+ * @returns {string|undefined} the foreign fragment, or undefined when it is ours (or empty).
343
+ */
344
+ function foreignFragmentOf(hash) {
345
+ if (typeof hash !== 'string') return undefined
346
+ const trimmed = hash.trim()
347
+ if (trimmed === '' || trimmed === '#') return undefined
348
+ const match = ROUTE_PATTERN.exec(trimmed)
349
+ if (match !== null && ROUTE_ALIASES[match[1]] !== undefined) return undefined
350
+ return trimmed
351
+ }
352
+
145
353
  function parseRoute(hash) {
146
354
  const match = ROUTE_PATTERN.exec(hash)
147
355
  if (match === null) return undefined
@@ -159,7 +367,14 @@ window.__ModuleLoader__.load({
159
367
  if (rest.length < 2 || rest[0] !== WORKSPACE_SESSION_SEGMENT) return { unknown: `workspace/${match[2]}` }
160
368
  const sessionId = rest[1].trim()
161
369
  if (sessionId === '') return { unknown: `workspace/${match[2]}` }
162
- return { kind: 'workspace', id: name, inner: innerPath([WORKSPACE_SESSION_SEGMENT, sessionId]) }
370
+ // The view a conversation is showing is part of the conversation, so it may follow.
371
+ const tail = rest.slice(2)
372
+ if (tail.length === 0) return { kind: 'workspace', id: name, inner: innerPath([WORKSPACE_SESSION_SEGMENT, sessionId]) }
373
+ if (tail.length < 2 || tail[1].trim() === '') return { unknown: `workspace/${match[2]}` }
374
+ if (tail[0] === VIEW_SEGMENT && tail.length === 2) return { kind: 'workspace', id: name, inner: innerPath([WORKSPACE_SESSION_SEGMENT, sessionId, VIEW_SEGMENT, tail[1]]) }
375
+ if (tail[0] === ROW_SEGMENT && tail.length >= 3) return { kind: 'workspace', id: name, inner: innerPath([WORKSPACE_SESSION_SEGMENT, sessionId, ...tail]) }
376
+ if (tail[0] === PANE_SEGMENT && tail.length === 2) return { kind: 'workspace', id: name, inner: innerPath([WORKSPACE_SESSION_SEGMENT, sessionId, PANE_SEGMENT, tail[1]]) }
377
+ return { unknown: `workspace/${match[2]}` }
163
378
  }
164
379
  const segments = match[2].replace(/\/+$/, '').split('/')
165
380
  const head = segments.shift() ?? ''
@@ -307,6 +522,11 @@ window.__ModuleLoader__.load({
307
522
  /** The package segment of the deepest panel page the address bar carried. */
308
523
  let panelScope
309
524
  /** The turn the user last clicked inside a conversation, with its session. */
525
+ /** What the report can say about this run: fragments it refused, kinds it left alone. */
526
+ const observed = { foreignFragment: undefined, ignored: [], warnings: [] }
527
+ let rowAnchor
528
+ /** The pane kind this claim already opened (one drive per claim, see the pane branch). */
529
+ let paneOpenedFor
310
530
  let turnAnchor
311
531
  /** How many times the linked panel was selected (bounded, see below). */
312
532
  let panelAttempts = 0
@@ -328,6 +548,7 @@ window.__ModuleLoader__.load({
328
548
  claimed = false
329
549
  panelAttempts = 0
330
550
  innerAttempts = 0
551
+ paneOpenedFor = undefined
331
552
  dropTimer()
332
553
  }
333
554
  /**
@@ -363,13 +584,30 @@ window.__ModuleLoader__.load({
363
584
  // The workspace is part of *where* the conversation lives, so it leads the route.
364
585
  // Reading it from the sidebar keeps this honest: no sidebar (or no matching row)
365
586
  // means the plain conversation route, never a guess.
587
+ // Which conversation view is showing, when it is not the default one: a plain
588
+ // conversation keeps its short route, any other view says which one it is.
589
+ // A pointed-at row is the most specific position there is, then the view, then the
590
+ // turn: they are all "where inside this conversation", so one of them is written.
591
+ const view = host.activeConversationView()
592
+ const position = rowAnchor !== undefined && rowAnchor.session === id
593
+ ? rowAnchor.turn === undefined || rowAnchor.turn === ''
594
+ ? [ROW_SEGMENT, ...rowAnchor.parts]
595
+ : [TURN_SEGMENT, rowAnchor.turn, ROW_SEGMENT, ...rowAnchor.parts]
596
+ : view !== undefined && view.index !== 0
597
+ ? [VIEW_SEGMENT, view.id]
598
+ : []
599
+ // The right-hand pane is its own layer, not a position inside the conversation:
600
+ // it is named next to whatever the main column shows, never instead of it — so it
601
+ // is read *before* the workspace branch, or the workspace would swallow it.
602
+ const pane = host.activePaneKind()
603
+ const tail = pane === undefined ? [] : [PANE_SEGMENT, pane]
366
604
  const workspaceName = host.workspaceNameOf(id)
367
605
  if (workspaceName !== undefined) {
368
- return { kind: 'workspace', id: workspaceName, inner: innerPath([WORKSPACE_SESSION_SEGMENT, id]) }
606
+ return { kind: 'workspace', id: workspaceName, inner: innerPath([WORKSPACE_SESSION_SEGMENT, id, ...position, ...tail]) }
369
607
  }
370
- const pane = host.activePaneKind()
371
- if (pane !== undefined) return { kind: 'session', id, inner: innerPath([PANE_SEGMENT, pane]) }
372
- // The turn the user last pointed at is a position inside this conversation;
608
+ if (tail.length === 0 && position.length > 0) return { kind: 'session', id, inner: innerPath(position) }
609
+ if (tail.length > 0) return { kind: 'session', id, inner: innerPath([...position, ...tail]) }
610
+ // The turn the user last pointed at is a position inside this conversation;
373
611
  // it is only named while the same conversation is the one on screen.
374
612
  if (turnAnchor !== undefined && turnAnchor.session === id) {
375
613
  return { kind: 'session', id, inner: innerPath([TURN_SEGMENT, turnAnchor.turn]) }
@@ -383,22 +621,55 @@ window.__ModuleLoader__.load({
383
621
  // surface, so the link lands at once instead of sitting there until its window expires.
384
622
  // A *named* section against a different named section is a real difference, so a section
385
623
  // link still drives the overlay to the section it asks for.
386
- const sameRoute = (left, right) => {
387
- if (routeKey(left) === routeKey(right)) return true
388
- // A workspace route is about the conversation it names; the workspace name is what the
389
- // link *shows*, so a stale name must not keep the link from landing.
390
- if (left?.kind === 'workspace' || right?.kind === 'workspace') {
391
- if (left?.kind !== right?.kind) return false
392
- return innerPath(left.inner)[1] === innerPath(right.inner)[1]
624
+ // Whether the view on screen already *is* what a link asked for.
625
+ //
626
+ // The rule is directional on purpose: a link may be less specific than the view (a bare
627
+ // `#/settings` is satisfied by any section, `#/session/<id>` by any view of it), but a view
628
+ // that is less specific than the link never satisfies it — otherwise a link naming a view
629
+ // would look "already there" and never be driven.
630
+ const sessionIdOfRoute = route => {
631
+ if (route?.kind === 'workspace') return innerPath(route.inner)[1]
632
+ if (route?.kind === 'session') return route.id
633
+ return undefined
634
+ }
635
+ const viewIdOfRoute = route => {
636
+ const parts = innerPath(route?.inner)
637
+ const at = parts.indexOf(VIEW_SEGMENT)
638
+ return at < 0 ? undefined : parts[at + 1]
639
+ }
640
+
641
+ const paneOf = route => {
642
+ const parts = innerPath(route?.inner)
643
+ const at = parts.indexOf(PANE_SEGMENT)
644
+ return at < 0 ? undefined : parts[at + 1]
645
+ }
646
+ const sameRoute = (current, linked) => {
647
+ if (routeKey(current) === routeKey(linked)) return true
648
+ const currentSession = sessionIdOfRoute(current)
649
+ const linkedSession = sessionIdOfRoute(linked)
650
+ if (currentSession !== undefined && linkedSession !== undefined) {
651
+ if (currentSession !== linkedSession) return false
652
+ const linkedView = viewIdOfRoute(linked)
653
+ if (linkedView === undefined) {
654
+ // A row or turn link is a position inside the conversation, so it stays exact; any other
655
+ // route that names the same conversation is satisfied by it being on screen —
656
+ // the workspace name is decoration, not a condition.
657
+ const parts = innerPath(linked.inner)
658
+ if (parts.includes(TURN_SEGMENT) || parts.includes(ROW_SEGMENT)) return routeKey(current) === routeKey(linked)
659
+ // A pane is its own layer, named next to the conversation: the route *shape* (with or
660
+ // without the workspace) is decoration, exactly like the workspace name — what has to
661
+ // match is the pane kind itself.
662
+ if (parts.includes(PANE_SEGMENT)) return paneOf(current) === paneOf(linked)
663
+ return true
664
+ }
665
+ // A link that names a view has to see that view.
666
+ return viewIdOfRoute(current) === linkedView
393
667
  }
394
- if (left?.kind !== 'settings' || right?.kind !== 'settings') return false
395
- if (left.id === '' || right.id === '') return true
396
- // One level deeper: the same section is satisfied while a link names no tab, but a
397
- // link that names a tab has to see that tab.
398
- if (left.id !== right.id) return false
399
- const leftTab = innerPath(left.inner)[0] ?? ''
400
- const rightTab = innerPath(right.inner)[0] ?? ''
401
- return leftTab === '' || rightTab === ''
668
+ if (current?.kind !== 'settings' || linked?.kind !== 'settings') return false
669
+ if (linked.id === '') return true
670
+ if (current.id !== linked.id) return false
671
+ const linkedTab = innerPath(linked.inner)[0] ?? ''
672
+ return linkedTab === '' || innerPath(current.inner)[0] === linkedTab
402
673
  }
403
674
  /** Write the fragment for the current view, leaving path and query untouched. */
404
675
  const reflect = route => {
@@ -407,6 +678,18 @@ window.__ModuleLoader__.load({
407
678
  // path or the launch query, and comparing them made every call look like a change.
408
679
  const wantedHash = route === undefined ? '' : routeHash(route.kind, route.id, route.inner)
409
680
  if (hash === wantedHash) return
681
+ // Somebody else's fragment is on screen: taking it over would erase their link, so
682
+ // this router stays quiet and says why instead.
683
+ const foreign = foreignFragmentOf(hash)
684
+ if (foreign !== undefined) {
685
+ observed.foreignFragment = foreign
686
+ const known = observed.warnings.includes(`refused to write over: ${foreign}`)
687
+ if (!known) {
688
+ observed.warnings.push(`refused to write over: ${foreign}`)
689
+ host.warn(`${TAG} not writing a route while ${foreign} is in the address bar: that fragment belongs to another plugin`)
690
+ }
691
+ return
692
+ }
410
693
  // Nothing to name and nothing named: leave the address bar exactly as it is
411
694
  // (this also leaves a foreign fragment alone, which the caller never reaches).
412
695
  if (route === undefined && hash === '') return
@@ -422,6 +705,13 @@ window.__ModuleLoader__.load({
422
705
  const currentKey = routeKey(current)
423
706
  const moved = currentKey !== seen
424
707
  seen = currentKey
708
+ // The right bar holds a tab this build cannot name: say it once, with the tab's own
709
+ // attributes. A silent pane layer is the worst of both worlds — this is how the shape
710
+ // reaches a bug report instead of a guess.
711
+ const paneTabs = attemptValue(() => window.document?.querySelector?.(`[${RIGHT_BAR_STRIP_ATTRIBUTE}]`)?.querySelectorAll?.('[role="tab"]')?.length) ?? 0
712
+ if (paneTabs > 0 && host.activePaneKind() === undefined) {
713
+ notePaneShape(message => host.warn(message), 'pane-shape', `the right bar shows ${paneTabs} tab(s) but this build cannot read the active one; the first tab is: ${activePaneTabOf.observed ?? 'no attributes read'}`)
714
+ }
425
715
  if (linked === undefined) {
426
716
  reflect(current)
427
717
  return
@@ -442,6 +732,40 @@ window.__ModuleLoader__.load({
442
732
  reflect(canonical())
443
733
  return
444
734
  }
735
+ // A conversation link may name which view it wants, on either route shape; the view is
736
+ // driven before the plain conversation/workspace branches, or they would swallow it.
737
+ const rowParts = innerPath(linked.inner)
738
+ const rowAt = rowParts.indexOf(ROW_SEGMENT)
739
+ const wantedTurn = rowParts.indexOf(TURN_SEGMENT)
740
+ const rowSession = linked.kind === 'workspace' ? rowParts[1] : linked.kind === 'session' ? linked.id : undefined
741
+ if (rowSession !== undefined && rowAt >= 0 && rowParts.length > rowAt + 2) {
742
+ const shown = host.mainViewSessionId()
743
+ if (shown !== rowSession && panelAttempts < INNER_ATTEMPT_LIMIT) {
744
+ panelAttempts += 1
745
+ host.openSession(rowSession)
746
+ } else if (shown === rowSession && innerAttempts < INNER_ATTEMPT_LIMIT) {
747
+ innerAttempts += 1
748
+ if (!host.revealRow(rowParts.slice(rowAt)) && wantedTurn >= 0) host.revealRow(rowParts.slice(wantedTurn, wantedTurn + 2))
749
+ }
750
+ armRetry()
751
+ return
752
+ }
753
+ const wantedView = linked.kind === 'session' || linked.kind === 'workspace' ? viewIdOfRoute(linked) : undefined
754
+ const wantedSession = linked.kind === 'workspace' ? innerPath(linked.inner)[1] : linked.kind === 'session' ? linked.id : undefined
755
+ if (wantedView !== undefined && wantedSession !== undefined) {
756
+ // Unlike the boot restore, a named view is driven whatever brought the route in —
757
+ // a pasted view link exists precisely to switch the view, so it must not be skipped.
758
+ const shown = host.mainViewSessionId()
759
+ if (shown !== wantedSession && panelAttempts < INNER_ATTEMPT_LIMIT) {
760
+ panelAttempts += 1
761
+ host.openSession(wantedSession)
762
+ } else if (shown === wantedSession && innerAttempts < INNER_ATTEMPT_LIMIT) {
763
+ innerAttempts += 1
764
+ host.openConversationView(wantedView)
765
+ }
766
+ armRetry()
767
+ return
768
+ }
445
769
  if (linked.kind === 'workspace') {
446
770
  // The workspace is where the conversation lives, not a separate surface: the link
447
771
  // is satisfied by that conversation being on screen, whatever the name says.
@@ -463,9 +787,16 @@ window.__ModuleLoader__.load({
463
787
  return
464
788
  }
465
789
  if (linked.kind === 'session' && linked.inner !== undefined && innerPath(linked.inner)[0] === PANE_SEGMENT) {
466
- if (source !== 'hash' && innerAttempts < INNER_ATTEMPT_LIMIT) {
790
+ // A pasted pane link is exactly what this drive is for, so the source is not a filter
791
+ // here (the same lesson as the view branch); the attempt bound still limits it.
792
+ const wantedPane = innerPath(linked.inner)[1]
793
+ // A page type that may be opened more than once (measured: `multiple: true`, the
794
+ // terminal) makes *every* attempt open another tab, so one claim drives once and the
795
+ // retry then only waits for the bar to report the tab it opened.
796
+ if (innerAttempts < INNER_ATTEMPT_LIMIT && paneOpenedFor !== wantedPane) {
467
797
  innerAttempts += 1
468
- host.openPane(innerPath(linked.inner)[1])
798
+ paneOpenedFor = wantedPane
799
+ host.openPane(wantedPane, linked.kind === 'panel' ? 'panel' : 'session', message => host.warn(message))
469
800
  }
470
801
  armRetry()
471
802
  return
@@ -480,9 +811,9 @@ window.__ModuleLoader__.load({
480
811
  }
481
812
  if (linked.kind === 'panel') {
482
813
  if (linked.inner !== undefined && innerPath(linked.inner)[0] === PANE_SEGMENT) {
483
- if (source !== 'hash' && innerAttempts < INNER_ATTEMPT_LIMIT) {
814
+ if (innerAttempts < INNER_ATTEMPT_LIMIT) {
484
815
  innerAttempts += 1
485
- host.openPane(innerPath(linked.inner)[1])
816
+ host.openPane(innerPath(linked.inner)[1], linked.kind === 'panel' ? 'panel' : 'session', message => host.warn(message))
486
817
  }
487
818
  armRetry()
488
819
  return
@@ -537,7 +868,21 @@ window.__ModuleLoader__.load({
537
868
  /** Adopt the surface the address bar names, if it names one. */
538
869
  const readLink = () => {
539
870
  if (disposed) return
540
- const parsed = parseRoute(host.readLocation().hash)
871
+ const hash = host.readLocation().hash
872
+ // Another writer owns the fragment: name it once and step aside. Overwriting it here
873
+ // is how two routers silently erase each other's links.
874
+ const foreign = foreignFragmentOf(hash)
875
+ if (foreign !== undefined) {
876
+ const known = observed.foreignFragment === foreign
877
+ observed.foreignFragment = foreign
878
+ dropLink()
879
+ if (!known) {
880
+ host.warn(`${TAG} ${foreign} was written by another plugin (for example a jump-to-message writer); a URL has one fragment, so this router leaves it alone — enable only one writer`)
881
+ observed.warnings.push(`foreign fragment: ${foreign}`)
882
+ }
883
+ return
884
+ }
885
+ const parsed = parseRoute(hash)
541
886
  if (parsed === undefined) {
542
887
  dropLink()
543
888
  reconcile('hash')
@@ -546,6 +891,7 @@ window.__ModuleLoader__.load({
546
891
  if (parsed.unknown !== undefined) {
547
892
  // Another router's route (or a typo): leave the address bar alone and say so
548
893
  // once, so a router owning more kinds can take over later.
894
+ if (!observed.ignored.includes(parsed.unknown)) observed.ignored.push(parsed.unknown)
549
895
  if (linked === undefined) host.warn(`${TAG} ignoring unknown route kind "${parsed.unknown}"`)
550
896
  return
551
897
  }
@@ -615,27 +961,91 @@ window.__ModuleLoader__.load({
615
961
  if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
616
962
  const target = event.target
617
963
  if (typeof target?.closest !== 'function') return
618
- const node = target.closest(`[${TURN_ATTRIBUTE}]`)
619
- if (node === null) return
964
+ // A trajectory row names itself first: it carries its own identity (and is often a
965
+ // button), while a chat turn carries only a turn number. Both paths are pure observers
966
+ // — nothing is prevented, so clicks keep behaving exactly as the views expect.
967
+ const session = host.mainViewSessionId()
968
+ const rowOwner = attemptValue(() => host.rowIdentityOf(target))
969
+ if (rowOwner !== undefined) {
970
+ if (session !== undefined) {
971
+ if (rowOwner[0] === TURN_SEGMENT && rowOwner.length === 2) {
972
+ turnAnchor = { session, turn: rowOwner[1] }
973
+ rowAnchor = undefined
974
+ } else {
975
+ // The view's own key carries the turn for assistant nodes but not for tool
976
+ // calls; when the row sits between two rendered turn boundaries the turn is
977
+ // provable, so the link may say it. Otherwise it stays out of the route.
978
+ const turn = rowOwner.includes(TURN_SEGMENT) ? undefined : attemptValue(() => host.turnOfRow(target))
979
+ rowAnchor = turn === undefined || turn === '' ? { session, parts: rowOwner } : { session, turn, parts: rowOwner }
980
+ turnAnchor = undefined
981
+ }
982
+ safeReconcile('row')
983
+ }
984
+ return
985
+ }
620
986
  // One selector at a time: a comma list is the kind of thing a small DOM stub (and a
621
987
  // future minimal engine) may not implement, and the answer must be the same.
622
988
  for (const interactive of ['button', 'a', 'input', 'textarea', 'select', '[role="button"]', '[contenteditable="true"]']) {
623
989
  if (target.closest(interactive) !== null) return
624
990
  }
991
+ const node = target.closest(`[${TURN_ATTRIBUTE}]`)
992
+ if (node === null) return
993
+ const id = session
994
+ if (id === undefined) return
625
995
  const raw = node.getAttribute?.(TURN_ATTRIBUTE)
626
996
  const turn = typeof raw === 'string' ? raw.trim() : ''
627
997
  if (turn === '') return
628
- const id = host.mainViewSessionId()
629
- if (id === undefined) return
630
998
  turnAnchor = { session: id, turn }
631
- safeReconcile('turn')
632
- }
999
+ rowAnchor = undefined
1000
+ safeReconcile('turn') }
633
1001
  try {
634
1002
  window.document?.addEventListener?.('click', onDocumentClick, true)
635
1003
  } catch {
636
1004
  // A composition without the DOM listener is still a working router.
637
1005
  }
638
1006
 
1007
+ /**
1008
+ * The route health report: every layer this router knows about, the shape it is in right
1009
+ * now, and everything it refused to touch. Reachable as `window.__dshUrlRouter.report()`
1010
+ * — a debug seam, so it never throws and never writes anything.
1011
+ * @returns {object} the report.
1012
+ */
1013
+ const report = () => {
1014
+ const current = attemptValue(() => canonical())
1015
+ const document = window.document
1016
+ const count = selector => attemptValue(() => document?.querySelectorAll?.(selector)?.length) ?? '\u2014'
1017
+ return {
1018
+ version: ROUTER_VERSION,
1019
+ hash: attemptValue(() => host.readLocation().hash) ?? '',
1020
+ canonical: current === undefined ? null : { kind: current.kind, id: current.id, inner: innerPath(current.inner) },
1021
+ link: linked === undefined ? null : { hash: routeHash(linked.kind, linked.id, linked.inner), attempts: { panel: panelAttempts, inner: innerAttempts } },
1022
+ surfaces: {
1023
+ conversationRows: count(`[data-row-key^="${ROW_KEY_PREFIX}"]`),
1024
+ workspaceRows: count(`[data-row-key^="${WORKSPACE_ROW_PREFIX}"]`),
1025
+ viewTabs: count(`[${CONVERSATION_TABS_ATTRIBUTE}]`),
1026
+ viewIds: attemptValue(() => host.conversationViewIds?.()) ?? [],
1027
+ turns: count(`[${TURN_ATTRIBUTE}]`),
1028
+ trajectoryRows: count(`[${TRAJECTORY_ROW_ATTRIBUTE}]`),
1029
+ turnBoundaries: count(`[${TRAJECTORY_TURN_START_ATTRIBUTE}]`),
1030
+ pluginPackages: count('[data-plugin-package]'),
1031
+ officialItems: count('[data-plugin-item]'),
1032
+ settingsOverlay: count(`[${SETTINGS_SURFACE_ATTRIBUTE}="${SETTINGS_SURFACE_VALUE}"]`),
1033
+ settingsSection: attemptValue(() => host.activeSettingsSection()) ?? null,
1034
+ taskBoardOpen: count(`[${TASK_BOARD_OPEN_ATTRIBUTE}]`),
1035
+ taskBoardNew: count(`[${TASK_BOARD_NEW_ATTRIBUTE}]`),
1036
+ paneActive: attemptValue(() => host.activePaneKind()) ?? null,
1037
+ paneKinds: attemptValue(() => host.paneKinds?.()) ?? null,
1038
+ },
1039
+ compat: observed.compat,
1040
+ foreignFragment: observed.foreignFragment,
1041
+ ignoredKinds: observed.ignored,
1042
+ warnings: observed.warnings,
1043
+ }
1044
+ }
1045
+ guard(() => {
1046
+ window.__dshUrlRouter = { version: ROUTER_VERSION, report, when: host.now() }
1047
+ })
1048
+
639
1049
  return () => {
640
1050
  disposed = true
641
1051
  try {
@@ -1019,7 +1429,7 @@ window.__ModuleLoader__.load({
1019
1429
  openSession: id => openSession.call(navigation, id),
1020
1430
  selectPanel: selectPanel === undefined ? undefined : id => selectPanel(id),
1021
1431
  beginNavigation: beginNavigation === undefined ? undefined : () => beginNavigation(),
1022
- openRightbar: openRightbar === undefined ? undefined : () => openRightbar(),
1432
+ openRightbar: openRightbar === undefined ? undefined : track => openRightbar(track),
1023
1433
  sidebarRightTabs,
1024
1434
  sidebarRight,
1025
1435
  panels,
@@ -1151,15 +1561,130 @@ window.__ModuleLoader__.load({
1151
1561
  openSession: id => {
1152
1562
  attempt(() => faces.openSession(id))
1153
1563
  },
1564
+ // A row's own identity, in the shape the trajectory views publish it. The value is a
1565
+ // percent-encoded NUL-separated triple, so the route can read as a plain path.
1566
+ rowIdentityOf: element => {
1567
+ let node = element
1568
+ for (let depth = 0; node !== null && node !== undefined && depth < 12; depth += 1, node = node.parentElement) {
1569
+ if (typeof node.getAttribute !== 'function') break
1570
+ const key = node.getAttribute(TRAJECTORY_ROW_ATTRIBUTE)
1571
+ if (typeof key === 'string' && key !== '') {
1572
+ const parts = key.split('%00').map(part => part.trim()).filter(part => part !== '')
1573
+ if (parts.length >= 2) return parts
1574
+ }
1575
+ const turn = node.getAttribute(TRAJECTORY_TURN_ATTRIBUTE)
1576
+ if (typeof turn === 'string' && turn.trim() !== '') return [TURN_SEGMENT, turn.trim()]
1577
+ }
1578
+ return undefined
1579
+ },
1580
+ // The turn a row belongs to, but only when it is *provable*: the trajectory view
1581
+ // virtualizes, so the evidence is a turn boundary rendered before the row, and that is
1582
+ // trustworthy exactly when the next boundary is also rendered after it. Otherwise the
1583
+ // answer is undefined — never a guess.
1584
+ turnOfRow: element => {
1585
+ if (typeof element?.compareDocumentPosition !== 'function') return undefined
1586
+ const boundaries = window.document?.querySelectorAll?.(`[${TRAJECTORY_TURN_START_ATTRIBUTE}]`)
1587
+ if (boundaries === undefined || boundaries === null || boundaries.length === 0) return undefined
1588
+ const following = window.Node?.DOCUMENT_POSITION_FOLLOWING ?? 4
1589
+ const preceding = window.Node?.DOCUMENT_POSITION_PRECEDING ?? 2
1590
+ let before
1591
+ let after
1592
+ for (const boundary of boundaries) {
1593
+ const position = attemptValue(() => element.compareDocumentPosition(boundary)) ?? 0
1594
+ const turn = (boundary.getAttribute?.(TRAJECTORY_TURN_START_ATTRIBUTE) ?? '').trim()
1595
+ if (turn === '') continue
1596
+ if ((position & following) !== 0) after ??= turn
1597
+ if ((position & preceding) !== 0) before = turn
1598
+ }
1599
+ if (before === undefined || after === undefined || before === after) return undefined
1600
+ return before
1601
+ },
1602
+ // Best effort: scroll to the named row when it is rendered.
1603
+ revealRow: parts => {
1604
+ const wanted = innerPath(parts)
1605
+ const document = window.document
1606
+ if (wanted[0] === ROW_SEGMENT && wanted.length >= 3) {
1607
+ const key = wanted.slice(1).join('%00')
1608
+ for (const candidate of document?.querySelectorAll?.(`[${TRAJECTORY_ROW_ATTRIBUTE}]`) ?? []) {
1609
+ if (candidate.getAttribute?.(TRAJECTORY_ROW_ATTRIBUTE) !== key) continue
1610
+ attempt(() => candidate.scrollIntoView?.({ block: 'center' }))
1611
+ return true
1612
+ }
1613
+ return false
1614
+ }
1615
+ if (wanted[0] === TURN_SEGMENT) {
1616
+ for (const candidate of document?.querySelectorAll?.(`[${TRAJECTORY_TURN_ATTRIBUTE}]`) ?? []) {
1617
+ if (candidate.getAttribute?.(TRAJECTORY_TURN_ATTRIBUTE) !== wanted[1]) continue
1618
+ attempt(() => candidate.scrollIntoView?.({ block: 'center' }))
1619
+ return true
1620
+ }
1621
+ }
1622
+ return false
1623
+ },
1624
+ // Which conversation view is showing. The registry is the source of truth for ids
1625
+ // (`ctx.uiConversation.views`), the header renders its tabs in registration order,
1626
+ // and the active tab is the aria-selected one — so the position maps to an id.
1627
+ activeConversationView: () => {
1628
+ // One selector at a time: the container first, then its tabs inside it.
1629
+ const row = window.document?.querySelector?.(`[${CONVERSATION_TABS_ATTRIBUTE}]`)
1630
+ const tabs = typeof row?.querySelectorAll === 'function' ? row.querySelectorAll('[role="tab"]') : undefined
1631
+ if (tabs === undefined || tabs === null || tabs.length === 0) return undefined
1632
+ let index = -1
1633
+ for (let position = 0; position < tabs.length; position += 1) {
1634
+ const tab = tabs[position]
1635
+ if (typeof tab.getAttribute !== 'function') continue
1636
+ if (tab.getAttribute('aria-selected') !== 'true') continue
1637
+ index = position
1638
+ break
1639
+ }
1640
+ if (index < 0) return undefined
1641
+ const ids = conversationViewIdsOf(faces)
1642
+ if (ids === undefined || ids.length !== tabs.length) return undefined
1643
+ return { id: ids[index], index, total: ids.length }
1644
+ },
1645
+ openConversationView: viewId => {
1646
+ const row = window.document?.querySelector?.(`[${CONVERSATION_TABS_ATTRIBUTE}]`)
1647
+ const tabs = typeof row?.querySelectorAll === 'function' ? row.querySelectorAll('[role="tab"]') : undefined
1648
+ if (tabs === undefined || tabs === null) return
1649
+ const ids = conversationViewIdsOf(faces)
1650
+ if (ids === undefined) return
1651
+ const index = ids.indexOf(viewId)
1652
+ const tab = index < 0 ? undefined : tabs[index]
1653
+ if (typeof tab?.click === 'function') attempt(() => tab.click())
1654
+ },
1154
1655
  // The right-hand pane names the tab in front. The service is the source of truth;
1155
1656
  // the DOM marker is a fallback for a composition where the service is absent.
1657
+ rightBarSessionId: () => rightBarSessionIdOf(window.document),
1658
+ activePaneTab: () => activePaneTabOf(window.document),
1156
1659
  activePaneKind: () => {
1157
- const face = faces.sidebarRightTabs
1158
- if (face !== undefined) {
1159
- const snapshot = attempt(() => (typeof face.getSnapshot === 'function' ? face.getSnapshot() : undefined))
1160
- const active = snapshot === undefined ? undefined : snapshot.activeTabId ?? snapshot.activeTab ?? snapshot.active
1161
- if (typeof active === 'string' && active !== '') return active
1660
+ // Both services answer `active`; the controller is authoritative when it does, and
1661
+ // `active` may be exposed as a value or as a method. Neither exposes `getSnapshot`.
1662
+ // `active` can be a string, a tab descriptor or a method returning either, and the
1663
+ // bar's own tab objects carry the kind under a few plausible names.
1664
+ const kindOf = value => {
1665
+ if (typeof value === 'string') return value === '' ? undefined : value
1666
+ if (value === null || typeof value !== 'object') return undefined
1667
+ const nested = value.tab ?? value.entry ?? value.options
1668
+ const candidates = [value.kind, value.id, value.tabId, value.name, nested?.kind, nested?.id]
1669
+ for (const candidate of candidates) {
1670
+ if (typeof candidate === 'string' && candidate !== '' && candidate !== 'pane1') return candidate
1671
+ }
1672
+ return undefined
1673
+ }
1674
+ const read = owner => {
1675
+ if (owner === undefined || owner === null) return undefined
1676
+ const value = typeof owner.active === 'function' ? attemptValue(() => owner.active()) : owner.active
1677
+ const direct = kindOf(value)
1678
+ if (direct !== undefined) return direct
1679
+ const snapshot = attemptValue(() => (typeof owner.getSnapshot === 'function' ? owner.getSnapshot() : undefined))
1680
+ return snapshot === undefined ? undefined : kindOf(snapshot.activeTabId ?? snapshot.activeTab ?? snapshot.active)
1162
1681
  }
1682
+ const fromStrip = activePaneTabOf(window.document, paneRegistryOf(faces))
1683
+ if (fromStrip !== undefined) return fromStrip
1684
+ const fromController = read(faces.sidebarRight)
1685
+ if (fromController !== undefined) return fromController
1686
+ const fromRegistry = read(faces.sidebarRightTabs)
1687
+ if (fromRegistry !== undefined) return fromRegistry
1163
1688
  // DOM fallback, deliberately narrow: the active-pane marker is shared with the
1164
1689
  // main dockkit, so it only counts when it really belongs to the right bar.
1165
1690
  const active = window.document?.querySelector?.('[data-dockkit-pane-active]')
@@ -1236,15 +1761,83 @@ window.__ModuleLoader__.load({
1236
1761
  : undefined
1237
1762
  if (typeof labelled?.click === 'function') attempt(() => labelled.click())
1238
1763
  },
1239
- openPane: kind => {
1764
+ openPane: (kind, track, warn) => {
1240
1765
  if (typeof kind !== 'string' || kind === '') return
1241
- const face = faces.sidebarRightTabs ?? faces.sidebarRight
1242
- if (face !== undefined && typeof face.openTab === 'function') {
1243
- attempt(() => face.openTab(kind))
1766
+ // A pane link is a claim like any other, so it is checked before it is made: the
1767
+ // registry names every kind the bar can show, and asking for one it does not know
1768
+ // would leave the link sitting there doing nothing. (Measured live: guide,
1769
+ // subagentchat, changes-review, plan, files, text, terminal.)
1770
+ const known = attemptValue(() => paneRegistryOf(faces))?.map(row => row.kind)
1771
+ if (Array.isArray(known) && !known.includes(kind)) {
1772
+ warn(`${TAG} the right bar has no pane type "${kind}"; it knows: ${known.join(', ')}`)
1244
1773
  return
1245
1774
  }
1246
- attempt(() => faces.openRightbar?.())
1775
+ // `openTab(kind, options)` belongs to the controller (`sidebarRight`); the registry
1776
+ // only knows the kinds. Asking the registry to open one fails silently.
1777
+ const controller = faces.sidebarRight
1778
+ // Present the right column first: a session's store is adopted only once the bar is
1779
+ // shown, and every open on an unadopted session is a silent no-op.
1780
+ const expanded = attemptValue(() => (typeof controller?.isExpanded === 'function' ? controller.isExpanded() : undefined))
1781
+ if (expanded === false) {
1782
+ const wanted = typeof track === 'string' && track !== '' ? track : (attemptValue(() => faces.mainViewSessionId()) !== undefined ? 'session' : 'panel')
1783
+ attempt(() => faces.openRightbar?.(wanted))
1784
+ }
1785
+ const session = rightBarSessionIdOf(window.document)
1786
+ const expand = () => {
1787
+ const expanded = attemptValue(() => (typeof controller?.isExpanded === 'function' ? controller.isExpanded() : true))
1788
+ if (expanded === false) attempt(() => controller?.toggleExpanded?.())
1789
+ }
1790
+ if (controller !== undefined && controller !== null) {
1791
+ // The controller a root plugin reaches is not "mounted" for a session, and `openTab(kind)`
1792
+ // throws exactly then ("no session surface is mounted") — so the per-session path is the
1793
+ // real one, and it takes the session **first**: `openTabIn(sessionId, kind, options)`.
1794
+ // (Passed the other way round it looks up a session called "files" and does nothing.)
1795
+ const opened = attemptValue(() => {
1796
+ if (typeof controller.openTabIn === 'function' && typeof session === 'string' && session !== '') {
1797
+ controller.openTabIn(session, kind, {})
1798
+ return true
1799
+ }
1800
+ if (typeof controller.openTab === 'function') {
1801
+ controller.openTab(kind, {})
1802
+ return true
1803
+ }
1804
+ return false
1805
+ })
1806
+ if (opened === true) {
1807
+ expand()
1808
+ return
1809
+ }
1810
+
1811
+ }
1812
+ // Last resort: the same action the right bar's empty-state card runs. It is the
1813
+ // bar's own "open this kind" entry, so using it is not a workaround.
1814
+ const guides = attemptValue(() => {
1815
+ const registry = faces.sidebarRightTabs
1816
+ if (registry === undefined || registry === null) return undefined
1817
+ const rows = typeof registry.guideEntries === 'function' ? registry.guideEntries() : registry.guideEntries
1818
+ if (Array.isArray(rows)) return rows
1819
+ const guide = typeof registry.guide === 'function' ? registry.guide() : registry.guide
1820
+ return Array.isArray(guide) ? guide : undefined
1821
+ })
1822
+ if (!Array.isArray(guides)) return
1823
+ for (const entry of guides) {
1824
+ if (entry === null || typeof entry !== 'object') continue
1825
+ const entryKind = entry.kind ?? entry.options?.kind ?? entry.id
1826
+ if (typeof entryKind !== 'string' || entryKind !== kind) continue
1827
+ const run = entry.onPick ?? entry.action ?? entry.select ?? entry.open ?? entry.run
1828
+ if (typeof run === 'function') {
1829
+ attempt(() => run())
1830
+ expand()
1831
+ return
1832
+ }
1833
+ }
1247
1834
  },
1835
+ // The kinds the bar can show: link targets are checked against this before claiming.
1836
+ paneKinds: () => paneRegistryOf(faces)?.map(row => row.kind),
1837
+ // The rows the registry publishes, for the report and for the drive's check.
1838
+ paneRegistry: () => paneRegistryOf(faces),
1839
+ // The conversation view ids the registry publishes, for the report.
1840
+ conversationViewIds: () => conversationViewIdsOf(faces),
1248
1841
  beginNavigation: () => {
1249
1842
  if (faces.beginNavigation === undefined) return
1250
1843
  attempt(() => faces.beginNavigation())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-url-router",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "DSH Web GUI 地址栏路由插件:每个界面都有自己的链接(会话 / 主面板 / 插件页与组件页 / 任务看板),整行即真链接,只写 fragment。",
5
5
  "repository": {
6
6
  "type": "git",