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 +53 -1
- package/README.md +13 -5
- package/lib/client.js +635 -42
- package/package.json +1 -1
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
|
-
-
|
|
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`
|
|
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
|
|
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
|
-
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
371
|
-
if (
|
|
372
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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 (
|
|
395
|
-
if (
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
619
|
-
|
|
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
|
-
|
|
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 :
|
|
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
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
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
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
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
|
-
|
|
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())
|