dsh-url-router 0.1.2 → 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,12 @@
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'` 挡住** ✓(粘贴视图链接就是要切过去 ✓)。
20
+ - **工作区路线**:`#/workspace/<工作区名>/session/<会话id>`(侧栏形状 ✓)。工作区行是 `data-row-key="workspace:<uuid>"` ✓、**行文本就是工作区名** ✓(无 `data-workspace-*` ✗,实测 ✓);会话所属工作区 = 树里**它之前最近的那个 workspace 行** ✓(`workspaceNameOf()` ✓,行锚点与反映共用 ✓)。名字是**装饰** ✓:`sameRoute` 对 workspace 路线只比**会话 id** ✓(名字过期也能落地 ✓);读不到工作区行就退回 `#/session/<id>` ✓(不猜 ✓)。
21
+ - **行锚点的 href 也要带工作区** ✓(右键"复制链接"直接给出带归属的链接 ✓)。
16
22
  - **作用域由视图决定,不靠猜**:主面板是应用级界面(列的是本机插件,不是某个对话),所以它的链接**不带**会话 id;只有会话级视图才写会话。
17
23
  - **一个 boot 的权威窗口(8 秒)**:链接在窗口内压过两边各自的 localStorage 恢复,窗口过后视图重新拥有地址栏。
18
24
  - **绝不抛错**:官方 `selectPanel` 对未注册的面板 id 是**抛异常**的,所有宿主面调用都必须被容纳(`attempt`/`guard`),失败只留一条诊断并回落到屏幕上真实的视图。
@@ -31,6 +37,22 @@
31
37
  - **观察者的 `attributeFilter` 必须列全所有已发布的页面钩子**:只列 `data-plugin-detail` 曾让任务看板的重试通知饿死(历史 bug,别删名单项)。
32
38
  - **未落地的链接要有定时重试**(`LINK_RETRY_MS` = 500ms):只靠 DOM 通知不够,启动后界面会安静下来,一次被吞掉的重试就永远不来了。
33
39
  - **切换主面板前先 `beginNavigation()`**(`ctx.layout` 的配套动作,可选面,缺失即跳过)。
40
+ - **设置面板是覆盖层**:`#/settings` ✓(裸段位 ✓,`ROUTE_PATTERN` 支持 `#/<kind>` ✓)与 `#/settings/<分区>` ✓。反映读 `[data-dsh-surface="settings"]` ✓;驱动点 `[data-slot="sidebar.settings"]` 的入口 ✓(兜底按可访问名 ✓)。
41
+ - **设置分区的身份 = 显示名**(实测无稳定 id ✗:导航项只有 `aria-current="true"` ✓,无 `id`/`data-*`/`aria-controls` ✗,右侧内容区无 `data-*` ✗)。反映:在覆盖层里找 `aria-current="true"` **且 `closest('nav') !== null`** 的按钮 ✓,取它的标签文字 ✓;驱动:按标签(先精确、再忽略大小写)点 ✓。**换语言/改文案会让旧分区链接失效** ✗(重新点一次即写出新链接 ✓)。
42
+ - **设置分区里的页签同理,再深一层** ✓:`#/settings/<分区>/<页签>`。契约是标准 ARIA ✓:`[role="tab"][aria-selected="true"]` ✓(实测 `dsh-usage` 的用量/个人套餐/Token 银行就是 `role="tab"` ✓),身份同样是**标签文字** ✓;驱动=先点分区、再点标签匹配的页签 ✓(同一次调用里做完 ✓,因为分区一点开,页签就在同一个覆盖层里 ✓)。
43
+ - **`aria-selected` 也必须观察** ✓(与 `aria-current` 同理:切换页签只改它 ✓)。
44
+ - **设置链接按"前缀"落地** ✓:裸 → 分区 → 页签逐级 ✓;命名了页签的链接必须看到那个页签 ✓,只命名到分区的链接被该分区任意页签满足 ✓。
45
+ - **`aria-current` 必须在 DOM 观察者的 `attributeFilter` 里** ✓:切换分区只改这一个属性 ✓,漏了它地址栏就永远停在第一次看到的分区(历史 bug ✓)。
46
+ - **裸 `#/settings` 立即可落地**(`sameRoute` 的定向例外 ✓):分区是那个表面的细化、不是另一个表面 ✓;但**两个命名分区之间仍然算不同** ✓(否则分区链接就不驱动了 ✓)。
47
+ - **轮次锚点是"位置",只做反映**:点击聊天里的一轮(`[data-chat-turn]` ✓)把轮号写进地址栏 ✓;**绝不 preventDefault** ✗、**绝不碰交互元素**(button/a/input/textarea/select/[role=button]/[contenteditable] ✓ —— 逐个 `closest`,不用逗号选择器 ✓,因为小 DOM 桩可能不支持 ✓)。驱动侧只开会话 + 一条说明 ✓(官方没有对外跳转接缝 ✗)。
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 ✓。
34
56
  - **链接落地即交权**:视图一旦等于链接描述的界面,链接立刻失去权威(只留"还没落地"的窗口期)。否则用户在面板里继续点进去时,地址栏会被拽回链接那一层(真机踩过:点组件行后又被改回包详情)。
35
57
  - **面板行同样是链接**:`data-slot="sidebar.panellist"` 所在的那个 `<button>`(官方侧栏渲染的面板行)会挂上 `href="#/panel/<id>"` 的锚点。**行里没有 id**,id 只能从注册表取:`ctx.slots.entries('sidebar.panellist')` 的 `options.id` / `options.order` / `options.label`;label 可能是**函数**(按语言解析),所以优先按可访问名匹配、数量一致时按 order 对齐,匹配不上一律不碰。
36
58
  - **`slots` 必须留在 `inject` 里**:cordis 下读未声明的服务会抛错,而插件激活期的抛错会让整个插件 `failed`(真机事故:面板链接那次就是这么挂的——假 ctx 无条件给服务,单测抓不到,只有真机能抓)。其余可选面(如 `ctx.layout`)一律 `try/catch` 读取。
@@ -38,3 +60,44 @@
38
60
  ## 跨机开发日志
39
61
 
40
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,21 +54,33 @@ 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 ✓)。默认视图(第一个 ✓)**不写进链接** ✓,所以普通对话链接保持短 ✓。**粘贴视图链接会真的切过去** ✓(与其它覆盖层不同:这是链接存在的意义 ✓)。
60
+ - **工作区是"它住在哪",所以写在链接最前面** ✓:`#/workspace/<工作区名>/session/<会话id>`(侧栏本身就是这个形状 ✓)。工作区名是**给眼睛看的** ✓ —— 决定打开哪个会话的永远是会话 id ✓,所以**名字过期/写错也照样打开对的会话** ✓(地址栏随后自己纠正成真实工作区 ✓)。读不到工作区行(没有侧栏 / 树里没有)就**不猜** ✓,退回 `#/session/<id>` ✓。
57
61
  - **作用域由视图决定,不靠猜**:主面板是应用级界面(列的是本机插件,不是某个对话),所以它的链接不带会话 id。
58
62
  - **链接落地即交权**:视图一旦变成链接描述的那个界面,链接立刻失去权威——这样你在面板里继续点进去时,地址栏不会被拽回上一层。
59
63
  - **绝不抛错**:官方 `selectPanel` 对未注册的面板 id 是抛异常的;所有宿主面调用都被容纳,失败只留一条诊断并回落到屏幕上真实的视图。
60
64
 
61
65
  ### 已知限制
62
66
 
63
- - **设置页分区暂未纳入**:设置包没有"打开某个分区"的接缝(它们是 keyed list slot),只能等它自己发布类似契约。
64
- - **右栏 pane 与会话内轮次/消息锚点暂未纳入**。
67
+ - **轮次链接是"位置"不是"界面"**:点某一轮,地址栏记下 `#/session/<id>/turn/<n>` ✓;但**粘贴它只会打开那个对话,不会滚到那一轮** ✗ —— 官方把"跳到某轮"(`scrollToTurn` / `JUMP_PAGE_OPTIONS`)留在 chat 包**内部**,没有对外接缝 ✗,逐页翻也不可靠 ✗(实测「Load earlier」按钮在两个会话上都没加载出更早的轮次 ✗)。要真跳转,需要官方开一个服务或槽位 ✓。
68
+ - **右栏 pane 已闭环** ✓:`#/session/<id>/pane/<kind>` 与 `#/panel/<面板>/pane/<kind>` ✓(真机验证:贴链接开出对应页签 ✓、地址栏跟着切换 ✓)。页签种类由右栏自己注册 ✓(实测 7 种:`guide` / `subagentchat` / `changes-review` / `plan` / `files` / `text` / `terminal` ✓)。
69
+ - **设置面板已纳入**:`#/settings` ✓、**`#/settings/<分区>`** ✓、**`#/settings/<分区>/<页签>`** ✓(页签靠标准 `role="tab"` + `aria-selected` ✓,分区内任何页签都吃这一套 ✓)。分区**没有稳定 id** ✗(实测:导航项上只有 `aria-current="true"` ✓,父链是 `nav` ✓,右侧内容区一个 `data-*` 都没有 ✗;官方内部的 `openSection(id)` 也不对外 ✗)→ 所以**分区的身份就是它的显示名** ✓:反映读「`aria-current="true"` 且属于 `nav`」的那个按钮的**标签文字** ✓,驱动按标签点 ✓。含义:**换语言或官方改文案后,旧的分区链接会失效** ✗(但你重新点一次,地址栏就会写出新链接 ✓ —— 自洽、不过期 ✓)。裸 `#/settings` 只要面板开着就算落地 ✓,随后地址栏自动升级为具体分区 ✓。
70
+ - **设置页分区暂未纳入(旧描述)**:设置包没有"打开某个分区"的接缝(它们是 keyed list slot),只能等它自己发布类似契约。
71
+ - **会话内的轮次与行锚点已纳入** ✓:`#/session/<id>/turn/<n>` ✓(只做反映 ✓)、`#/session/<id>/row/<类>/<作用域>/<id>` ✓(反映 + 尽力滚动 ✓,含可证明时的轮次归属 ✓)。**跳转仍然做不到** ✗(官方把 `scrollToTurn` 留在包内部 ✓,已向官方提 <https://github.com/deepseek-ai/deepseek-harness/discussions/9339> ✓)。
65
72
  - **技能中心 / SSH 的内部页**:需要它们各自发布"当前实体 id + 按 id 打开"的契约(任务看板就是这么做的)。
73
+ - **和别的 fragment 写者互斥**(例如写 `#msg=` 的"跳到消息"插件):一个 URL 只有一个 fragment ✓,两个写者只能轮流拥有 ✗。本插件**不假装合并** ✓ —— 它认出外来 fragment ✓、**绝不覆盖** ✓,并明确说"只能启用一个写者" ✓(而不是互相静默擦掉对方的链接 ✗)。
66
74
  - 链接**同名不跨机**:会话 id 只在同一台机器、同一个 `DSH_HOME` 下有意义。
67
75
 
68
76
  ### 开发
69
77
 
70
78
  手写 JS 直接进 `lib/`(无构建步骤),`npm test` 直接跑交付产物(35→45 条用例,`node --test`)。改之前请读 [AGENTS.md](AGENTS.md) 里的路由与行链接契约。
71
79
 
80
+ ### 排障
81
+
82
+ 浏览器 Console 里调用 `window.__dshUrlRouter.report()` ✓,它会列出:当前地址栏 ✓、本插件认为屏幕在哪个界面 ✓、正在落地的链接与重试次数 ✓、**每一层读到了多少个钩子** ✓、右栏 pane 的**真实 kind 列表** ✓、以及它**拒绝认领**的段位 ✓。它绝不出错、也不写任何东西 ✓。
83
+
72
84
  ## English
73
85
 
74
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".
@@ -94,7 +106,7 @@ dsh plugin --profile web add dsh-url-router
94
106
  | `#/panel/task-board/task/<taskId>` | one task's detail on the task board |
95
107
  | `#/panel/task-board/new` | the task board's new-task form |
96
108
 
97
- 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`.
98
110
 
99
111
  ### Every row is a real link
100
112
 
@@ -111,4 +123,4 @@ Conversation rows and both plugin-list groups carry a transparent full-row ancho
111
123
 
112
124
  ### Limits
113
125
 
114
- 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
@@ -63,9 +63,15 @@ window.__ModuleLoader__.load({
63
63
  // another router's route may legitimately be pasted in.
64
64
  // ---------------------------------------------------------------------
65
65
  /** Route kind aliases accepted on input. */
66
- const ROUTE_ALIASES = { session: 'session', sessions: 'session', panel: 'panel', panels: 'panel' }
67
- /** Canonical fragment shape. */
68
- const ROUTE_PATTERN = /^#\/?([a-z][a-z0-9-]*)\/(.+)$/
66
+ const ROUTE_ALIASES = { session: 'session', sessions: 'session', panel: 'panel', panels: 'panel', settings: 'settings', workspace: 'workspace', workspaces: 'workspace' }
67
+ /** Attribute the settings overlay puts on its root element. */
68
+ const SETTINGS_SURFACE_ATTRIBUTE = 'data-dsh-surface'
69
+ /** Value of that attribute for the settings overlay. */
70
+ const SETTINGS_SURFACE_VALUE = 'settings'
71
+ /** Slot the settings entry is rendered into (its button opens the overlay). */
72
+ const SETTINGS_TRIGGER_SLOT = 'sidebar.settings'
73
+ /** Canonical fragment shape, plus the bare `#/<kind>` form a modal surface uses. */
74
+ const ROUTE_PATTERN = /^#\/?([a-z][a-z0-9-]*)(?:\/(.+))?$/
69
75
  /** How often an unsatisfied link is retried while it is authoritative. */
70
76
  const LINK_RETRY_MS = 500
71
77
  /** How many times an inner page may be requested while its link is authoritative. */
@@ -82,11 +88,237 @@ window.__ModuleLoader__.load({
82
88
  const TASK_BOARD_NEW_ATTRIBUTE = 'data-dsh-taskboard-new-task'
83
89
  /** Window event that asks the task board for its new-task form. */
84
90
  const TASK_BOARD_NEW_EVENT = 'dsh-taskboard-new-task'
91
+ /** Sidebar row key prefix for a workspace (the value is its id; the row text is its name). */
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'
109
+ /** Inner segment that marks the conversation inside a workspace route. */
110
+ const WORKSPACE_SESSION_SEGMENT = 'session'
111
+ /** Attribute the chat view puts on every rendered turn node (the turn number). */
112
+ const TURN_ATTRIBUTE = 'data-chat-turn'
113
+ /** Inner segment that names the turn a conversation is showing. */
114
+ const TURN_SEGMENT = 'turn'
115
+ /** Inner segment that names the right-hand pane in front. */
116
+ const PANE_SEGMENT = 'pane'
117
+ /** Service the right-hand pane registers, whose snapshot names the active tab. */
118
+ const SIDEBAR_RIGHT_SERVICE = 'sidebarRightTabs'
85
119
  /** Panel id of the official plugin manager, whose list rows and detail pages are routed. */
86
120
  const PLUGINS_PANEL_ID = 'plugins'
87
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
+
88
128
  const TAG = '[url-router]'
89
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
+
169
+ /**
170
+ * The workspace a sidebar row belongs to: the nearest workspace row before it, read from the
171
+ * row text. Returns undefined when the tree says nothing, so no caller has to guess.
172
+ * @param {object} row - the conversation row.
173
+ * @param {object} document - the document the tree lives in.
174
+ * @returns {string|undefined} the workspace display name.
175
+ */
176
+ function workspaceNameOf(row, document) {
177
+ const rows = document?.querySelectorAll?.('[data-row-key]')
178
+ if (rows === undefined || rows === null) return undefined
179
+ let name
180
+ for (const candidate of rows) {
181
+ const key = typeof candidate.getAttribute === 'function' ? candidate.getAttribute('data-row-key') : null
182
+ if (typeof key !== 'string') continue
183
+ if (key.startsWith(WORKSPACE_ROW_PREFIX)) {
184
+ name = (candidate.textContent ?? '').trim()
185
+ continue
186
+ }
187
+ if (candidate !== row) continue
188
+ return name === undefined || name === '' ? undefined : name
189
+ }
190
+ return undefined
191
+ }
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
+
90
322
  /**
91
323
  * Read a route out of a location fragment.
92
324
  * @param {string} hash - the raw `window.location.hash`, including the leading `#`.
@@ -100,16 +332,65 @@ window.__ModuleLoader__.load({
100
332
  }
101
333
  }
102
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
+
103
353
  function parseRoute(hash) {
104
354
  const match = ROUTE_PATTERN.exec(hash)
105
355
  if (match === null) return undefined
356
+ const kind = ROUTE_ALIASES[match[1]]
357
+ if (kind === undefined) return { unknown: match[1] }
358
+ // A modal surface needs no identity: `#/settings` is the whole route.
359
+ if (match[2] === undefined) return kind === 'settings' ? { kind, id: '' } : undefined
360
+ // A conversation can be named inside its workspace — the shape the sidebar itself shows,
361
+ // and the only way a link says *where* a conversation lives.
362
+ if (kind === 'workspace') {
363
+ const parts = match[2].replace(/\/+$/, '').split('/')
364
+ const name = decodePart(parts.shift() ?? '').trim()
365
+ if (name === '') return { unknown: `workspace/${match[2]}` }
366
+ const rest = parts.map(decodePart).filter(segment => segment.trim() !== '')
367
+ if (rest.length < 2 || rest[0] !== WORKSPACE_SESSION_SEGMENT) return { unknown: `workspace/${match[2]}` }
368
+ const sessionId = rest[1].trim()
369
+ if (sessionId === '') return { unknown: `workspace/${match[2]}` }
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]}` }
378
+ }
106
379
  const segments = match[2].replace(/\/+$/, '').split('/')
107
380
  const head = segments.shift() ?? ''
108
- if (head === '') return undefined
381
+ if (head === '') return kind === 'settings' ? { kind, id: '' } : undefined
109
382
  const id = decodePart(head).trim()
110
- if (id === '') return undefined
111
- const kind = ROUTE_ALIASES[match[1]]
112
- if (kind === undefined) return { unknown: match[1] }
383
+ if (id === '') return kind === 'settings' ? { kind, id: '' } : undefined
384
+ // A settings section has no stable id in the DOM (measured: the nav item carries only
385
+ // aria-current), so its identity is its *label* — the string the user actually sees and
386
+ // clicks. Round-tripping the label keeps the link self-consistent; a locale change
387
+ // renames the sections and the address bar simply writes the new label next time.
388
+ if (kind === 'settings') {
389
+ // A section may hold tabs of its own (measured: `role="tab"` + `aria-selected`), so
390
+ // the rest of the path names the tab — again by its label.
391
+ const rest = segments.map(decodePart).filter(segment => segment.trim() !== '')
392
+ return rest.length === 0 ? { kind, id } : { kind, id, inner: rest }
393
+ }
113
394
  // A panel may have an inner page (a plugin's detail view, for instance): the
114
395
  // rest of the path names it, per panel.
115
396
  const inner = segments.map(decodePart).filter(segment => segment.trim() !== '')
@@ -123,6 +404,18 @@ window.__ModuleLoader__.load({
123
404
  * @returns {string} the `#/<kind>/<encoded id>` fragment.
124
405
  */
125
406
  function routeHash(kind, id, inner) {
407
+ // A modal surface has no identity: `#/settings`, not `#/settings/`.
408
+ if (kind === 'settings' && (id === undefined || id === '')) return '#/settings'
409
+ if (kind === 'workspace') {
410
+ const rest = innerPath(inner)
411
+ const tail = rest.length === 0 ? '' : `/${rest.map(encodeURIComponent).join('/')}`
412
+ return `#/workspace/${encodeURIComponent(id)}${tail}`
413
+ }
414
+ if (kind === 'settings') {
415
+ const head = `#/settings/${encodeURIComponent(id)}`
416
+ const rest = innerPath(inner)
417
+ return rest.length === 0 ? head : `${head}/${rest.map(encodeURIComponent).join('/')}`
418
+ }
126
419
  const base = `#/${kind}/${encodeURIComponent(id)}`
127
420
  if (inner === undefined || inner === '') return base
128
421
  const segments = innerPath(inner)
@@ -228,6 +521,13 @@ window.__ModuleLoader__.load({
228
521
  let claimed = false
229
522
  /** The package segment of the deepest panel page the address bar carried. */
230
523
  let panelScope
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
530
+ let turnAnchor
231
531
  /** How many times the linked panel was selected (bounded, see below). */
232
532
  let panelAttempts = 0
233
533
  /** How many times a panel link's inner page was requested (bounded, see below). */
@@ -248,6 +548,7 @@ window.__ModuleLoader__.load({
248
548
  claimed = false
249
549
  panelAttempts = 0
250
550
  innerAttempts = 0
551
+ paneOpenedFor = undefined
251
552
  dropTimer()
252
553
  }
253
554
  /**
@@ -255,9 +556,20 @@ window.__ModuleLoader__.load({
255
556
  * conversation behind it, because the panel is what fills the screen.
256
557
  */
257
558
  const canonical = () => {
559
+ // The settings overlay covers the main column, so while it is up it is what is on
560
+ // screen; when it closes the ordinary surface takes the address bar back.
561
+ if (window.document?.querySelector?.(`[${SETTINGS_SURFACE_ATTRIBUTE}="${SETTINGS_SURFACE_VALUE}"]`) != null) {
562
+ const section = host.activeSettingsSection() ?? ''
563
+ const tab = section === '' ? undefined : host.activeSettingsTab()
564
+ return tab === undefined ? { kind: 'settings', id: section } : { kind: 'settings', id: section, inner: [tab] }
565
+ }
258
566
  const panelId = host.activePanelId()
259
567
  if (panelId !== undefined && panelId !== null) {
260
568
  const id = String(panelId)
569
+ // A right-hand pane in front is part of what is on screen, so it is named first:
570
+ // it is true whether or not the panel itself reports an inner page.
571
+ const paneInPanel = host.activePaneKind()
572
+ if (paneInPanel !== undefined) return { kind: 'panel', id, inner: innerPath([PANE_SEGMENT, paneInPanel]) }
261
573
  const page = host.panelPage(id, panelScope)
262
574
  if (page === undefined) return { kind: 'panel', id, inner: undefined }
263
575
  const segments = innerPath(page)
@@ -267,10 +579,98 @@ window.__ModuleLoader__.load({
267
579
  return { kind: 'panel', id, inner: segments }
268
580
  }
269
581
  const session = host.mainViewSessionId()
270
- if (session !== undefined && session !== null) return { kind: 'session', id: String(session) }
582
+ if (session !== undefined && session !== null) {
583
+ const id = String(session)
584
+ // The workspace is part of *where* the conversation lives, so it leads the route.
585
+ // Reading it from the sidebar keeps this honest: no sidebar (or no matching row)
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]
604
+ const workspaceName = host.workspaceNameOf(id)
605
+ if (workspaceName !== undefined) {
606
+ return { kind: 'workspace', id: workspaceName, inner: innerPath([WORKSPACE_SESSION_SEGMENT, id, ...position, ...tail]) }
607
+ }
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;
611
+ // it is only named while the same conversation is the one on screen.
612
+ if (turnAnchor !== undefined && turnAnchor.session === id) {
613
+ return { kind: 'session', id, inner: innerPath([TURN_SEGMENT, turnAnchor.turn]) }
614
+ }
615
+ return { kind: 'session', id }
616
+ }
617
+ return undefined
618
+ }
619
+ // Two routes are the same surface when they compare equal — plus one refinement: a bare
620
+ // `#/settings` names the overlay, and whichever section it happens to show is that same
621
+ // surface, so the link lands at once instead of sitting there until its window expires.
622
+ // A *named* section against a different named section is a real difference, so a section
623
+ // link still drives the overlay to the section it asks for.
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
271
633
  return undefined
272
634
  }
273
- const sameRoute = (left, right) => routeKey(left) === routeKey(right)
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
667
+ }
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
673
+ }
274
674
  /** Write the fragment for the current view, leaving path and query untouched. */
275
675
  const reflect = route => {
276
676
  const { pathname, search, hash } = host.readLocation()
@@ -278,6 +678,18 @@ window.__ModuleLoader__.load({
278
678
  // path or the launch query, and comparing them made every call look like a change.
279
679
  const wantedHash = route === undefined ? '' : routeHash(route.kind, route.id, route.inner)
280
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
+ }
281
693
  // Nothing to name and nothing named: leave the address bar exactly as it is
282
694
  // (this also leaves a foreign fragment alone, which the caller never reaches).
283
695
  if (route === undefined && hash === '') return
@@ -293,6 +705,13 @@ window.__ModuleLoader__.load({
293
705
  const currentKey = routeKey(current)
294
706
  const moved = currentKey !== seen
295
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
+ }
296
715
  if (linked === undefined) {
297
716
  reflect(current)
298
717
  return
@@ -313,7 +732,92 @@ window.__ModuleLoader__.load({
313
732
  reflect(canonical())
314
733
  return
315
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
+ }
769
+ if (linked.kind === 'workspace') {
770
+ // The workspace is where the conversation lives, not a separate surface: the link
771
+ // is satisfied by that conversation being on screen, whatever the name says.
772
+ const target = innerPath(linked.inner)[1]
773
+ const shown = host.mainViewSessionId()
774
+ if (shown !== target && source !== 'hash' && panelAttempts < INNER_ATTEMPT_LIMIT) {
775
+ panelAttempts += 1
776
+ host.openSession(target)
777
+ }
778
+ armRetry()
779
+ return
780
+ }
781
+ if (linked.kind === 'settings') {
782
+ if (source !== 'hash' && innerAttempts < INNER_ATTEMPT_LIMIT) {
783
+ innerAttempts += 1
784
+ host.openSettings(linked.id, innerPath(linked.inner)[0])
785
+ }
786
+ armRetry()
787
+ return
788
+ }
789
+ if (linked.kind === 'session' && linked.inner !== undefined && innerPath(linked.inner)[0] === PANE_SEGMENT) {
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) {
797
+ innerAttempts += 1
798
+ paneOpenedFor = wantedPane
799
+ host.openPane(wantedPane, linked.kind === 'panel' ? 'panel' : 'session', message => host.warn(message))
800
+ }
801
+ armRetry()
802
+ return
803
+ }
804
+ if (linked.kind === 'session' && linked.inner !== undefined && innerPath(linked.inner)[0] === TURN_SEGMENT) {
805
+ // A turn link is a position, not a surface: the conversation is opened, and the
806
+ // view is told once that this build does not scroll to the turn (see README).
807
+ // Record the position so the canonical form keeps it, then fall through to the
808
+ // ordinary conversation branch: a turn link opens the conversation, and saying so
809
+ // in the console would only be noise.
810
+ turnAnchor = { session: linked.id, turn: innerPath(linked.inner)[1] }
811
+ }
316
812
  if (linked.kind === 'panel') {
813
+ if (linked.inner !== undefined && innerPath(linked.inner)[0] === PANE_SEGMENT) {
814
+ if (innerAttempts < INNER_ATTEMPT_LIMIT) {
815
+ innerAttempts += 1
816
+ host.openPane(innerPath(linked.inner)[1], linked.kind === 'panel' ? 'panel' : 'session', message => host.warn(message))
817
+ }
818
+ armRetry()
819
+ return
820
+ }
317
821
  // Panels are selected by id and report back through the panel store; an id
318
822
  // nobody registered never becomes active, so the claim is retried when the
319
823
  // panel store moves (a late-loading plugin), not on every catalog tick.
@@ -364,7 +868,21 @@ window.__ModuleLoader__.load({
364
868
  /** Adopt the surface the address bar names, if it names one. */
365
869
  const readLink = () => {
366
870
  if (disposed) return
367
- 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)
368
886
  if (parsed === undefined) {
369
887
  dropLink()
370
888
  reconcile('hash')
@@ -373,6 +891,7 @@ window.__ModuleLoader__.load({
373
891
  if (parsed.unknown !== undefined) {
374
892
  // Another router's route (or a typo): leave the address bar alone and say so
375
893
  // once, so a router owning more kinds can take over later.
894
+ if (!observed.ignored.includes(parsed.unknown)) observed.ignored.push(parsed.unknown)
376
895
  if (linked === undefined) host.warn(`${TAG} ignoring unknown route kind "${parsed.unknown}"`)
377
896
  return
378
897
  }
@@ -434,8 +953,106 @@ window.__ModuleLoader__.load({
434
953
  const offDom = host.onDomChange(() => safeReconcile('dom'))
435
954
  const offHashChange = host.onHashChange(() => safeReadLink())
436
955
  safeReadLink()
956
+ // Pointing at a turn records a position, never a navigation: the listener is passive
957
+ // and it refuses to act on any interactive element, so clicks keep working exactly as
958
+ // the chat expects. Clicking a turn is how the address bar learns "which turn".
959
+ const onDocumentClick = event => {
960
+ if (disposed || event.button !== 0 || event.defaultPrevented) return
961
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
962
+ const target = event.target
963
+ if (typeof target?.closest !== 'function') 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
+ }
986
+ // One selector at a time: a comma list is the kind of thing a small DOM stub (and a
987
+ // future minimal engine) may not implement, and the answer must be the same.
988
+ for (const interactive of ['button', 'a', 'input', 'textarea', 'select', '[role="button"]', '[contenteditable="true"]']) {
989
+ if (target.closest(interactive) !== null) return
990
+ }
991
+ const node = target.closest(`[${TURN_ATTRIBUTE}]`)
992
+ if (node === null) return
993
+ const id = session
994
+ if (id === undefined) return
995
+ const raw = node.getAttribute?.(TURN_ATTRIBUTE)
996
+ const turn = typeof raw === 'string' ? raw.trim() : ''
997
+ if (turn === '') return
998
+ turnAnchor = { session: id, turn }
999
+ rowAnchor = undefined
1000
+ safeReconcile('turn') }
1001
+ try {
1002
+ window.document?.addEventListener?.('click', onDocumentClick, true)
1003
+ } catch {
1004
+ // A composition without the DOM listener is still a working router.
1005
+ }
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
+
437
1049
  return () => {
438
1050
  disposed = true
1051
+ try {
1052
+ window.document?.removeEventListener?.('click', onDocumentClick, true)
1053
+ } catch {
1054
+ // Nothing to undo when the listener was never attached.
1055
+ }
439
1056
  dropTimer()
440
1057
  dropRetry()
441
1058
  offPanel()
@@ -630,7 +1247,13 @@ window.__ModuleLoader__.load({
630
1247
  function enhanceRow(document, row, openSession) {
631
1248
  const id = sessionIdOfRow(row)
632
1249
  if (id === undefined) return false
633
- return mountAnchor(document, row, routeHash('session', id), () => openSession(id))
1250
+ // The workspace the row sits under leads the link, so a copied link says where the
1251
+ // conversation lives. No workspace row in the tree means no guess: the plain route.
1252
+ const workspaceName = workspaceNameOf(row, document)
1253
+ const href = workspaceName === undefined
1254
+ ? routeHash('session', id)
1255
+ : routeHash('workspace', workspaceName, innerPath([WORKSPACE_SESSION_SEGMENT, id]))
1256
+ return mountAnchor(document, row, href, () => openSession(id))
634
1257
  }
635
1258
 
636
1259
  /**
@@ -761,7 +1384,7 @@ window.__ModuleLoader__.load({
761
1384
  // browser wiring: probe the official faces, then drive the machine.
762
1385
  // ---------------------------------------------------------------------
763
1386
  /** Services this plugin needs before apply runs; `layout` is optional and read through `ctx.get`. */
764
- const inject = ['sessions', 'workspaces', 'uiWorkspace', 'slots']
1387
+ const inject = ['sessions', 'workspaces', 'uiWorkspace', 'slots', 'sidebarRightTabs']
765
1388
 
766
1389
  /**
767
1390
  * Probe the faces the router needs.
@@ -796,6 +1419,9 @@ window.__ModuleLoader__.load({
796
1419
  const panels = typeof panelInfo?.getSnapshot === 'function' && typeof panelInfo?.subscribe === 'function' ? panelInfo : undefined
797
1420
  const selectPanel = typeof layout?.selectPanel === 'function' ? layout.selectPanel.bind(layout) : undefined
798
1421
  const beginNavigation = typeof layout?.beginNavigation === 'function' ? layout.beginNavigation.bind(layout) : undefined
1422
+ const openRightbar = typeof layout?.openRightbar === 'function' ? layout.openRightbar.bind(layout) : undefined
1423
+ const sidebarRightTabs = typeof scope.get === 'function' ? scope.get('sidebarRightTabs') : undefined
1424
+ const sidebarRight = typeof scope.get === 'function' ? scope.get('sidebarRight') : undefined
799
1425
  return {
800
1426
  faces: {
801
1427
  catalog: list,
@@ -803,6 +1429,9 @@ window.__ModuleLoader__.load({
803
1429
  openSession: id => openSession.call(navigation, id),
804
1430
  selectPanel: selectPanel === undefined ? undefined : id => selectPanel(id),
805
1431
  beginNavigation: beginNavigation === undefined ? undefined : () => beginNavigation(),
1432
+ openRightbar: openRightbar === undefined ? undefined : track => openRightbar(track),
1433
+ sidebarRightTabs,
1434
+ sidebarRight,
806
1435
  panels,
807
1436
  slots,
808
1437
  },
@@ -832,6 +1461,24 @@ window.__ModuleLoader__.load({
832
1461
  }
833
1462
  return {
834
1463
  mainViewSessionId: () => attempt(() => mainViewSessionId(faces.catalog.getSnapshot().byId), undefined),
1464
+ // The sidebar lists workspaces and their sessions in one tree, in order, so the
1465
+ // workspace a conversation belongs to is the nearest workspace row before it.
1466
+ workspaceNameOf: sessionId => {
1467
+ const rows = window.document?.querySelectorAll?.('[data-row-key]')
1468
+ if (rows === undefined || rows === null) return undefined
1469
+ let name
1470
+ for (const row of rows) {
1471
+ const key = typeof row.getAttribute === 'function' ? row.getAttribute('data-row-key') : null
1472
+ if (typeof key !== 'string') continue
1473
+ if (key.startsWith(WORKSPACE_ROW_PREFIX)) {
1474
+ name = (row.textContent ?? '').trim()
1475
+ continue
1476
+ }
1477
+ if (key !== `${ROW_KEY_PREFIX}${sessionId}`) continue
1478
+ return name === undefined || name === '' ? undefined : name
1479
+ }
1480
+ return undefined
1481
+ },
835
1482
  activePanelId: () => (faces.panels === undefined ? undefined : attempt(() => faces.panels.getSnapshot()?.activePanelId, undefined)),
836
1483
  // The panel that owns inner pages advertises them with a data attribute; the
837
1484
  // router only reads it, never the panel's internals.
@@ -914,6 +1561,283 @@ window.__ModuleLoader__.load({
914
1561
  openSession: id => {
915
1562
  attempt(() => faces.openSession(id))
916
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
+ },
1655
+ // The right-hand pane names the tab in front. The service is the source of truth;
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),
1659
+ activePaneKind: () => {
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)
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
1688
+ // DOM fallback, deliberately narrow: the active-pane marker is shared with the
1689
+ // main dockkit, so it only counts when it really belongs to the right bar.
1690
+ const active = window.document?.querySelector?.('[data-dockkit-pane-active]')
1691
+ if (active === null || active === undefined) return undefined
1692
+ const own = typeof active.getAttribute === 'function' ? active.getAttribute('data-sidebar-right-tab') : null
1693
+ if (typeof own === 'string' && own !== '') return own
1694
+ const inside = typeof active.querySelector === 'function' ? active.querySelector('[data-sidebar-right-tab]') : null
1695
+ const inner = typeof inside?.getAttribute === 'function' ? inside.getAttribute('data-sidebar-right-tab') : null
1696
+ return typeof inner === 'string' && inner !== '' ? inner : undefined
1697
+ },
1698
+ // The section the overlay shows: the nav item the platform marks aria-current.
1699
+ // One selector at a time (a comma list is beyond a small DOM stub) and the `nav`
1700
+ // ancestor check keeps unrelated aria-current controls out of it.
1701
+ activeSettingsSection: () => {
1702
+ const overlay = window.document?.querySelector?.(`[${SETTINGS_SURFACE_ATTRIBUTE}="${SETTINGS_SURFACE_VALUE}"]`)
1703
+ if (overlay === null || overlay === undefined || typeof overlay.querySelectorAll !== 'function') return undefined
1704
+ for (const candidate of overlay.querySelectorAll('button')) {
1705
+ if (typeof candidate.getAttribute !== 'function') continue
1706
+ if (candidate.getAttribute('aria-current') !== 'true') continue
1707
+ if (typeof candidate.closest === 'function' && candidate.closest('nav') === null) continue
1708
+ const label = (candidate.textContent ?? '').trim()
1709
+ if (label !== '') return label
1710
+ }
1711
+ return undefined
1712
+ },
1713
+ // The tab a settings section is showing: the same ARIA contract one level deeper.
1714
+ activeSettingsTab: () => {
1715
+ const overlay = window.document?.querySelector?.(`[${SETTINGS_SURFACE_ATTRIBUTE}="${SETTINGS_SURFACE_VALUE}"]`)
1716
+ if (overlay === null || overlay === undefined || typeof overlay.querySelectorAll !== 'function') return undefined
1717
+ for (const candidate of overlay.querySelectorAll('[role="tab"]')) {
1718
+ if (typeof candidate.getAttribute !== 'function') continue
1719
+ if (candidate.getAttribute('aria-selected') !== 'true') continue
1720
+ const label = (candidate.textContent ?? '').trim()
1721
+ if (label !== '') return label
1722
+ }
1723
+ return undefined
1724
+ },
1725
+ openSettings: (section, tab) => {
1726
+ if (typeof section === 'string' && section !== '') {
1727
+ // A section is named by the label the user reads, so the request is a label match.
1728
+ const overlay = window.document?.querySelector?.(`[${SETTINGS_SURFACE_ATTRIBUTE}="${SETTINGS_SURFACE_VALUE}"]`)
1729
+ const scope = overlay ?? window.document
1730
+ if (typeof scope?.querySelectorAll === 'function') {
1731
+ const candidates = [...scope.querySelectorAll('button')].filter(candidate => {
1732
+ if (typeof candidate.closest === 'function' && candidate.closest('nav') === null) return false
1733
+ return (candidate.textContent ?? '').trim() !== ''
1734
+ })
1735
+ const wanted = section.trim().toLowerCase()
1736
+ const match = candidates.find(candidate => (candidate.textContent ?? '').trim().toLowerCase() === wanted)
1737
+ if (match !== undefined && typeof match.click === 'function') {
1738
+ attempt(() => match.click())
1739
+ // The section may own tabs: a named one is clicked once the section is up.
1740
+ if (typeof tab === 'string' && tab !== '') {
1741
+ const wantedTab = tab.trim().toLowerCase()
1742
+ const tabs = [...scope.querySelectorAll('[role="tab"]')]
1743
+ const matchTab = tabs.find(candidate => (candidate.textContent ?? '').trim().toLowerCase() === wantedTab)
1744
+ if (matchTab !== undefined && typeof matchTab.click === 'function') attempt(() => matchTab.click())
1745
+ }
1746
+ return
1747
+ }
1748
+ }
1749
+ }
1750
+ const slot = window.document?.querySelector?.(`[data-slot="${SETTINGS_TRIGGER_SLOT}"]`)
1751
+ const button =
1752
+ (typeof slot?.querySelector === 'function' ? slot.querySelector('button') : null) ??
1753
+ (typeof slot?.closest === 'function' ? slot.closest('button') : null)
1754
+ if (typeof button?.click === 'function') {
1755
+ attempt(() => button.click())
1756
+ return
1757
+ }
1758
+ // Fallback for a composition without that slot: the labelled entry.
1759
+ const labelled = typeof window.document?.querySelectorAll === 'function'
1760
+ ? [...window.document.querySelectorAll('button')].find(candidate => /^(settings|设置)$/i.test((candidate.textContent ?? '').trim()))
1761
+ : undefined
1762
+ if (typeof labelled?.click === 'function') attempt(() => labelled.click())
1763
+ },
1764
+ openPane: (kind, track, warn) => {
1765
+ if (typeof kind !== 'string' || kind === '') return
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(', ')}`)
1773
+ return
1774
+ }
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
+ }
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),
917
1841
  beginNavigation: () => {
918
1842
  if (faces.beginNavigation === undefined) return
919
1843
  attempt(() => faces.beginNavigation())
@@ -953,6 +1877,12 @@ window.__ModuleLoader__.load({
953
1877
  'data-plugin-row-detail',
954
1878
  TASK_BOARD_OPEN_ATTRIBUTE,
955
1879
  TASK_BOARD_NEW_ATTRIBUTE,
1880
+ // Switching a settings section only moves this marker, so it must be
1881
+ // watched or the address bar keeps the section it saw first.
1882
+ 'aria-current',
1883
+ // Switching a settings *tab* moves this one, for the same reason.
1884
+ 'aria-selected',
1885
+ SETTINGS_SURFACE_ATTRIBUTE,
956
1886
  ],
957
1887
  })
958
1888
  return () => observer.disconnect()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-url-router",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "DSH Web GUI 地址栏路由插件:每个界面都有自己的链接(会话 / 主面板 / 插件页与组件页 / 任务看板),整行即真链接,只写 fragment。",
5
5
  "repository": {
6
6
  "type": "git",
@@ -29,7 +29,8 @@
29
29
  "@deepseek-ai/dsh-api-session-controller",
30
30
  "@deepseek-ai/dsh-api-workspace-controller",
31
31
  "@deepseek-ai/dsh-client-ui-workspace",
32
- "@deepseek-ai/dsh-client-ui-layout"
32
+ "@deepseek-ai/dsh-client-ui-layout",
33
+ "@deepseek-ai/dsh-client-ui-sidebar-right"
33
34
  ]
34
35
  },
35
36
  "compatibility": {