dsh-url-router 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,36 @@
1
+ # AGENTS.md — dsh-url-router
2
+
3
+ 本仓是一个独立的 DeepSeek Harness Web GUI 插件仓(**只有浏览器半区**)。
4
+
5
+ ## 形态约束
6
+
7
+ - **手写 JS,直接进 `lib/`**:没有构建步骤,`lib/index.js`(宿主半区,空操作)与 `lib/client.js`(浏览器半区,交付产物)既是源也是产物。
8
+ - **`lib/client.js` 必须守住模块加载器合同**:`window.__ModuleLoader__.load({ id: 'dsh-url-router', factory })`,工厂返回 `{ apply, inject }`;`inject` 是服务名 `['sessions','workspaces','uiWorkspace','slots']`,包 `dsh.client.inject` 声明的是四个官方客户端模块 id(含 `@deepseek-ai/dsh-client-ui-layout`)。`ctx.layout` 走 `ctx.get('layout')` **可选读取**(组合里可能没有布局包)。
9
+ - **零依赖**:没有 dependencies/devDependencies,也没有遥测与网络请求。
10
+ - **测试用 `node --test`**:`npm test`。测试**直接加载交付产物**(`node:vm` 里假 loader + 假 window + 手动时钟 + 假 panel store/目录),不复制实现、不 mock 模块图;行为变化必须带测试。
11
+
12
+ ## 路由契约(改之前先读)
13
+
14
+ - **本插件是地址栏的唯一写者**。任何其它写 fragment 的插件都不得同时启用(`dsh-session-url` 就是这样一个插件)。
15
+ - **段位**:`#/session/<id>`(会话,`uiWorkspace.openSession`)、`#/panel/<panelId>`(主面板,`ctx.layout.selectPanel`)。输入侧接受复数与百分号编码,规范化成单数形式;**不认识的段位一律不碰**,只留一条诊断。
16
+ - **作用域由视图决定,不靠猜**:主面板是应用级界面(列的是本机插件,不是某个对话),所以它的链接**不带**会话 id;只有会话级视图才写会话。
17
+ - **一个 boot 的权威窗口(8 秒)**:链接在窗口内压过两边各自的 localStorage 恢复,窗口过后视图重新拥有地址栏。
18
+ - **绝不抛错**:官方 `selectPanel` 对未注册的面板 id 是**抛异常**的,所有宿主面调用都必须被容纳(`attempt`/`guard`),失败只留一条诊断并回落到屏幕上真实的视图。
19
+ - 只用 `replaceState`;路径与查询串原样保留。
20
+
21
+ ## 行链接契约(改之前先读)
22
+
23
+ - **整行即链接**:`[data-row-key^="session:"]` 的会话行会被挂上**覆盖整行的透明真锚点**(`data-dsh-part="url-router-link"`,作为行首子元素,`href="#/session/<id>"`、`target="_blank"`)。右键=浏览器原生链接菜单,中键/修饰键=原生新标签,**左键单击只 `preventDefault` 后调官方导航**(原地切换、不新增历史记录)。
24
+ - **样式表两条规则**,其中 `:is(button,[role="button"],a,…):not(.dsh-url-router-link)` 的 `:not()` 是**止血点**:去掉它,这条规则会匹配锚点自身(锚点就是 `<a>`),把它压成 0×0 的 relative 元素,右键就再也命中不到(历史事故,别删)。
25
+ - 锚点 href 必须用本插件自己的编码函数 `routeHash('session', id)`,不要另写一套。
26
+ - **找不到 DOM / 行属性不匹配一律静默 no-op**,绝不改官方行、绝不抛错;锚点点击里的导航也要 `try/catch`(官方导航可能抛)。
27
+ - 行选择器或属性一旦改动,必须同步改 `test/client.test.js` 里的假 DOM 用例(假 DOM 已包含 `fakeDom`/`openWithDom`)。
28
+
29
+ - **面板内部页是多段的**:`#/panel/plugins/<包>/<组件>`(每段单独 `encodeURIComponent`,所以原生的 `/` 一定是层级分隔)。钩子分**两组**,不要以为一套通吃:**已安装组** `li[data-plugin-package]` → `[data-plugin-detail]` → `li[data-plugin-row="<kind>:<rowId>"]` → `[data-plugin-row-detail="<包>#<rowId>"]`;**官方组** `li[data-plugin-item="<id>"]` → `[data-plugin-item-detail="<id>"]`(实测:官方项页面**没有** `data-plugin-detail`)。驱动一个段位时先找包行、再找官方行。**钩子有两种拼写**(`#` 与 `:`),解析统一走 `splitRowHook()`,别自己 `split`。
30
+ - **链接落地即交权**:视图一旦等于链接描述的界面,链接立刻失去权威(只留"还没落地"的窗口期)。否则用户在面板里继续点进去时,地址栏会被拽回链接那一层(真机踩过:点组件行后又被改回包详情)。
31
+ - **面板行同样是链接**:`data-slot="sidebar.panellist"` 所在的那个 `<button>`(官方侧栏渲染的面板行)会挂上 `href="#/panel/<id>"` 的锚点。**行里没有 id**,id 只能从注册表取:`ctx.slots.entries('sidebar.panellist')` 的 `options.id` / `options.order` / `options.label`;label 可能是**函数**(按语言解析),所以优先按可访问名匹配、数量一致时按 order 对齐,匹配不上一律不碰。
32
+ - **`slots` 必须留在 `inject` 里**:cordis 下读未声明的服务会抛错,而插件激活期的抛错会让整个插件 `failed`(真机事故:面板链接那次就是这么挂的——假 ctx 无条件给服务,单测抓不到,只有真机能抓)。其余可选面(如 `ctx.layout`)一律 `try/catch` 读取。
33
+
34
+ ## 跨机开发日志
35
+
36
+ 改动记录在私有库 [DDDMUC/repo-devlogs](https://github.com/DDDMUC/repo-devlogs) 的 **`dsh-url-router/`** 文件夹(`HANDOFF.md` 最新一轮在最上面;macOS 端写 `WORKLOG-macos.md`,条目以 `[macOS]` 开头)。按该库规矩,每条先写 `**运行环境**`(设备 / 应用 / 服务商与模型),再写做了什么、动了哪些文件、怎么验证、遗留问题。
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DDDMUC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,112 @@
1
- # Temporary Holding Version
1
+ # dsh-url-router
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ One URL for every surface: the address bar names what is on screen, and a link opens
4
+ the surface it names. **Browser half only** — no dependencies, no host route, no telemetry.
5
+
6
+ ```
7
+ #/session/session-2b6e6371-6475-4c44-9f46-a779916a0b56 ← the conversation on screen
8
+ #/panel/plugins ← the Plugins panel
9
+ #/panel/task-board ← the Task Board panel
10
+ ```
11
+
12
+ Click the Plugins row in the sidebar and the address bar becomes `#/panel/plugins`;
13
+ click a conversation and it becomes `#/session/<id>`. Paste either link into another
14
+ tab (same machine, same `DSH_HOME`) and that surface opens.
15
+
16
+ ## Install
17
+
18
+ ```sh
19
+ dsh plugin --profile web add dsh-url-router
20
+ # restart `dsh web`, then hard-refresh the browser
21
+ ```
22
+
23
+ ## Routes
24
+
25
+ | Route | Surface | Opened by |
26
+ |---|---|---|
27
+ | `#/session/<id>` | the conversation the main view shows | `uiWorkspace.openSession(id)` |
28
+ | `#/panel/<panelId>` | a main-column panel (`plugins`, `task-board`, `skill-explorer`, `ssh`, …) | `layout.selectPanel(id)` |
29
+ | `#/panel/plugins/<package>` | an installed plugin's page | the panel's own list row control |
30
+ | `#/panel/plugins/<item>` | a built-in plugin's page (`shell`, `agent-loop`, `subagent`, `web-search`) | the panel's own list row control |
31
+ | `#/panel/plugins/<package>/<component>` | one component page inside that plugin's page | the panel's own component row control |
32
+
33
+ Route kinds are owned by this plugin; a kind it does not know (`#/settings/…`) is
34
+ **left alone** with a single diagnostic, so another router can own it later.
35
+
36
+ ## Row links
37
+
38
+ Every conversation row in the sidebar is exposed as a **real link** — a transparent
39
+ `<a href="#/session/<id>" target="_blank">` covering the row, so the browser's own link
40
+ behavior applies with no custom menu:
41
+
42
+ - **right-click** → the native menu (open link in new tab / copy link / save link as)
43
+ - **middle click, Cmd/Ctrl+click** → a new tab, natively
44
+ - **plain left click** → opens the conversation *here* (`preventDefault` + the official
45
+ navigation), so the address bar keeps using `replaceState` and no history entry is added
46
+ - the row's own controls (⋯, buttons) are lifted above the overlay and keep working
47
+
48
+ The same applies to the **main-panel rows** (Plugins, Task Board, …): each becomes
49
+ `#/panel/<id>`. A panel row carries no identity in the DOM, so its id comes from the panel
50
+ registry (`ctx.slots.entries('sidebar.panellist')`), matched by accessible name and aligned
51
+ by registration order; a row that cannot be matched is left untouched.
52
+
53
+ It supersedes the earlier `dsh-session-url` plugin: install one, not both (both write the
54
+ fragment). The link shape `#/session/<id>` is identical, so existing links keep working.
55
+
56
+ ## Rules it keeps
57
+
58
+ - **Fragment only.** Path and query string are never touched (a launch `?token=…` survives).
59
+ - **One writer.** This plugin owns the fragment; run it *instead of* any other plugin
60
+ that writes the address bar (`dsh-session-url` mirrors conversations into the same
61
+ fragment — the two would overwrite each other).
62
+ - **`replaceState` only**, so browsing panels does not fill the Back stack.
63
+ - **Scopes are declared, not guessed.** A main panel is application-level (it lists this
64
+ machine's plugins, not a conversation), so its link carries no session id.
65
+ - **A link stays authoritative until it lands** (at most one boot, 8 s): once the named
66
+ surface is on screen the link stops owning the URL, so walking deeper inside a panel
67
+ updates the address bar instead of being pulled back to the link.
68
+ - **A link wins for one boot.** Both owners restore their own remembered state while the
69
+ page loads, so a linked surface stays authoritative for 8 s; after that the view owns
70
+ the URL again.
71
+ - **Never throws.** Refused navigations and broken services are contained, reported once,
72
+ and fall back to the view actually on screen — never a blank page.
73
+
74
+ ## Limits
75
+
76
+ - Depends on the official client faces `ctx.layout.selectPanel` / `ctx.layout.panelInfo`
77
+ and the session/workspace controllers. If their shape changes, the plugin disables
78
+ itself with one diagnostic rather than touching official behavior.
79
+ - Row links depend on the official row markup (`div[data-row-key="session:<id>"]`); if
80
+ that changes the plugin degrades to doing nothing rather than touching official rows.
81
+ - Settings sections are **not** routed yet: the settings package exposes no
82
+ "open section" seam (they are keyed list slots). Right-hand panes are openable
83
+ (`ISidebarRight.openTab(kind)`) but not yet routed.
84
+ - An unregistered panel id is refused by the layout by *throwing*; the link is dropped at
85
+ the end of its window with one diagnostic. A panel that registers within the window is
86
+ still reached.
87
+
88
+ ---
89
+
90
+ # dsh-url-router(中文)
91
+
92
+ **一个界面,一条链接。** 地址栏永远描述屏幕上的东西,链接打开它命名的界面。
93
+
94
+ - 点侧栏「插件」→ 地址栏变成 `#/panel/plugins`;点回某个对话 → 变回 `#/session/<id>`。
95
+ - 把链接粘到另一个标签页(同机、同 `DSH_HOME`)就能直接打开那个界面。
96
+ - 只写 fragment,路径与查询串原样保留;只用 `replaceState`,不往历史里塞垃圾。
97
+ - **它必须是地址栏的唯一写者**:不要和 `dsh-session-url` 同时启用(两者会互相覆盖)。
98
+ - 未知的段位(如 `#/settings/…`)一律**不碰**,只留一条诊断,留给将来的路由器。
99
+ - 目标打不开(未注册的面板、归档的会话)→ 到期丢弃 + 一条诊断 + 回落到屏幕上真实的界面,**绝不白屏、绝不抛错**。
100
+
101
+ ## 安装
102
+
103
+ ```sh
104
+ dsh plugin --profile web add dsh-url-router
105
+ # 重启 dsh web,之后硬刷新浏览器
106
+ ```
107
+
108
+ ## 已知限制
109
+
110
+ - 依赖官方 `ctx.layout.selectPanel` / `panelInfo` 与会话、工作区控制器;形态变了就自我禁用(一条诊断),不改官方行为。
111
+ - **设置页分区暂未纳入**(设置包没有"打开某分区"的接缝;它们是 keyed list slot)。
112
+ - 右栏 pane 能打开(`ISidebarRight.openTab(kind)`),但尚未纳入路由。
@@ -0,0 +1,23 @@
1
+ # dsh-url-router bundle patch: inserts the plugin row into the profile roster.
2
+ #
3
+ # The browser half (exports "./client") owns the URL fragment: it mirrors the view
4
+ # on screen into the address bar (`#/session/<id>`, `#/panel/<panelId>`) and opens
5
+ # the surface a link names. There is no host behavior: no route, no tool, no service.
6
+ #
7
+ # Because this plugin is the ONLY writer of the fragment, do not run it alongside
8
+ # another plugin that writes the fragment (for example dsh-session-url): the two
9
+ # would overwrite each other's URL on every notification.
10
+ #
11
+ # The entry name is the BARE package name on purpose: the client-module scanner
12
+ # treats it as a package specifier and resolves `<name>/package.json`, so a
13
+ # subpath entry would be cached as "not a client package" and the browser half
14
+ # would silently never load.
15
+ #
16
+ # A bundle-row change is a profile composition change: enabling, disabling or
17
+ # updating this plugin takes effect on the next page load for the browser half,
18
+ # and after a host restart for the row itself (unless the profile sets
19
+ # `patchReload: live`, in which case the row hot-loads and a page refresh is
20
+ # enough).
21
+ - insert:
22
+ - id: dsh-url-router
23
+ name: 'dsh-url-router'
package/lib/client.js ADDED
@@ -0,0 +1,1026 @@
1
+ /**
2
+ * dsh-url-router · browser half.
3
+ *
4
+ * One URL for every surface: the address bar names what is on screen, and a link
5
+ * opens the surface it names. This plugin is the **single owner of the URL
6
+ * fragment** — other plugins register routes with it instead of writing the
7
+ * address bar themselves, so two plugins can never fight over the URL.
8
+ *
9
+ * Surfaces it owns today (L1, "which view is shown"):
10
+ * - `#/session/<id>` — the conversation the main view shows
11
+ * - `#/panel/<panelId>` — a main-column panel (plugins, skills, SSH, task board, …)
12
+ *
13
+ * Why a router rather than one plugin per surface: the official GUI has no URL
14
+ * concept of its own (no route, no `?session=`, no `#/session/…` anywhere in the
15
+ * official client bundles), yet several community plugins write the address bar.
16
+ * Whoever writes it must be one, or the links fight.
17
+ *
18
+ * Scopes are declared by the view, not guessed: a main panel is an
19
+ * application-level surface — it lists this machine's plugins, not a conversation
20
+ * — so its link carries no session id. Only session-scoped views name a session.
21
+ *
22
+ * Facts this router relies on, read from the official client packages:
23
+ * - `ctx.layout.selectPanel(panelId | null)` selects the active main panel.
24
+ * - `ctx.layout.panelInfo` is a store: `getSnapshot().activePanelId` + `subscribe()`.
25
+ * - `ctx.uiWorkspace.openSession(id)` navigates; `ctx.sessions.list` lists.
26
+ * `ctx.layout` is read through `ctx.get('layout')` (the layout package may be
27
+ * absent from a composition); the three session faces are hard dependencies.
28
+ *
29
+ * Failure policy: unknown route kinds and unreachable targets are ignored with ONE
30
+ * diagnostic each — never an exception, never a blank screen. A refused target
31
+ * falls back to the view the address bar itself would have described.
32
+ *
33
+ * @module dsh-url-router
34
+ */
35
+
36
+ window.__ModuleLoader__.load({
37
+ id: 'dsh-url-router',
38
+ factory: () => {
39
+ const module = { exports: {} }
40
+ const exports = module.exports
41
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
42
+
43
+ // ---------------------------------------------------------------------
44
+ // main-view session: resolve the Session the main view currently shows.
45
+ // Reads the catalog's rows rather than a per-id retain-info source, so this
46
+ // neither allocates observers nor opens history, and a subscription to the
47
+ // list still fires when the selection moves.
48
+ // ---------------------------------------------------------------------
49
+ function mainViewSessionId(byId) {
50
+ if (byId === undefined || byId === null) return undefined
51
+ for (const row of Object.values(byId)) {
52
+ if (row !== undefined && (row.retainedBy?.mainView ?? 0) > 0) return row.id
53
+ }
54
+ return undefined
55
+ }
56
+
57
+ // ---------------------------------------------------------------------
58
+ // route codec: the single mapping between a location fragment and a surface.
59
+ //
60
+ // Canonical form is `#/<kind>/<encoded id>`. A plural kind is accepted on
61
+ // input (a retyped or hand-written link) and normalised to the canonical
62
+ // singular. Unknown kinds are reported so the caller stays silent about them:
63
+ // another router's route may legitimately be pasted in.
64
+ // ---------------------------------------------------------------------
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-]*)\/(.+)$/
69
+ /** How often an unsatisfied link is retried while it is authoritative. */
70
+ const LINK_RETRY_MS = 500
71
+ /** How many times an inner page may be requested while its link is authoritative. */
72
+ const INNER_ATTEMPT_LIMIT = 40
73
+ /** How long a linked surface stays authoritative over the view owner's own restore. */
74
+ const LINK_ENFORCE_WINDOW_MS = 8000
75
+ /** Panel id of the task board, whose open-task contract this router drives. */
76
+ const TASK_BOARD_PANEL_ID = 'task-board'
77
+ /** Attribute the task board publishes the open task on. */
78
+ const TASK_BOARD_OPEN_ATTRIBUTE = 'data-dsh-taskboard-open-task'
79
+ /** Window event that asks the task board to open a task. */
80
+ const TASK_BOARD_OPEN_EVENT = 'dsh-taskboard-open-task'
81
+ /** Attribute the task board publishes while its new-task form is open. */
82
+ const TASK_BOARD_NEW_ATTRIBUTE = 'data-dsh-taskboard-new-task'
83
+ /** Window event that asks the task board for its new-task form. */
84
+ const TASK_BOARD_NEW_EVENT = 'dsh-taskboard-new-task'
85
+ /** Panel id of the official plugin manager, whose list rows and detail pages are routed. */
86
+ const PLUGINS_PANEL_ID = 'plugins'
87
+ /** Log prefix for every diagnostic. */
88
+ const TAG = '[url-router]'
89
+
90
+ /**
91
+ * Read a route out of a location fragment.
92
+ * @param {string} hash - the raw `window.location.hash`, including the leading `#`.
93
+ * @returns {{kind: string, id: string}|{unknown: string}|undefined} the route, an unknown-kind report, or undefined for "no route here".
94
+ */
95
+ function decodePart(raw) {
96
+ try {
97
+ return decodeURIComponent(raw)
98
+ } catch {
99
+ return raw
100
+ }
101
+ }
102
+
103
+ function parseRoute(hash) {
104
+ const match = ROUTE_PATTERN.exec(hash)
105
+ if (match === null) return undefined
106
+ const segments = match[2].replace(/\/+$/, '').split('/')
107
+ const head = segments.shift() ?? ''
108
+ if (head === '') return undefined
109
+ 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] }
113
+ // A panel may have an inner page (a plugin's detail view, for instance): the
114
+ // rest of the path names it, per panel.
115
+ const inner = segments.map(decodePart).filter(segment => segment.trim() !== '')
116
+ return { kind, id, inner: inner.length === 0 ? undefined : inner }
117
+ }
118
+
119
+ /**
120
+ * Build the canonical fragment for a route.
121
+ * @param {string} kind - route kind.
122
+ * @param {string} id - route identity.
123
+ * @returns {string} the `#/<kind>/<encoded id>` fragment.
124
+ */
125
+ function routeHash(kind, id, inner) {
126
+ const base = `#/${kind}/${encodeURIComponent(id)}`
127
+ if (inner === undefined || inner === '') return base
128
+ const segments = innerPath(inner)
129
+ return segments.length === 0 ? base : `${base}/${segments.map(encodeURIComponent).join('/')}`
130
+ }
131
+
132
+ /**
133
+ * Normalise an inner path into its segments.
134
+ *
135
+ * A panel's inner page may nest (`<package>/<component>`), and each segment is
136
+ * encoded on its own, so a raw `/` in the path is always a separator while a `/`
137
+ * inside a name survives as `%2F`.
138
+ * @param {string|Array<string>} value - the inner path or its segments.
139
+ * @returns {Array<string>} the segments, empty when there is no inner page.
140
+ */
141
+ /**
142
+ * Split a row hook value into the package it belongs to and the row id.
143
+ *
144
+ * Two spellings exist in the panel's DOM: a page-level `<package>#<rowId>` and a
145
+ * row-level `<kind>:<rowId>`. Only the row id is routed; the package is used when
146
+ * the page reports one.
147
+ * @param {unknown} value - the raw attribute value.
148
+ * @returns {{packageName: string|undefined, rowId: string}|undefined} the parts, or undefined when there is nothing usable.
149
+ */
150
+ function splitRowHook(value) {
151
+ if (typeof value !== 'string' || value.trim() === '') return undefined
152
+ const hash = value.indexOf('#')
153
+ if (hash !== -1) {
154
+ const rowId = value.slice(hash + 1).trim()
155
+ if (rowId === '') return undefined
156
+ const packageName = value.slice(0, hash).trim()
157
+ return { packageName: packageName === '' ? undefined : packageName, rowId }
158
+ }
159
+ const colon = value.lastIndexOf(':')
160
+ const rowId = (colon === -1 ? value : value.slice(colon + 1)).trim()
161
+ return rowId === '' ? undefined : { packageName: undefined, rowId }
162
+ }
163
+
164
+ function innerPath(value) {
165
+ const parts = Array.isArray(value) ? value : String(value ?? '').split('/')
166
+ return parts.map(part => String(part).trim()).filter(part => part !== '')
167
+ }
168
+
169
+ /**
170
+ * Build the address-bar URL for one view state, preserving path and query.
171
+ * @param {string} pathname - current `location.pathname` (the mount path is preserved).
172
+ * @param {string} search - current `location.search` (a launch token, when present).
173
+ * @param {{kind: string, id: string}|undefined} route - the view to name, or undefined for "no view named".
174
+ * @returns {string} the URL to write.
175
+ */
176
+ function routeUrl(pathname, search, route) {
177
+ const base = `${pathname}${search}`
178
+ return route === undefined ? base : `${base}${routeHash(route.kind, route.id, route.inner)}`
179
+ }
180
+
181
+ /**
182
+ * Read one data attribute anywhere in the document.
183
+ * @param {string} selector - the attribute selector to find the owner by.
184
+ * @param {string} name - the attribute to read.
185
+ * @returns {string|undefined} the value, when it is a non-empty string.
186
+ */
187
+ function attributeOf(selector, name) {
188
+ const node = window.document?.querySelector?.(selector)
189
+ const value = typeof node?.getAttribute === 'function' ? node.getAttribute(name) : null
190
+ return typeof value === 'string' && value !== '' ? value : undefined
191
+ }
192
+
193
+ /** Serialise a route for cheap comparison. */
194
+ const routeKey = route =>
195
+ route === undefined ? 'none' : `${route.kind}:${route.id}:${innerPath(route.inner).map(part => encodeURIComponent(part)).join('/')}`
196
+
197
+ // ---------------------------------------------------------------------
198
+ // the router: two directions, one writer.
199
+ //
200
+ // - Fragment to view: a link names a surface, so loading (or pasting)
201
+ // `#/panel/plugins` selects that panel and `#/session/<id>` opens that
202
+ // conversation.
203
+ // - View to fragment: the address bar always names what is on screen — a
204
+ // panel when one is selected, otherwise the conversation the main view
205
+ // shows.
206
+ //
207
+ // A link needs an enforcement window because both owners restore their own
208
+ // remembered state while loading, which can land after the link was read.
209
+ // Within the window the link wins; after it the view owns the URL again, so a
210
+ // click during boot is never fought for longer than a boot.
211
+ //
212
+ // The state machine is host-injected (panel store, catalog, navigation,
213
+ // location, clock, timer) and every notification carries its source, so it is
214
+ // deterministic in tests and a claim can be retried on the notification that
215
+ // actually matters (a panel registering late) without retrying on unrelated
216
+ // catalog churn.
217
+ // ---------------------------------------------------------------------
218
+
219
+ /**
220
+ * Start routing the fragment.
221
+ * @param {object} host - the injected faces.
222
+ * @returns {() => void} the disposer removing every subscription and timer.
223
+ */
224
+ function startUrlRouting(host) {
225
+ /** The route the address bar claims, with its enforcement deadline. */
226
+ let linked
227
+ /** Whether the claimed route was already handed to its view owner. */
228
+ let claimed = false
229
+ /** The package segment of the deepest panel page the address bar carried. */
230
+ let panelScope
231
+ /** How many times the linked panel was selected (bounded, see below). */
232
+ let panelAttempts = 0
233
+ /** How many times a panel link's inner page was requested (bounded, see below). */
234
+ let innerAttempts = 0
235
+ /** The view state observed on the previous notification. */
236
+ let seen = 'none'
237
+ let cancelTimer
238
+ let retryTimer
239
+ let disposed = false
240
+
241
+ const dropTimer = () => {
242
+ cancelTimer?.()
243
+ cancelTimer = undefined
244
+ }
245
+ const dropLink = () => {
246
+ dropRetry()
247
+ linked = undefined
248
+ claimed = false
249
+ panelAttempts = 0
250
+ innerAttempts = 0
251
+ dropTimer()
252
+ }
253
+ /**
254
+ * The route the current view deserves: a selected panel outranks the
255
+ * conversation behind it, because the panel is what fills the screen.
256
+ */
257
+ const canonical = () => {
258
+ const panelId = host.activePanelId()
259
+ if (panelId !== undefined && panelId !== null) {
260
+ const id = String(panelId)
261
+ const page = host.panelPage(id, panelScope)
262
+ if (page === undefined) return { kind: 'panel', id, inner: undefined }
263
+ const segments = innerPath(page)
264
+ // Remember the package a page was opened under, so a bare component page (which
265
+ // reports only its row) can still be named fully.
266
+ if (id === PLUGINS_PANEL_ID && segments.length === 1) panelScope = segments[0]
267
+ return { kind: 'panel', id, inner: segments }
268
+ }
269
+ const session = host.mainViewSessionId()
270
+ if (session !== undefined && session !== null) return { kind: 'session', id: String(session) }
271
+ return undefined
272
+ }
273
+ const sameRoute = (left, right) => routeKey(left) === routeKey(right)
274
+ /** Write the fragment for the current view, leaving path and query untouched. */
275
+ const reflect = route => {
276
+ const { pathname, search, hash } = host.readLocation()
277
+ // Compare against the fragment, not the whole URL: the hash never carries the
278
+ // path or the launch query, and comparing them made every call look like a change.
279
+ const wantedHash = route === undefined ? '' : routeHash(route.kind, route.id, route.inner)
280
+ if (hash === wantedHash) return
281
+ // Nothing to name and nothing named: leave the address bar exactly as it is
282
+ // (this also leaves a foreign fragment alone, which the caller never reaches).
283
+ if (route === undefined && hash === '') return
284
+ host.replaceUrl(routeUrl(pathname, search, route))
285
+ }
286
+ /**
287
+ * Re-read the view state and move towards the state the address bar describes.
288
+ * @param {string} source - what notified: `boot`, `panel`, `catalog`, `hash`, or `timer`.
289
+ */
290
+ const reconcile = (source = 'boot') => {
291
+ if (disposed) return
292
+ const current = canonical()
293
+ const currentKey = routeKey(current)
294
+ const moved = currentKey !== seen
295
+ seen = currentKey
296
+ if (linked === undefined) {
297
+ reflect(current)
298
+ return
299
+ }
300
+ if (sameRoute(current, linked)) {
301
+ dropRetry()
302
+ // The link has landed: it stops being authoritative right here, so the user
303
+ // keeps owning the address bar afterwards (walking deeper inside a panel
304
+ // updates the URL instead of being pulled back to the link).
305
+ dropLink()
306
+ reflect(current)
307
+ return
308
+ }
309
+ if (host.now() >= linked.deadline) {
310
+ const hash = routeHash(linked.kind, linked.id, linked.inner)
311
+ host.warn(`${TAG} ${hash} did not become the shown view within ${LINK_ENFORCE_WINDOW_MS} ms; the link was ignored`)
312
+ dropLink()
313
+ reflect(canonical())
314
+ return
315
+ }
316
+ if (linked.kind === 'panel') {
317
+ // Panels are selected by id and report back through the panel store; an id
318
+ // nobody registered never becomes active, so the claim is retried when the
319
+ // panel store moves (a late-loading plugin), not on every catalog tick.
320
+ // `current` may be undefined when nothing is on screen yet; reading through it
321
+ // unconditionally would abort the whole step inside the guard.
322
+ const panelShown = current !== undefined && current.kind === 'panel' && current.id === linked.id
323
+ if (!panelShown) {
324
+ // Selecting a panel can be refused or swallowed while the shell is still
325
+ // settling, so the request is retried (on later notifications and on a
326
+ // timer) with a bound.
327
+ if (source !== 'hash' && panelAttempts < INNER_ATTEMPT_LIMIT) {
328
+ panelAttempts += 1
329
+ // A panel switch is a navigation of its own: abort whatever navigation is
330
+ // in flight first (the layout exposes exactly that pair), otherwise the
331
+ // selection can be swallowed by the boot restore.
332
+ host.beginNavigation()
333
+ host.selectPanel(linked.id)
334
+ }
335
+ armRetry()
336
+ return
337
+ }
338
+ // The panel is on screen; if the link names an inner page, drive it and let
339
+ // the DOM observer report back (the panel owns how its pages open).
340
+ // An inner page is driven through the panel's own control, which only exists
341
+ // once that panel has rendered: the request is retried on every later
342
+ // notification (not on the hash read itself, to avoid a double request),
343
+ // with a bound so an unopenable page cannot turn into a click loop.
344
+ if (linked.inner !== undefined && routeKey(current) !== routeKey(linked) && source !== 'hash' && innerAttempts < INNER_ATTEMPT_LIMIT) {
345
+ innerAttempts += 1
346
+ host.openPanelPage(linked.id, linked.inner)
347
+ }
348
+ armRetry()
349
+ return
350
+ }
351
+ // A session link: refuse immediately when the conversation cannot be opened at
352
+ // all, otherwise hand the navigation over once and wait for the view to land.
353
+ if (host.isArchived(linked.id)) {
354
+ host.warn(`${TAG} ${routeHash(linked.kind, linked.id)} is archived; the link was ignored`)
355
+ dropLink()
356
+ reflect(canonical())
357
+ return
358
+ }
359
+ if ((!claimed || moved) && host.isOpenable(linked.id)) {
360
+ claimed = true
361
+ host.openSession(linked.id)
362
+ }
363
+ }
364
+ /** Adopt the surface the address bar names, if it names one. */
365
+ const readLink = () => {
366
+ if (disposed) return
367
+ const parsed = parseRoute(host.readLocation().hash)
368
+ if (parsed === undefined) {
369
+ dropLink()
370
+ reconcile('hash')
371
+ return
372
+ }
373
+ if (parsed.unknown !== undefined) {
374
+ // Another router's route (or a typo): leave the address bar alone and say so
375
+ // once, so a router owning more kinds can take over later.
376
+ if (linked === undefined) host.warn(`${TAG} ignoring unknown route kind "${parsed.unknown}"`)
377
+ return
378
+ }
379
+ if (linked === undefined || linked.kind !== parsed.kind || linked.id !== parsed.id) {
380
+ linked = { kind: parsed.kind, id: parsed.id, inner: parsed.inner, deadline: host.now() + LINK_ENFORCE_WINDOW_MS }
381
+ if (parsed.kind === 'panel' && parsed.inner !== undefined) panelScope = innerPath(parsed.inner)[0]
382
+ claimed = false
383
+ panelAttempts = 0
384
+ innerAttempts = 0
385
+ seen = 'none'
386
+ }
387
+ dropTimer()
388
+ cancelTimer = host.setTimer(() => {
389
+ cancelTimer = undefined
390
+ // Fires from the browser, outside any notification: guard it too.
391
+ guard(() => reconcile('timer'))
392
+ }, LINK_ENFORCE_WINDOW_MS)
393
+ reconcile('boot')
394
+ }
395
+
396
+ /** Whether an internal error was already reported (one diagnostic per instance). */
397
+ let crashed = false
398
+ /**
399
+ * Run a machine step so that nothing it meets can escape into the service that
400
+ * notified us: a broken face or an unexpected value must not take the page down.
401
+ */
402
+ const guard = step => {
403
+ try {
404
+ step()
405
+ } catch (error) {
406
+ if (crashed) return
407
+ crashed = true
408
+ host.warn(`${TAG} internal error contained: ${error?.message ?? String(error)}`)
409
+ }
410
+ }
411
+ const safeReconcile = source => guard(() => reconcile(source))
412
+ /**
413
+ * Keep retrying an unsatisfied link on a timer.
414
+ *
415
+ * Notifications alone are not enough: after boot the shell can go quiet, so a claim
416
+ * that was refused once (a panel registered a moment later, a control not rendered
417
+ * yet) would never be retried and the link would sit there doing nothing. The timer
418
+ * stops as soon as the link lands, is dropped, or its window expires.
419
+ */
420
+ const armRetry = () => {
421
+ if (disposed || linked === undefined || retryTimer !== undefined) return
422
+ retryTimer = host.setTimer(() => {
423
+ retryTimer = undefined
424
+ safeReconcile('retry')
425
+ }, LINK_RETRY_MS)
426
+ }
427
+ const dropRetry = () => {
428
+ retryTimer?.()
429
+ retryTimer = undefined
430
+ }
431
+ const safeReadLink = () => guard(readLink)
432
+ const offPanel = host.onPanelChange(() => safeReconcile('panel'))
433
+ const offCatalog = host.onCatalogChange(() => safeReconcile('catalog'))
434
+ const offDom = host.onDomChange(() => safeReconcile('dom'))
435
+ const offHashChange = host.onHashChange(() => safeReadLink())
436
+ safeReadLink()
437
+ return () => {
438
+ disposed = true
439
+ dropTimer()
440
+ dropRetry()
441
+ offPanel()
442
+ offCatalog()
443
+ offDom()
444
+ offHashChange()
445
+ }
446
+ }
447
+
448
+ // ---------------------------------------------------------------------
449
+ // row links: every conversation row in the sidebar becomes a real anchor.
450
+ //
451
+ // A real <a href="#/session/<id>" target="_blank"> is what makes the link a
452
+ // first-class thing instead of an address-bar convention: left click and
453
+ // middle click open the conversation in a new tab, and the browser's own
454
+ // context menu offers "open link in new tab", "copy link", "save link as" —
455
+ // no custom menu to maintain.
456
+ //
457
+ // The row's identity rides its `data-row-key="session:<id>"` attribute (the
458
+ // official sidebar list is virtualized, so rows come and go; a
459
+ // MutationObserver re-scans). Anything unexpected — no document, no rows, no
460
+ // observer — degrades to doing nothing at all.
461
+ // ---------------------------------------------------------------------
462
+ /** Row attribute carrying the conversation identity. */
463
+ const ROW_KEY_ATTRIBUTE = 'data-row-key'
464
+ /** Prefix of that attribute's value for a conversation row. */
465
+ const ROW_KEY_PREFIX = 'session:'
466
+ /** Rows this plugin looks at. */
467
+ const ROW_SELECTOR = `[${ROW_KEY_ATTRIBUTE}^="${ROW_KEY_PREFIX}"]`
468
+ /** Marks the anchor this plugin owns inside a row. */
469
+ const ANCHOR_PART = 'url-router-link'
470
+ /** Id of the stylesheet this plugin injects. */
471
+ const STYLE_ID = 'dsh-url-router-style'
472
+ /** Marks a row whose positioning context this plugin had to establish. */
473
+ const HOST_ATTRIBUTE = 'data-dsh-url-router-host'
474
+ /** Tooltip naming both the click and the right-click behaviour. */
475
+ const LINK_TITLE = '在新标签页中打开 · 右键可复制链接 / Open in a new tab · right-click to copy the link'
476
+ /** One stylesheet for every anchor; the icon stays invisible until the row is hovered. */
477
+ const STYLE_TEXT = [
478
+ // The anchor covers the whole row, so a right-click anywhere on the row
479
+ // reaches the browser's link menu exactly like any other link. It is
480
+ // transparent, sits under everything (z-index 0), and the row's own controls
481
+ // are lifted above it so their clicks keep working.
482
+ // z-index 3 keeps the overlay above the row's own hover mask; the row's
483
+ // controls are lifted to 4. The :not() is load-bearing: without it this
484
+ // rule also matches THE OVERLAY ITSELF (it is an <a>), turns it into a
485
+ // zero-size relative element, and the right-click never reaches it.
486
+ '.dsh-url-router-link{position:absolute;inset:0;z-index:3;display:block;border-radius:inherit;cursor:pointer;text-decoration:none}',
487
+ `[${HOST_ATTRIBUTE}] :is(button,[role="button"],a,select,input,textarea):not(.dsh-url-router-link){position:relative;z-index:4}`,
488
+ ].join('')
489
+
490
+ /**
491
+ * Read the conversation identity out of one row.
492
+ * @param {object} row - a candidate row element.
493
+ * @returns {string|undefined} the session id, or undefined when the row names none.
494
+ */
495
+ function sessionIdOfRow(row) {
496
+ if (row === null || row === undefined || typeof row.getAttribute !== 'function') return undefined
497
+ const key = row.getAttribute(ROW_KEY_ATTRIBUTE)
498
+ if (typeof key !== 'string' || !key.startsWith(ROW_KEY_PREFIX)) return undefined
499
+ const id = key.slice(ROW_KEY_PREFIX.length).trim()
500
+ return id === '' ? undefined : id
501
+ }
502
+
503
+ /**
504
+ * Build the row-wide anchor for one conversation.
505
+ *
506
+ * It covers the row, so a right-click anywhere reaches the browser's link menu
507
+ * (open in new tab / copy link / save link as) and a middle or modified click
508
+ * opens a new tab natively. A plain left click opens the conversation HERE, by
509
+ * calling the official navigation with the same id the row would use: the
510
+ * address bar therefore keeps being written with replaceState instead of
511
+ * gaining a history entry per click, and the behaviour does not depend on the
512
+ * row's own hit testing.
513
+ * @param {object} document - the document to create the node in.
514
+ * @param {string} id - session identity the link names.
515
+ * @returns {object} the anchor node.
516
+ */
517
+ function rowAnchor(document, href, open) {
518
+ const anchor = document.createElement('a')
519
+ anchor.setAttribute('href', href)
520
+ anchor.setAttribute('target', '_blank')
521
+ anchor.setAttribute('rel', 'noreferrer')
522
+ anchor.setAttribute('title', LINK_TITLE)
523
+ anchor.setAttribute('aria-label', LINK_TITLE)
524
+ anchor.setAttribute('data-dsh-plugin', 'dsh-url-router')
525
+ anchor.setAttribute('data-dsh-part', ANCHOR_PART)
526
+ anchor.className = 'dsh-url-router-link'
527
+ anchor.addEventListener('click', event => {
528
+ if (event === null || event.defaultPrevented === true) return
529
+ const plain = (event.button === undefined || event.button === 0)
530
+ && event.metaKey !== true && event.ctrlKey !== true
531
+ && event.shiftKey !== true && event.altKey !== true
532
+ if (!plain) return
533
+ if (typeof event.preventDefault === 'function') event.preventDefault()
534
+ if (typeof event.stopPropagation === 'function') event.stopPropagation()
535
+ try {
536
+ open()
537
+ } catch {
538
+ // Navigation belongs to the view owner: if it refuses, the link has
539
+ // still done nothing harmful to the shell.
540
+ }
541
+ })
542
+ return anchor
543
+ }
544
+
545
+ /**
546
+ * Give one row its anchor, if it needs one.
547
+ * @param {object} document - the document the row lives in.
548
+ * @param {object} row - candidate row element.
549
+ * @returns {boolean} whether the row now carries an anchor.
550
+ */
551
+ function mountAnchor(document, row, href, open) {
552
+ if (typeof row.querySelector !== 'function' || typeof row.appendChild !== 'function') return false
553
+ if (row.querySelector(`[data-dsh-part="${ANCHOR_PART}"]`) !== null) return true
554
+ // The anchor is absolutely positioned, so the row must be a positioning
555
+ // context; only take that over when the row does not already establish one,
556
+ // and remember which case it was so disposal only undoes our own change.
557
+ const style = typeof window.getComputedStyle === 'function' ? window.getComputedStyle(row) : undefined
558
+ const owns = style === undefined || style === null || style.position === 'static'
559
+ if (owns && row.style !== undefined) row.style.position = 'relative'
560
+ if (typeof row.setAttribute === 'function') row.setAttribute(HOST_ATTRIBUTE, owns ? 'own' : 'shared')
561
+ const anchor = rowAnchor(document, href, open)
562
+ // Prepended so the row's own controls — lifted by the stylesheet — stay in
563
+ // front of the overlay; appended, the overlay would swallow them.
564
+ if (typeof row.insertBefore === 'function') row.insertBefore(anchor, row.firstChild ?? null)
565
+ else row.appendChild(anchor)
566
+ return true
567
+ }
568
+
569
+ /** Slot the sidebar renders its main-panel rows from. */
570
+ const PANEL_LIST_SLOT = 'sidebar.panellist'
571
+
572
+ /**
573
+ * Read the registered main panels in the order the sidebar renders them.
574
+ *
575
+ * The registry is the only source of a panel's identity: the row itself carries
576
+ * its label and nothing else. A label may be a function (resolved per locale by
577
+ * the shell), so it is only used for matching when it is a plain string; rows are
578
+ * aligned by `order` otherwise.
579
+ * @param {object} slots - the client slots service, when the composition has one.
580
+ * @returns {Array<{id: string, order: number, label: string|undefined}>} registry rows.
581
+ */
582
+ function readPanels(slots) {
583
+ if (typeof slots?.entries !== 'function') return []
584
+ let stored
585
+ try {
586
+ stored = slots.entries(PANEL_LIST_SLOT)
587
+ } catch {
588
+ return []
589
+ }
590
+ if (!Array.isArray(stored)) return []
591
+ return stored
592
+ .map((entry, index) => {
593
+ const options = entry?.options ?? {}
594
+ const id = typeof options.id === 'string' ? options.id : undefined
595
+ const order = typeof options.order === 'number' ? options.order : index
596
+ const label = typeof options.label === 'string' ? options.label : undefined
597
+ return { id, order, label, index }
598
+ })
599
+ .filter(entry => entry.id !== undefined && entry.id !== '')
600
+ .sort((left, right) => left.order - right.order || left.index - right.index)
601
+ }
602
+
603
+ /**
604
+ * The main-panel rows the official sidebar renders.
605
+ *
606
+ * Located structurally (`data-slot`) rather than by a localized accessible name,
607
+ * so the same hook works in every language.
608
+ * @param {object} document - the document to scan.
609
+ * @returns {Array} the panel row elements, in render order.
610
+ */
611
+ function panelRows(document) {
612
+ const rows = []
613
+ try {
614
+ for (const button of document.querySelectorAll('button')) {
615
+ if (typeof button.querySelector === 'function' && button.querySelector(`[data-slot="${PANEL_LIST_SLOT}"]`) !== null) rows.push(button)
616
+ }
617
+ } catch {
618
+ return []
619
+ }
620
+ return rows
621
+ }
622
+
623
+ /**
624
+ * Give one conversation row its link to the conversation route.
625
+ * @param {object} document - the document the row lives in.
626
+ * @param {object} row - candidate row element.
627
+ * @param {Function} openSession - navigation for a plain left click.
628
+ * @returns {boolean} whether the row now carries an anchor.
629
+ */
630
+ function enhanceRow(document, row, openSession) {
631
+ const id = sessionIdOfRow(row)
632
+ if (id === undefined) return false
633
+ return mountAnchor(document, row, routeHash('session', id), () => openSession(id))
634
+ }
635
+
636
+ /**
637
+ * Give one main-panel row its link to the panel route.
638
+ *
639
+ * A panel row carries no identity in the DOM, so the id has to come from the
640
+ * registry the sidebar renders from; rows are matched by accessible name when
641
+ * the registered label is a plain string, and by position otherwise. A row that
642
+ * cannot be matched is left exactly as it was.
643
+ * @param {object} document - the document the row lives in.
644
+ * @param {object} row - candidate row element.
645
+ * @param {Array} panels - the registry rows, in render order.
646
+ * @param {Function} selectPanel - panel selection for a plain left click.
647
+ * @returns {boolean} whether the row now carries an anchor.
648
+ */
649
+ function enhancePanelRow(document, row, panels, index, useIndex, selectPanel) {
650
+ const name = typeof row.getAttribute === 'function' ? row.getAttribute('aria-label') : undefined
651
+ let target
652
+ if (typeof name === 'string' && name !== '') target = panels.find(entry => entry.label === name)
653
+ if (target === undefined && useIndex) target = panels[index]
654
+ if (target === undefined) return false
655
+ return mountAnchor(document, row, routeHash('panel', target.id), () => selectPanel(target.id))
656
+ }
657
+
658
+ /**
659
+ * Turn every conversation row into a link, and keep doing so as the list
660
+ * virtualizes.
661
+ * @param {object} document - the document to enhance (may be absent).
662
+ * @returns {() => void} the disposer removing every anchor, the stylesheet and the observer.
663
+ */
664
+ function startRowLinks(document, openSession, slots, selectPanel) {
665
+ if (document === undefined || document === null) return () => {}
666
+ if (typeof document.createElement !== 'function' || typeof document.querySelectorAll !== 'function') return () => {}
667
+ const scan = () => {
668
+ let linked = 0
669
+ try {
670
+ for (const row of document.querySelectorAll(ROW_SELECTOR)) if (enhanceRow(document, row, openSession)) linked += 1
671
+ } catch {
672
+ // A DOM that cannot be scanned is left untouched: the address-bar sync
673
+ // is the plugin's contract, the row links are an addition to it.
674
+ }
675
+ try {
676
+ for (const row of document.querySelectorAll('li[data-plugin-item]')) {
677
+ const id = typeof row.getAttribute === 'function' ? row.getAttribute('data-plugin-item') : null
678
+ if (typeof id !== 'string' || id === '') continue
679
+ const open = () => {
680
+ selectPanel(PLUGINS_PANEL_ID)
681
+ const button = typeof row.querySelector === 'function' ? row.querySelector('button') : null
682
+ if (typeof button?.click === 'function') button.click()
683
+ }
684
+ if (mountAnchor(document, row, routeHash('panel', PLUGINS_PANEL_ID, id), open)) linked += 1
685
+ }
686
+ } catch {
687
+ // Same policy as every other row pass.
688
+ }
689
+ try {
690
+ for (const row of document.querySelectorAll(`li[data-plugin-package]`)) {
691
+ const name = typeof row.getAttribute === 'function' ? row.getAttribute('data-plugin-package') : null
692
+ if (typeof name !== 'string' || name === '') continue
693
+ const open = () => {
694
+ // Select the owning panel first, then let its own control open the page:
695
+ // the panel stays the only thing that knows how a page opens.
696
+ selectPanel(PLUGINS_PANEL_ID)
697
+ const button = typeof row.querySelector === 'function' ? row.querySelector('button') : null
698
+ if (typeof button?.click === 'function') button.click()
699
+ }
700
+ if (mountAnchor(document, row, routeHash('panel', PLUGINS_PANEL_ID, name), open)) linked += 1
701
+ }
702
+ } catch {
703
+ // Same policy: a list row that cannot be read stays as it was.
704
+ }
705
+ try {
706
+ const panels = readPanels(slots)
707
+ const rows = panelRows(document)
708
+ // Index alignment is only trustworthy when every registered panel has a
709
+ // row; otherwise a row is linked only when its label matches exactly.
710
+ const useIndex = panels.length > 0 && panels.length === rows.length
711
+ for (let index = 0; index < rows.length; index += 1) {
712
+ if (enhancePanelRow(document, rows[index], panels, index, useIndex, selectPanel)) linked += 1
713
+ }
714
+ } catch {
715
+ // Same policy for the panel list: an unmatched row stays as it was.
716
+ }
717
+ return linked
718
+ }
719
+ let style
720
+ try {
721
+ style = document.createElement('style')
722
+ style.id = STYLE_ID
723
+ style.textContent = STYLE_TEXT
724
+ const parent = document.head ?? document.body
725
+ if (parent !== undefined && parent !== null && typeof parent.appendChild === 'function') parent.appendChild(style)
726
+ } catch {
727
+ style = undefined
728
+ }
729
+ let observer
730
+ try {
731
+ const root = document.body ?? document.documentElement
732
+ if (root !== undefined && root !== null && typeof window.MutationObserver === 'function') {
733
+ observer = new window.MutationObserver(scan)
734
+ observer.observe(root, { childList: true, subtree: true })
735
+ }
736
+ } catch {
737
+ observer = undefined
738
+ }
739
+ const linked = scan()
740
+ if (linked === undefined) return () => {}
741
+ if (typeof console.info === 'function') console.info(`${TAG} row links: every conversation row is a real link (right-click menu, middle/Cmd-click new tab)`)
742
+ return () => {
743
+ try {
744
+ observer?.disconnect()
745
+ style?.remove()
746
+ for (const row of [...document.querySelectorAll(ROW_SELECTOR), ...panelRows(document)]) {
747
+ row.querySelector(`[data-dsh-part="${ANCHOR_PART}"]`)?.remove()
748
+ const host = typeof row.getAttribute === 'function' ? row.getAttribute(HOST_ATTRIBUTE) : null
749
+ if (host !== null && typeof row.removeAttribute === 'function') {
750
+ row.removeAttribute(HOST_ATTRIBUTE)
751
+ // Only undo the positioning context this plugin established itself.
752
+ if (host === 'own' && row.style !== undefined) row.style.position = ''
753
+ }
754
+ }
755
+ } catch {
756
+ // Disposal is best effort; nothing here may break the shell.
757
+ }
758
+ }
759
+ }
760
+ // ---------------------------------------------------------------------
761
+ // browser wiring: probe the official faces, then drive the machine.
762
+ // ---------------------------------------------------------------------
763
+ /** Services this plugin needs before apply runs; `layout` is optional and read through `ctx.get`. */
764
+ const inject = ['sessions', 'workspaces', 'uiWorkspace', 'slots']
765
+
766
+ /**
767
+ * Probe the faces the router needs.
768
+ *
769
+ * A changed surface returns null so the plugin leaves the official behavior
770
+ * untouched. The layout face is optional: without it the plugin still routes
771
+ * conversations and simply never claims a panel.
772
+ * @param {object} scope - client context.
773
+ * @returns {{faces: object}|null} the probed faces, or null when the session faces are unusable.
774
+ */
775
+ function probeFaces(scope) {
776
+ const list = scope.sessions?.list
777
+ const registry = scope.workspaces?.list
778
+ const navigation = scope.uiWorkspace
779
+ if (typeof list !== 'object' || list === null) return null
780
+ if (typeof list.getSnapshot !== 'function' || typeof list.subscribe !== 'function') return null
781
+ if (typeof registry !== 'object' || registry === null) return null
782
+ if (typeof registry.getSnapshot !== 'function') return null
783
+ if (typeof navigation !== 'object' || navigation === null) return null
784
+ const openSession = navigation.openSession
785
+ if (typeof openSession !== 'function') return null
786
+ // `slots` is declared in `inject`, but a composition may still omit the package:
787
+ // reading it defensively keeps the panel links optional rather than fatal.
788
+ let slots
789
+ try {
790
+ slots = scope.slots
791
+ } catch {
792
+ slots = undefined
793
+ }
794
+ const layout = typeof scope.get === 'function' ? scope.get('layout') : undefined
795
+ const panelInfo = layout?.panelInfo
796
+ const panels = typeof panelInfo?.getSnapshot === 'function' && typeof panelInfo?.subscribe === 'function' ? panelInfo : undefined
797
+ const selectPanel = typeof layout?.selectPanel === 'function' ? layout.selectPanel.bind(layout) : undefined
798
+ const beginNavigation = typeof layout?.beginNavigation === 'function' ? layout.beginNavigation.bind(layout) : undefined
799
+ return {
800
+ faces: {
801
+ catalog: list,
802
+ archivedSessionIds: () => registry.getSnapshot().archivedSessionIds,
803
+ openSession: id => openSession.call(navigation, id),
804
+ selectPanel: selectPanel === undefined ? undefined : id => selectPanel(id),
805
+ beginNavigation: beginNavigation === undefined ? undefined : () => beginNavigation(),
806
+ panels,
807
+ slots,
808
+ },
809
+ }
810
+ }
811
+
812
+ /**
813
+ * Build the live browser host over the probed faces.
814
+ * @param {object} faces - the probed faces.
815
+ * @returns {object} the host the state machine drives.
816
+ */
817
+ function browserHost(faces) {
818
+ /**
819
+ * Call a host face defensively: a refused navigation or a service that
820
+ * changed its mind must never take the page down with it.
821
+ */
822
+ const attempt = (fn, fallback) => {
823
+ try {
824
+ return fn()
825
+ } catch {
826
+ return fallback
827
+ }
828
+ }
829
+ const archived = () => {
830
+ const ids = attempt(() => faces.archivedSessionIds(), [])
831
+ return Array.isArray(ids) ? ids : []
832
+ }
833
+ return {
834
+ mainViewSessionId: () => attempt(() => mainViewSessionId(faces.catalog.getSnapshot().byId), undefined),
835
+ activePanelId: () => (faces.panels === undefined ? undefined : attempt(() => faces.panels.getSnapshot()?.activePanelId, undefined)),
836
+ // The panel that owns inner pages advertises them with a data attribute; the
837
+ // router only reads it, never the panel's internals.
838
+ // The inner page a panel is showing, in that panel's own vocabulary. Each panel
839
+ // publishes it with a data attribute; reading it is the whole reflect direction.
840
+ panelPage: (panelId, scope) => {
841
+ if (panelId === TASK_BOARD_PANEL_ID) {
842
+ // The form and a task are mutually exclusive, and the form wins when both
843
+ // would be reported (it is the page the user is looking at).
844
+ if (window.document?.querySelector?.(`[${TASK_BOARD_NEW_ATTRIBUTE}]`) != null) return ['new']
845
+ const value = attributeOf(`[${TASK_BOARD_OPEN_ATTRIBUTE}]`, TASK_BOARD_OPEN_ATTRIBUTE)
846
+ return value === undefined ? undefined : ['task', value]
847
+ }
848
+ if (panelId !== PLUGINS_PANEL_ID) return undefined
849
+ const row = splitRowHook(attributeOf('[data-plugin-row-detail]', 'data-plugin-row-detail'))
850
+ if (row !== undefined) {
851
+ // The page names its package when it can; otherwise the package is the deepest
852
+ // segment the address bar already carried.
853
+ const pkg = row.packageName ?? scope
854
+ return pkg === undefined ? [row.rowId] : innerPath([pkg, row.rowId])
855
+ }
856
+ // The built-in plugins (the official group) have their own page hook.
857
+ const item = attributeOf('[data-plugin-item-detail]', 'data-plugin-item-detail')
858
+ if (item !== undefined) return [item]
859
+ const detail = attributeOf('[data-plugin-detail]', 'data-plugin-detail')
860
+ return detail === undefined ? undefined : [detail]
861
+ },
862
+ openPanelPage: (panelId, inner) => {
863
+ const asked = innerPath(inner)
864
+ if (panelId === TASK_BOARD_PANEL_ID) {
865
+ // The task board owns how its overlays open: we hand over the request on its
866
+ // own contract (window events) and nothing else crosses the seam.
867
+ if (asked[0] === 'new' && asked.length === 1) {
868
+ attempt(() => window.dispatchEvent(new window.CustomEvent(TASK_BOARD_NEW_EVENT)))
869
+ return
870
+ }
871
+ if (asked[0] !== 'task' || asked.length < 2) return
872
+ attempt(() =>
873
+ window.dispatchEvent(
874
+ new window.CustomEvent(TASK_BOARD_OPEN_EVENT, { detail: { taskId: asked.slice(1).join('/') } }),
875
+ ),
876
+ )
877
+ return
878
+ }
879
+ if (panelId !== PLUGINS_PANEL_ID) return
880
+ const segments = asked
881
+ if (segments.length === 0) return
882
+ // The component page lives inside its package page, so that page is opened
883
+ // first and the row is clicked once it exists.
884
+ if (segments.length === 1 || window.document?.querySelector?.('[data-plugin-detail]') === null) {
885
+ // A segment names either an installed package or a built-in plugin.
886
+ const pkg =
887
+ window.document?.querySelector?.(`li[data-plugin-package="${segments[0]}"]`) ??
888
+ window.document?.querySelector?.(`li[data-plugin-item="${segments[0]}"]`)
889
+ const button = pkg?.querySelector?.('button')
890
+ if (typeof button?.click === 'function') button.click()
891
+ if (segments.length === 1) return
892
+ }
893
+ const rowId = segments[segments.length - 1]
894
+ const rows = window.document?.querySelectorAll?.('li[data-plugin-row]') ?? []
895
+ for (const row of rows) {
896
+ const parsed = splitRowHook(typeof row.getAttribute === 'function' ? row.getAttribute('data-plugin-row') : null)
897
+ if (parsed === undefined || parsed.rowId !== rowId) continue
898
+ const button = row.querySelector?.('button')
899
+ if (typeof button?.click === 'function') button.click()
900
+ return
901
+ }
902
+ },
903
+ isArchived: id => archived().includes(id),
904
+ isOpenable: id =>
905
+ attempt(() => {
906
+ const snapshot = faces.catalog.getSnapshot()
907
+ if (snapshot.phase !== 'ready') return false
908
+ if (archived().includes(id)) return false
909
+ return snapshot.ids.includes(id) || snapshot.byId[id] !== undefined
910
+ }, false),
911
+ // A panel id nobody registered is refused by the layout by THROWING
912
+ // (`selectPanel: main panel "x" is not registered`), so the claim is
913
+ // swallowed here and reported as "the view did not move" at expiry.
914
+ openSession: id => {
915
+ attempt(() => faces.openSession(id))
916
+ },
917
+ beginNavigation: () => {
918
+ if (faces.beginNavigation === undefined) return
919
+ attempt(() => faces.beginNavigation())
920
+ },
921
+ selectPanel: id => {
922
+ if (faces.selectPanel === undefined) return
923
+ attempt(() => faces.selectPanel(id))
924
+ },
925
+ readLocation: () =>
926
+ attempt(() => ({
927
+ pathname: window.location.pathname,
928
+ search: window.location.search,
929
+ hash: window.location.hash,
930
+ }), { pathname: '', search: '', hash: '' }),
931
+ replaceUrl: url => {
932
+ attempt(() => window.history.replaceState(null, '', url))
933
+ },
934
+ onPanelChange: handler => (faces.panels === undefined ? () => {} : attempt(() => faces.panels.subscribe(handler), () => {})),
935
+ onCatalogChange: handler => attempt(() => faces.catalog.subscribe(handler), () => {}),
936
+ // A panel's inner page is reported by attributes the panel itself publishes, so
937
+ // DOM changes are a notification source of their own (list <-> detail).
938
+ onDomChange: handler =>
939
+ attempt(() => {
940
+ const root = window.document?.body ?? window.document?.documentElement
941
+ if (root === undefined || root === null || typeof window.MutationObserver !== 'function') return () => {}
942
+ const observer = new window.MutationObserver(handler)
943
+ // Every published page hook must be watched: a filter that names only some of
944
+ // them silently starves the retry loop of notifications (real bug: the task
945
+ // board's drive looked flaky because its attributes were not observed).
946
+ observer.observe(root, {
947
+ childList: true,
948
+ subtree: true,
949
+ attributes: true,
950
+ attributeFilter: [
951
+ 'data-plugin-detail',
952
+ 'data-plugin-item-detail',
953
+ 'data-plugin-row-detail',
954
+ TASK_BOARD_OPEN_ATTRIBUTE,
955
+ TASK_BOARD_NEW_ATTRIBUTE,
956
+ ],
957
+ })
958
+ return () => observer.disconnect()
959
+ }, () => {}),
960
+ onHashChange: handler =>
961
+ attempt(() => {
962
+ window.addEventListener('hashchange', handler)
963
+ window.addEventListener('popstate', handler)
964
+ return () => {
965
+ window.removeEventListener('hashchange', handler)
966
+ window.removeEventListener('popstate', handler)
967
+ }
968
+ }, () => {}),
969
+ now: () => attempt(() => Date.now(), 0),
970
+ setTimer: (handler, delayMs) =>
971
+ attempt(() => {
972
+ const timer = window.setTimeout(handler, delayMs)
973
+ return () => window.clearTimeout(timer)
974
+ }, () => {}),
975
+ warn: message => console.warn(message),
976
+ }
977
+ }
978
+
979
+ /**
980
+ * Register the URL routing on the running GUI.
981
+ * @param {object} ctx - client root context.
982
+ */
983
+ function apply(ctx) {
984
+ // Fragment routing: the address bar names the view, and a link opens it.
985
+ ctx.effect(() => {
986
+ if (typeof window === 'undefined') return () => {}
987
+ const probed = probeFaces(ctx)
988
+ if (probed === null) {
989
+ console.warn(`${TAG} disabled: the sessions/workspaces/uiWorkspace service shape changed`)
990
+ return () => {}
991
+ }
992
+ if (probed.faces.panels === undefined) {
993
+ console.warn(`${TAG} the layout service exposes no panel store; only conversation routes are routed`)
994
+ }
995
+ return startUrlRouting(browserHost(probed.faces))
996
+ }, 'url-router: fragment routing')
997
+ // Row links: every conversation row is exposed as a real link to its route, so
998
+ // the browser's own link menu (open in new tab / copy link) works on it.
999
+ ctx.effect(() => {
1000
+ if (typeof window === 'undefined' || window.document === undefined) return () => {}
1001
+ const probed = probeFaces(ctx)
1002
+ if (probed === null) return () => {}
1003
+ const openSession = id => {
1004
+ try {
1005
+ probed.faces.openSession(id)
1006
+ } catch {
1007
+ // A refused navigation must not escape into the anchor's click handler.
1008
+ }
1009
+ }
1010
+ const selectPanel = id => {
1011
+ try {
1012
+ probed.faces.selectPanel(id)
1013
+ } catch {
1014
+ // The layout throws for an id nobody registered; a link must not.
1015
+ }
1016
+ }
1017
+ const slots = probed.faces.slots
1018
+ return startRowLinks(window.document, openSession, slots, selectPanel)
1019
+ }, 'url-router: row links')
1020
+ }
1021
+
1022
+ exports.apply = apply
1023
+ exports.inject = inject
1024
+ return module.exports
1025
+ },
1026
+ })
package/lib/index.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * dsh-url-router · host half.
3
+ *
4
+ * No host behavior: this plugin has no route, no tool, and no service. It exists
5
+ * so the package is a well-formed bundle row (the row needs a `main` entry); the
6
+ * whole feature lives in the browser half, `./client`.
7
+ */
8
+
9
+ /**
10
+ * Apply the host half.
11
+ * @param {object} _ctx - host plugin context (unused).
12
+ */
13
+ export function apply(_ctx) {}
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "name": "dsh-url-router",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "DSH Web GUI plugin: one URL for every surface — the address bar names what is on screen (a conversation or a main panel) and a link opens it. Single owner of the URL fragment; browser half only, no dependencies, no telemetry.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/DDDMUC/dsh-url-router.git"
8
+ },
9
+ "type": "module",
10
+ "main": "lib/index.js",
11
+ "exports": {
12
+ ".": "./lib/index.js",
13
+ "./client": "./lib/client.js",
14
+ "./package.json": "./package.json"
15
+ },
16
+ "scripts": {
17
+ "test": "node --test \"test/*.test.js\""
18
+ },
19
+ "dsh": {
20
+ "engines": {
21
+ "dsh": ">=0.2.0-rc.2"
22
+ },
23
+ "bundle": {
24
+ "patch": "./cordis.patch.yml"
25
+ },
26
+ "client": {
27
+ "platform": "web",
28
+ "inject": [
29
+ "@deepseek-ai/dsh-api-session-controller",
30
+ "@deepseek-ai/dsh-api-workspace-controller",
31
+ "@deepseek-ai/dsh-client-ui-workspace",
32
+ "@deepseek-ai/dsh-client-ui-layout"
33
+ ]
34
+ },
35
+ "compatibility": {
36
+ "dshReleases": {
37
+ "0.2.0-rc.2": "compatible"
38
+ }
39
+ }
40
+ },
41
+ "files": [
42
+ "lib",
43
+ "cordis.patch.yml",
44
+ "README.md",
45
+ "AGENTS.md",
46
+ "LICENSE"
47
+ ],
48
+ "keywords": [
49
+ "deepseek-harness",
50
+ "dsh",
51
+ "plugin",
52
+ "url",
53
+ "router",
54
+ "deep-link"
55
+ ],
56
+ "license": "MIT"
57
+ }