dsh-url-router 0.3.0 → 0.3.2

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/lib/client.js CHANGED
@@ -1,1956 +1,2207 @@
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', settings: 'settings', workspace: 'workspace', workspaces: 'workspace' }
67
- /** Attribute the settings overlay puts on its root element. */
68
- const SETTINGS_SURFACE_ATTRIBUTE = 'data-dsh-surface'
69
- /** Value of that attribute for the settings overlay. */
70
- const SETTINGS_SURFACE_VALUE = 'settings'
71
- /** Slot the settings entry is rendered into (its button opens the overlay). */
72
- const SETTINGS_TRIGGER_SLOT = 'sidebar.settings'
73
- /** Canonical fragment shape, plus the bare `#/<kind>` form a modal surface uses. */
74
- const ROUTE_PATTERN = /^#\/?([a-z][a-z0-9-]*)(?:\/(.+))?$/
75
- /** How often an unsatisfied link is retried while it is authoritative. */
76
- const LINK_RETRY_MS = 500
77
- /** How many times an inner page may be requested while its link is authoritative. */
78
- const INNER_ATTEMPT_LIMIT = 40
79
- /** How long a linked surface stays authoritative over the view owner's own restore. */
80
- const LINK_ENFORCE_WINDOW_MS = 8000
81
- /** Panel id of the task board, whose open-task contract this router drives. */
82
- const TASK_BOARD_PANEL_ID = 'task-board'
83
- /** Attribute the task board publishes the open task on. */
84
- const TASK_BOARD_OPEN_ATTRIBUTE = 'data-dsh-taskboard-open-task'
85
- /** Window event that asks the task board to open a task. */
86
- const TASK_BOARD_OPEN_EVENT = 'dsh-taskboard-open-task'
87
- /** Attribute the task board publishes while its new-task form is open. */
88
- const TASK_BOARD_NEW_ATTRIBUTE = 'data-dsh-taskboard-new-task'
89
- /** Window event that asks the task board for its new-task form. */
90
- const TASK_BOARD_NEW_EVENT = 'dsh-taskboard-new-task'
91
- /** Sidebar row key prefix for a workspace (the value is its id; the row text is its name). */
92
- const WORKSPACE_ROW_PREFIX = 'workspace:'
93
- /** Stable container the conversation header renders its view tabs into. */
94
- const CONVERSATION_TABS_ATTRIBUTE = 'data-conversation-tabs'
95
- /** Attribute each trajectory row carries: `<kind>%00<scope>%00<id>`. */
96
- const TRAJECTORY_ROW_ATTRIBUTE = 'data-trajectory-row-key'
97
- /** Attribute the family trajectory view puts on a row's turn cell. */
98
- const TRAJECTORY_TURN_ATTRIBUTE = 'data-dshts-turn'
99
- /** The right bar's own root: it carries the session the bar belongs to. */
100
- const RIGHT_BAR_ATTRIBUTE = 'data-sidebar-right-session'
101
- /** The right bar's tab strip: only *tabs* live here (the empty state has a bare pane). */
102
- const RIGHT_BAR_STRIP_ATTRIBUTE = 'data-dockkit-strip-tabs'
103
- /** Attribute the trajectory view puts on the first row of a turn. */
104
- const TRAJECTORY_TURN_START_ATTRIBUTE = 'data-turn-start'
105
- /** Inner segment that names one row inside a conversation view. */
106
- const ROW_SEGMENT = 'row'
107
- /** Inner segment that names which conversation view is showing. */
108
- const VIEW_SEGMENT = 'view'
109
- /** Inner segment that marks the conversation inside a workspace route. */
110
- const WORKSPACE_SESSION_SEGMENT = 'session'
111
- /** Attribute the chat view puts on every rendered turn node (the turn number). */
112
- const TURN_ATTRIBUTE = 'data-chat-turn'
113
- /** Inner segment that names the turn a conversation is showing. */
114
- const TURN_SEGMENT = 'turn'
115
- /** Inner segment that names the right-hand pane in front. */
116
- const PANE_SEGMENT = 'pane'
117
- /** Service the right-hand pane registers, whose snapshot names the active tab. */
118
- const SIDEBAR_RIGHT_SERVICE = 'sidebarRightTabs'
119
- /** Panel id of the official plugin manager, whose list rows and detail pages are routed. */
120
- const PLUGINS_PANEL_ID = 'plugins'
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
-
128
- const TAG = '[url-router]'
129
-
130
- /**
131
- * The conversation view ids the registry publishes, in registration order — the same order
132
- * the header renders its tabs in, which is what lets a tab position map back to an id.
133
- * @param {object} faces - the probed host faces.
134
- * @returns {string[]|undefined} the ids, or undefined when the registry is not readable.
135
- */
136
- function conversationViewIdsOf(faces) {
137
- // The conversation's own `uiConversation` registry is scoped to the conversation and is
138
- // not visible from a root plugin (measured: undefined), but the view tabs are contributed
139
- // through the `conversation.view` slot, and that *is* readable — with an id and an order.
140
- const entries = attemptValue(() => faces.slots?.entries?.('conversation.view'))
141
- if (!Array.isArray(entries)) return undefined
142
- const rows = []
143
- for (const entry of entries) {
144
- if (entry === null || typeof entry !== 'object') continue
145
- const options = entry.options ?? entry
146
- const id = options.id ?? entry.id
147
- if (typeof id !== 'string' || id === '') continue
148
- const order = typeof options.order === 'number' ? options.order : 0
149
- rows.push({ id, order })
150
- }
151
- if (rows.length === 0) return undefined
152
- rows.sort((left, right) => left.order - right.order)
153
- return rows.map(row => row.id)
154
- }
155
-
156
- /**
157
- * Read a value, or undefined when reading it throws.
158
- * @param {Function} read - the reader.
159
- * @returns {*} the value, or undefined.
160
- */
161
- function attemptValue(read) {
162
- try {
163
- return read()
164
- } catch {
165
- return undefined
166
- }
167
- }
168
-
169
- /**
170
- * The workspace a sidebar row belongs to: the nearest workspace row before it, read from the
171
- * row text. Returns undefined when the tree says nothing, so no caller has to guess.
172
- * @param {object} row - the conversation row.
173
- * @param {object} document - the document the tree lives in.
174
- * @returns {string|undefined} the workspace display name.
175
- */
176
- function workspaceNameOf(row, document) {
177
- const rows = document?.querySelectorAll?.('[data-row-key]')
178
- if (rows === undefined || rows === null) return undefined
179
- let name
180
- for (const candidate of rows) {
181
- const key = typeof candidate.getAttribute === 'function' ? candidate.getAttribute('data-row-key') : null
182
- if (typeof key !== 'string') continue
183
- if (key.startsWith(WORKSPACE_ROW_PREFIX)) {
184
- name = (candidate.textContent ?? '').trim()
185
- continue
186
- }
187
- if (candidate !== row) continue
188
- return name === undefined || name === '' ? undefined : name
189
- }
190
- return undefined
191
- }
192
-
193
- /**
194
- * The kind inside a tab id: the bar builds one as `sidebar://<kind>` and appends a uuid for
195
- * kinds that may be opened more than once, so the kind is right there in the address.
196
- * @param {string} value - a tab id.
197
- * @returns {string|undefined} the kind, when the value is one of these addresses.
198
- */
199
- /**
200
- * The right bar's registry, normalised: one row per page type with its kind, the label
201
- * the tab shows, whether more than one may be open, and its empty-state card (measured
202
- * live: `{kind, title(): () => string, multiple?: true, guide?: [{id, order}]}`).
203
- * @param {object} faces - the probed host faces.
204
- * @returns {{kind: string, label: string|undefined, multiple: boolean}[]|undefined} the rows.
205
- */
206
- function paneRegistryOf(faces) {
207
- const rows = attemptValue(() => faces.sidebarRightTabs?.entries?.())
208
- if (!Array.isArray(rows)) return undefined
209
- const out = []
210
- for (const row of rows) {
211
- if (row === null || typeof row !== 'object') continue
212
- const kind = row.kind ?? row.options?.kind ?? row.id
213
- if (typeof kind !== 'string' || kind === '') continue
214
- const label = attemptValue(() => (typeof row.title === 'function' ? row.title() : row.title))
215
- out.push({ kind, label: typeof label === 'string' && label !== '' ? label : undefined, multiple: row.multiple === true })
216
- }
217
- return out.length === 0 ? undefined : out
218
- }
219
-
220
- /**
221
- * A tab label compared without the noise a visible label can carry (padding, an underline
222
- * marker, a shortcut hint), so a registry label can be matched to what the tab shows.
223
- * @param {string} value - the text to flatten.
224
- * @returns {string} the flattened text.
225
- */
226
- function flatLabel(value) {
227
- return String(value).replace(/\s+/g, ' ').trim()
228
- }
229
-
230
- function kindOfTabId(value) {
231
- if (typeof value !== 'string' || value === '') return undefined
232
- const match = /^sidebar:\/\/([^/?#]+)/.exec(value)
233
- return match === null ? undefined : decodeURIComponent(match[1])
234
- }
235
-
236
- /**
237
- * The session the right bar belongs to: the bar is per session and says so on its own root.
238
- * @param {object} document - the document to read.
239
- * @returns {string|undefined} the session id.
240
- */
241
- function rightBarSessionIdOf(document) {
242
- const root = attemptValue(() => document?.querySelector?.(`[${RIGHT_BAR_ATTRIBUTE}]`))
243
- const id = root === null || root === undefined ? null : attemptValue(() => root.getAttribute?.(RIGHT_BAR_ATTRIBUTE))
244
- return typeof id === 'string' && id !== '' ? id : undefined
245
- }
246
-
247
- /**
248
- * The right bar's active tab kind, read from its *strip*: the empty state also renders a
249
- * `data-dockkit-pane`, so any pane-active marker would report a tab that is not there.
250
- * @param {object} document - the document to read.
251
- * @returns {string|undefined} the active kind.
252
- */
253
- /**
254
- * Notes already said, across reconciles: a compatibility note is useful the first time, and
255
- * a plugin that repeats it every reconcile is a log spammer.
256
- */
257
- const paneNotes = new Set()
258
- /**
259
- * Say a pane diagnostic once.
260
- * @param {Function} warn - the host's warning sink.
261
- * @param {string} key - deduplication key.
262
- * @param {string} message - what to say.
263
- */
264
- const notePaneShape = (warn, key, message) => {
265
- if (paneNotes.has(key)) return
266
- paneNotes.add(key)
267
- warn(`${TAG} ${message}`)
268
- }
269
-
270
- function activePaneTabOf(document, registry) {
271
- // Scoped to the right bar's own root: the same dockkit attributes dress the main area
272
- // too, and reading them unscoped reports the main area's panes, not this bar's tabs.
273
- const right = attemptValue(() => document?.querySelector?.(`[${RIGHT_BAR_ATTRIBUTE}]`))
274
- const scope = right === null || right === undefined ? document : right
275
- const strip = typeof scope?.querySelector === 'function' ? attemptValue(() => scope.querySelector(`[${RIGHT_BAR_STRIP_ATTRIBUTE}]`)) : undefined
276
- if (strip === null || strip === undefined) return undefined
277
- // A tab is `role="tab"` with its id in `data-dockkit-tab` — and that id *is* the page
278
- // address, so the kind sits inside it. Pane markers belong to the content area.
279
- // Remember what the strip holds, *before* trying to read it: this is what a compatibility
280
- // note has to name when nothing can be read, so it must be recorded either way.
281
- const described = attemptValue(() => {
282
- const child = strip.children?.[0]
283
- if (child === undefined) return undefined
284
- // An Attr list and a Map both show up (the DOM makes the former, a stub the latter).
285
- const pairs = [...child.attributes].map(attribute => Array.isArray(attribute) ? { name: attribute[0], value: attribute[1] } : attribute)
286
- return pairs.map(attribute => `${attribute.name}="${String(attribute.value).slice(0, 24)}"`).join(' ')
287
- })
288
- if (typeof described === 'string') activePaneTabOf.observed = described
289
- const tabs = typeof strip.querySelectorAll === 'function' ? attemptValue(() => [...strip.querySelectorAll('[role="tab"]')]) : undefined
290
- if (!Array.isArray(tabs) || tabs.length === 0) return undefined
291
- const chosen = tabs.find(tab => attemptValue(() => tab.getAttribute?.('aria-selected')) === 'true') ?? tabs[0]
292
- // The tab's own name is the reliable source: measured live, `data-dockkit-tab` and
293
- // `data-dockkit-content` both carry the strip's tab id (`tab3`), which says nothing about
294
- // the kind — while the visible label *is* the kind's name (`Files`, `Start`, `Terminal`).
295
- const nameOfTab = tab => {
296
- const labelled = attemptValue(() => tab.getAttribute?.('aria-label')) ?? attemptValue(() => tab.getAttribute?.('title'))
297
- if (typeof labelled === 'string' && labelled.trim() !== '') return labelled.trim()
298
- const text = attemptValue(() => (tab.textContent ?? '').trim())
299
- return typeof text === 'string' && text !== '' && text.length < 40 ? text : undefined
300
- }
301
- // 1) the tab's name, resolved to a kind through the registry whenever it can be: the
302
- // registry publishes the very label each tab shows, so a link can carry the kind
303
- // (`pane/files`) instead of a sentence in whatever language is on screen.
304
- const named = nameOfTab(chosen)
305
- if (named !== undefined) {
306
- const wanted = flatLabel(named).toLowerCase()
307
- const match = Array.isArray(registry) ? registry.find(row => typeof row.label === 'string' && flatLabel(row.label).toLowerCase() === wanted) : undefined
308
- return match === undefined ? named : match.kind
309
- }
310
- // 2) an address-shaped id, for a future build that puts one there
311
- const fromTab = kindOfTabId(attemptValue(() => chosen.getAttribute?.('data-dockkit-tab')))
312
- if (fromTab !== undefined) return fromTab
313
- // 3) the tab host section, likewise
314
- const hosts = typeof scope.querySelectorAll === 'function' ? attemptValue(() => [...scope.querySelectorAll('[data-dockkit-content]')]) : []
315
- for (const host of hosts) {
316
- const kind = kindOfTabId(attemptValue(() => host.getAttribute?.('data-dockkit-content')))
317
- if (kind !== undefined) return kind
318
- }
319
- return undefined
320
- }
321
-
322
- /**
323
- * Read a route out of a location fragment.
324
- * @param {string} hash - the raw `window.location.hash`, including the leading `#`.
325
- * @returns {{kind: string, id: string}|{unknown: string}|undefined} the route, an unknown-kind report, or undefined for "no route here".
326
- */
327
- function decodePart(raw) {
328
- try {
329
- return decodeURIComponent(raw)
330
- } catch {
331
- return raw
332
- }
333
- }
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
-
353
- function parseRoute(hash) {
354
- const match = ROUTE_PATTERN.exec(hash)
355
- if (match === null) return undefined
356
- const kind = ROUTE_ALIASES[match[1]]
357
- if (kind === undefined) return { unknown: match[1] }
358
- // A modal surface needs no identity: `#/settings` is the whole route.
359
- if (match[2] === undefined) return kind === 'settings' ? { kind, id: '' } : undefined
360
- // A conversation can be named inside its workspace — the shape the sidebar itself shows,
361
- // and the only way a link says *where* a conversation lives.
362
- if (kind === 'workspace') {
363
- const parts = match[2].replace(/\/+$/, '').split('/')
364
- const name = decodePart(parts.shift() ?? '').trim()
365
- if (name === '') return { unknown: `workspace/${match[2]}` }
366
- const rest = parts.map(decodePart).filter(segment => segment.trim() !== '')
367
- if (rest.length < 2 || rest[0] !== WORKSPACE_SESSION_SEGMENT) return { unknown: `workspace/${match[2]}` }
368
- const sessionId = rest[1].trim()
369
- if (sessionId === '') return { unknown: `workspace/${match[2]}` }
370
- // The view a conversation is showing is part of the conversation, so it may follow.
371
- const tail = rest.slice(2)
372
- if (tail.length === 0) return { kind: 'workspace', id: name, inner: innerPath([WORKSPACE_SESSION_SEGMENT, sessionId]) }
373
- if (tail.length < 2 || tail[1].trim() === '') return { unknown: `workspace/${match[2]}` }
374
- if (tail[0] === VIEW_SEGMENT && tail.length === 2) return { kind: 'workspace', id: name, inner: innerPath([WORKSPACE_SESSION_SEGMENT, sessionId, VIEW_SEGMENT, tail[1]]) }
375
- if (tail[0] === ROW_SEGMENT && tail.length >= 3) return { kind: 'workspace', id: name, inner: innerPath([WORKSPACE_SESSION_SEGMENT, sessionId, ...tail]) }
376
- if (tail[0] === PANE_SEGMENT && tail.length === 2) return { kind: 'workspace', id: name, inner: innerPath([WORKSPACE_SESSION_SEGMENT, sessionId, PANE_SEGMENT, tail[1]]) }
377
- return { unknown: `workspace/${match[2]}` }
378
- }
379
- const segments = match[2].replace(/\/+$/, '').split('/')
380
- const head = segments.shift() ?? ''
381
- if (head === '') return kind === 'settings' ? { kind, id: '' } : undefined
382
- const id = decodePart(head).trim()
383
- if (id === '') return kind === 'settings' ? { kind, id: '' } : undefined
384
- // A settings section has no stable id in the DOM (measured: the nav item carries only
385
- // aria-current), so its identity is its *label* — the string the user actually sees and
386
- // clicks. Round-tripping the label keeps the link self-consistent; a locale change
387
- // renames the sections and the address bar simply writes the new label next time.
388
- if (kind === 'settings') {
389
- // A section may hold tabs of its own (measured: `role="tab"` + `aria-selected`), so
390
- // the rest of the path names the tab — again by its label.
391
- const rest = segments.map(decodePart).filter(segment => segment.trim() !== '')
392
- return rest.length === 0 ? { kind, id } : { kind, id, inner: rest }
393
- }
394
- // A panel may have an inner page (a plugin's detail view, for instance): the
395
- // rest of the path names it, per panel.
396
- const inner = segments.map(decodePart).filter(segment => segment.trim() !== '')
397
- return { kind, id, inner: inner.length === 0 ? undefined : inner }
398
- }
399
-
400
- /**
401
- * Build the canonical fragment for a route.
402
- * @param {string} kind - route kind.
403
- * @param {string} id - route identity.
404
- * @returns {string} the `#/<kind>/<encoded id>` fragment.
405
- */
406
- function routeHash(kind, id, inner) {
407
- // A modal surface has no identity: `#/settings`, not `#/settings/`.
408
- if (kind === 'settings' && (id === undefined || id === '')) return '#/settings'
409
- if (kind === 'workspace') {
410
- const rest = innerPath(inner)
411
- const tail = rest.length === 0 ? '' : `/${rest.map(encodeURIComponent).join('/')}`
412
- return `#/workspace/${encodeURIComponent(id)}${tail}`
413
- }
414
- if (kind === 'settings') {
415
- const head = `#/settings/${encodeURIComponent(id)}`
416
- const rest = innerPath(inner)
417
- return rest.length === 0 ? head : `${head}/${rest.map(encodeURIComponent).join('/')}`
418
- }
419
- const base = `#/${kind}/${encodeURIComponent(id)}`
420
- if (inner === undefined || inner === '') return base
421
- const segments = innerPath(inner)
422
- return segments.length === 0 ? base : `${base}/${segments.map(encodeURIComponent).join('/')}`
423
- }
424
-
425
- /**
426
- * Normalise an inner path into its segments.
427
- *
428
- * A panel's inner page may nest (`<package>/<component>`), and each segment is
429
- * encoded on its own, so a raw `/` in the path is always a separator while a `/`
430
- * inside a name survives as `%2F`.
431
- * @param {string|Array<string>} value - the inner path or its segments.
432
- * @returns {Array<string>} the segments, empty when there is no inner page.
433
- */
434
- /**
435
- * Split a row hook value into the package it belongs to and the row id.
436
- *
437
- * Two spellings exist in the panel's DOM: a page-level `<package>#<rowId>` and a
438
- * row-level `<kind>:<rowId>`. Only the row id is routed; the package is used when
439
- * the page reports one.
440
- * @param {unknown} value - the raw attribute value.
441
- * @returns {{packageName: string|undefined, rowId: string}|undefined} the parts, or undefined when there is nothing usable.
442
- */
443
- function splitRowHook(value) {
444
- if (typeof value !== 'string' || value.trim() === '') return undefined
445
- const hash = value.indexOf('#')
446
- if (hash !== -1) {
447
- const rowId = value.slice(hash + 1).trim()
448
- if (rowId === '') return undefined
449
- const packageName = value.slice(0, hash).trim()
450
- return { packageName: packageName === '' ? undefined : packageName, rowId }
451
- }
452
- const colon = value.lastIndexOf(':')
453
- const rowId = (colon === -1 ? value : value.slice(colon + 1)).trim()
454
- return rowId === '' ? undefined : { packageName: undefined, rowId }
455
- }
456
-
457
- function innerPath(value) {
458
- const parts = Array.isArray(value) ? value : String(value ?? '').split('/')
459
- return parts.map(part => String(part).trim()).filter(part => part !== '')
460
- }
461
-
462
- /**
463
- * Build the address-bar URL for one view state, preserving path and query.
464
- * @param {string} pathname - current `location.pathname` (the mount path is preserved).
465
- * @param {string} search - current `location.search` (a launch token, when present).
466
- * @param {{kind: string, id: string}|undefined} route - the view to name, or undefined for "no view named".
467
- * @returns {string} the URL to write.
468
- */
469
- function routeUrl(pathname, search, route) {
470
- const base = `${pathname}${search}`
471
- return route === undefined ? base : `${base}${routeHash(route.kind, route.id, route.inner)}`
472
- }
473
-
474
- /**
475
- * Read one data attribute anywhere in the document.
476
- * @param {string} selector - the attribute selector to find the owner by.
477
- * @param {string} name - the attribute to read.
478
- * @returns {string|undefined} the value, when it is a non-empty string.
479
- */
480
- function attributeOf(selector, name) {
481
- const node = window.document?.querySelector?.(selector)
482
- const value = typeof node?.getAttribute === 'function' ? node.getAttribute(name) : null
483
- return typeof value === 'string' && value !== '' ? value : undefined
484
- }
485
-
486
- /** Serialise a route for cheap comparison. */
487
- const routeKey = route =>
488
- route === undefined ? 'none' : `${route.kind}:${route.id}:${innerPath(route.inner).map(part => encodeURIComponent(part)).join('/')}`
489
-
490
- // ---------------------------------------------------------------------
491
- // the router: two directions, one writer.
492
- //
493
- // - Fragment to view: a link names a surface, so loading (or pasting)
494
- // `#/panel/plugins` selects that panel and `#/session/<id>` opens that
495
- // conversation.
496
- // - View to fragment: the address bar always names what is on screen — a
497
- // panel when one is selected, otherwise the conversation the main view
498
- // shows.
499
- //
500
- // A link needs an enforcement window because both owners restore their own
501
- // remembered state while loading, which can land after the link was read.
502
- // Within the window the link wins; after it the view owns the URL again, so a
503
- // click during boot is never fought for longer than a boot.
504
- //
505
- // The state machine is host-injected (panel store, catalog, navigation,
506
- // location, clock, timer) and every notification carries its source, so it is
507
- // deterministic in tests and a claim can be retried on the notification that
508
- // actually matters (a panel registering late) without retrying on unrelated
509
- // catalog churn.
510
- // ---------------------------------------------------------------------
511
-
512
- /**
513
- * Start routing the fragment.
514
- * @param {object} host - the injected faces.
515
- * @returns {() => void} the disposer removing every subscription and timer.
516
- */
517
- function startUrlRouting(host) {
518
- /** The route the address bar claims, with its enforcement deadline. */
519
- let linked
520
- /** Whether the claimed route was already handed to its view owner. */
521
- let claimed = false
522
- /** The package segment of the deepest panel page the address bar carried. */
523
- let panelScope
524
- /** The turn the user last clicked inside a conversation, with its session. */
525
- /** What the report can say about this run: fragments it refused, kinds it left alone. */
526
- const observed = { foreignFragment: undefined, ignored: [], warnings: [] }
527
- let rowAnchor
528
- /** The pane kind this claim already opened (one drive per claim, see the pane branch). */
529
- let paneOpenedFor
530
- let turnAnchor
531
- /** How many times the linked panel was selected (bounded, see below). */
532
- let panelAttempts = 0
533
- /** How many times a panel link's inner page was requested (bounded, see below). */
534
- let innerAttempts = 0
535
- /** The view state observed on the previous notification. */
536
- let seen = 'none'
537
- let cancelTimer
538
- let retryTimer
539
- let disposed = false
540
-
541
- const dropTimer = () => {
542
- cancelTimer?.()
543
- cancelTimer = undefined
544
- }
545
- const dropLink = () => {
546
- dropRetry()
547
- linked = undefined
548
- claimed = false
549
- panelAttempts = 0
550
- innerAttempts = 0
551
- paneOpenedFor = undefined
552
- dropTimer()
553
- }
554
- /**
555
- * The route the current view deserves: a selected panel outranks the
556
- * conversation behind it, because the panel is what fills the screen.
557
- */
558
- const canonical = () => {
559
- // The settings overlay covers the main column, so while it is up it is what is on
560
- // screen; when it closes the ordinary surface takes the address bar back.
561
- if (window.document?.querySelector?.(`[${SETTINGS_SURFACE_ATTRIBUTE}="${SETTINGS_SURFACE_VALUE}"]`) != null) {
562
- const section = host.activeSettingsSection() ?? ''
563
- const tab = section === '' ? undefined : host.activeSettingsTab()
564
- return tab === undefined ? { kind: 'settings', id: section } : { kind: 'settings', id: section, inner: [tab] }
565
- }
566
- const panelId = host.activePanelId()
567
- if (panelId !== undefined && panelId !== null) {
568
- const id = String(panelId)
569
- // A right-hand pane in front is part of what is on screen, so it is named first:
570
- // it is true whether or not the panel itself reports an inner page.
571
- const paneInPanel = host.activePaneKind()
572
- if (paneInPanel !== undefined) return { kind: 'panel', id, inner: innerPath([PANE_SEGMENT, paneInPanel]) }
573
- const page = host.panelPage(id, panelScope)
574
- if (page === undefined) return { kind: 'panel', id, inner: undefined }
575
- const segments = innerPath(page)
576
- // Remember the package a page was opened under, so a bare component page (which
577
- // reports only its row) can still be named fully.
578
- if (id === PLUGINS_PANEL_ID && segments.length === 1) panelScope = segments[0]
579
- return { kind: 'panel', id, inner: segments }
580
- }
581
- const session = host.mainViewSessionId()
582
- if (session !== undefined && session !== null) {
583
- const id = String(session)
584
- // The workspace is part of *where* the conversation lives, so it leads the route.
585
- // Reading it from the sidebar keeps this honest: no sidebar (or no matching row)
586
- // means the plain conversation route, never a guess.
587
- // Which conversation view is showing, when it is not the default one: a plain
588
- // conversation keeps its short route, any other view says which one it is.
589
- // A pointed-at row is the most specific position there is, then the view, then the
590
- // turn: they are all "where inside this conversation", so one of them is written.
591
- const view = host.activeConversationView()
592
- const position = rowAnchor !== undefined && rowAnchor.session === id
593
- ? rowAnchor.turn === undefined || rowAnchor.turn === ''
594
- ? [ROW_SEGMENT, ...rowAnchor.parts]
595
- : [TURN_SEGMENT, rowAnchor.turn, ROW_SEGMENT, ...rowAnchor.parts]
596
- : view !== undefined && view.index !== 0
597
- ? [VIEW_SEGMENT, view.id]
598
- : []
599
- // The right-hand pane is its own layer, not a position inside the conversation:
600
- // it is named next to whatever the main column shows, never instead of it — so it
601
- // is read *before* the workspace branch, or the workspace would swallow it.
602
- const pane = host.activePaneKind()
603
- const tail = pane === undefined ? [] : [PANE_SEGMENT, pane]
604
- const workspaceName = host.workspaceNameOf(id)
605
- if (workspaceName !== undefined) {
606
- return { kind: 'workspace', id: workspaceName, inner: innerPath([WORKSPACE_SESSION_SEGMENT, id, ...position, ...tail]) }
607
- }
608
- if (tail.length === 0 && position.length > 0) return { kind: 'session', id, inner: innerPath(position) }
609
- if (tail.length > 0) return { kind: 'session', id, inner: innerPath([...position, ...tail]) }
610
- // The turn the user last pointed at is a position inside this conversation;
611
- // it is only named while the same conversation is the one on screen.
612
- if (turnAnchor !== undefined && turnAnchor.session === id) {
613
- return { kind: 'session', id, inner: innerPath([TURN_SEGMENT, turnAnchor.turn]) }
614
- }
615
- return { kind: 'session', id }
616
- }
617
- return undefined
618
- }
619
- // Two routes are the same surface when they compare equal — plus one refinement: a bare
620
- // `#/settings` names the overlay, and whichever section it happens to show is that same
621
- // surface, so the link lands at once instead of sitting there until its window expires.
622
- // A *named* section against a different named section is a real difference, so a section
623
- // link still drives the overlay to the section it asks for.
624
- // Whether the view on screen already *is* what a link asked for.
625
- //
626
- // The rule is directional on purpose: a link may be less specific than the view (a bare
627
- // `#/settings` is satisfied by any section, `#/session/<id>` by any view of it), but a view
628
- // that is less specific than the link never satisfies it — otherwise a link naming a view
629
- // would look "already there" and never be driven.
630
- const sessionIdOfRoute = route => {
631
- if (route?.kind === 'workspace') return innerPath(route.inner)[1]
632
- if (route?.kind === 'session') return route.id
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
667
- }
668
- if (current?.kind !== 'settings' || linked?.kind !== 'settings') return false
669
- if (linked.id === '') return true
670
- if (current.id !== linked.id) return false
671
- const linkedTab = innerPath(linked.inner)[0] ?? ''
672
- return linkedTab === '' || innerPath(current.inner)[0] === linkedTab
673
- }
674
- /** Write the fragment for the current view, leaving path and query untouched. */
675
- const reflect = route => {
676
- const { pathname, search, hash } = host.readLocation()
677
- // Compare against the fragment, not the whole URL: the hash never carries the
678
- // path or the launch query, and comparing them made every call look like a change.
679
- const wantedHash = route === undefined ? '' : routeHash(route.kind, route.id, route.inner)
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
- }
693
- // Nothing to name and nothing named: leave the address bar exactly as it is
694
- // (this also leaves a foreign fragment alone, which the caller never reaches).
695
- if (route === undefined && hash === '') return
696
- host.replaceUrl(routeUrl(pathname, search, route))
697
- }
698
- /**
699
- * Re-read the view state and move towards the state the address bar describes.
700
- * @param {string} source - what notified: `boot`, `panel`, `catalog`, `hash`, or `timer`.
701
- */
702
- const reconcile = (source = 'boot') => {
703
- if (disposed) return
704
- const current = canonical()
705
- const currentKey = routeKey(current)
706
- const moved = currentKey !== seen
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
- }
715
- if (linked === undefined) {
716
- reflect(current)
717
- return
718
- }
719
- if (sameRoute(current, linked)) {
720
- dropRetry()
721
- // The link has landed: it stops being authoritative right here, so the user
722
- // keeps owning the address bar afterwards (walking deeper inside a panel
723
- // updates the URL instead of being pulled back to the link).
724
- dropLink()
725
- reflect(current)
726
- return
727
- }
728
- if (host.now() >= linked.deadline) {
729
- const hash = routeHash(linked.kind, linked.id, linked.inner)
730
- host.warn(`${TAG} ${hash} did not become the shown view within ${LINK_ENFORCE_WINDOW_MS} ms; the link was ignored`)
731
- dropLink()
732
- reflect(canonical())
733
- return
734
- }
735
- // A conversation link may name which view it wants, on either route shape; the view is
736
- // driven before the plain conversation/workspace branches, or they would swallow it.
737
- const rowParts = innerPath(linked.inner)
738
- const rowAt = rowParts.indexOf(ROW_SEGMENT)
739
- const wantedTurn = rowParts.indexOf(TURN_SEGMENT)
740
- const rowSession = linked.kind === 'workspace' ? rowParts[1] : linked.kind === 'session' ? linked.id : undefined
741
- if (rowSession !== undefined && rowAt >= 0 && rowParts.length > rowAt + 2) {
742
- const shown = host.mainViewSessionId()
743
- if (shown !== rowSession && panelAttempts < INNER_ATTEMPT_LIMIT) {
744
- panelAttempts += 1
745
- host.openSession(rowSession)
746
- } else if (shown === rowSession && innerAttempts < INNER_ATTEMPT_LIMIT) {
747
- innerAttempts += 1
748
- if (!host.revealRow(rowParts.slice(rowAt)) && wantedTurn >= 0) host.revealRow(rowParts.slice(wantedTurn, wantedTurn + 2))
749
- }
750
- armRetry()
751
- return
752
- }
753
- const wantedView = linked.kind === 'session' || linked.kind === 'workspace' ? viewIdOfRoute(linked) : undefined
754
- const wantedSession = linked.kind === 'workspace' ? innerPath(linked.inner)[1] : linked.kind === 'session' ? linked.id : undefined
755
- if (wantedView !== undefined && wantedSession !== undefined) {
756
- // Unlike the boot restore, a named view is driven whatever brought the route in —
757
- // a pasted view link exists precisely to switch the view, so it must not be skipped.
758
- const shown = host.mainViewSessionId()
759
- if (shown !== wantedSession && panelAttempts < INNER_ATTEMPT_LIMIT) {
760
- panelAttempts += 1
761
- host.openSession(wantedSession)
762
- } else if (shown === wantedSession && innerAttempts < INNER_ATTEMPT_LIMIT) {
763
- innerAttempts += 1
764
- host.openConversationView(wantedView)
765
- }
766
- armRetry()
767
- return
768
- }
769
- if (linked.kind === 'workspace') {
770
- // The workspace is where the conversation lives, not a separate surface: the link
771
- // is satisfied by that conversation being on screen, whatever the name says.
772
- const target = innerPath(linked.inner)[1]
773
- const shown = host.mainViewSessionId()
774
- if (shown !== target && source !== 'hash' && panelAttempts < INNER_ATTEMPT_LIMIT) {
775
- panelAttempts += 1
776
- host.openSession(target)
777
- }
778
- armRetry()
779
- return
780
- }
781
- if (linked.kind === 'settings') {
782
- if (source !== 'hash' && innerAttempts < INNER_ATTEMPT_LIMIT) {
783
- innerAttempts += 1
784
- host.openSettings(linked.id, innerPath(linked.inner)[0])
785
- }
786
- armRetry()
787
- return
788
- }
789
- if (linked.kind === 'session' && linked.inner !== undefined && innerPath(linked.inner)[0] === PANE_SEGMENT) {
790
- // A pasted pane link is exactly what this drive is for, so the source is not a filter
791
- // here (the same lesson as the view branch); the attempt bound still limits it.
792
- const wantedPane = innerPath(linked.inner)[1]
793
- // A page type that may be opened more than once (measured: `multiple: true`, the
794
- // terminal) makes *every* attempt open another tab, so one claim drives once and the
795
- // retry then only waits for the bar to report the tab it opened.
796
- if (innerAttempts < INNER_ATTEMPT_LIMIT && paneOpenedFor !== wantedPane) {
797
- innerAttempts += 1
798
- paneOpenedFor = wantedPane
799
- host.openPane(wantedPane, linked.kind === 'panel' ? 'panel' : 'session', message => host.warn(message))
800
- }
801
- armRetry()
802
- return
803
- }
804
- if (linked.kind === 'session' && linked.inner !== undefined && innerPath(linked.inner)[0] === TURN_SEGMENT) {
805
- // A turn link is a position, not a surface: the conversation is opened, and the
806
- // view is told once that this build does not scroll to the turn (see README).
807
- // Record the position so the canonical form keeps it, then fall through to the
808
- // ordinary conversation branch: a turn link opens the conversation, and saying so
809
- // in the console would only be noise.
810
- turnAnchor = { session: linked.id, turn: innerPath(linked.inner)[1] }
811
- }
812
- if (linked.kind === 'panel') {
813
- if (linked.inner !== undefined && innerPath(linked.inner)[0] === PANE_SEGMENT) {
814
- if (innerAttempts < INNER_ATTEMPT_LIMIT) {
815
- innerAttempts += 1
816
- host.openPane(innerPath(linked.inner)[1], linked.kind === 'panel' ? 'panel' : 'session', message => host.warn(message))
817
- }
818
- armRetry()
819
- return
820
- }
821
- // Panels are selected by id and report back through the panel store; an id
822
- // nobody registered never becomes active, so the claim is retried when the
823
- // panel store moves (a late-loading plugin), not on every catalog tick.
824
- // `current` may be undefined when nothing is on screen yet; reading through it
825
- // unconditionally would abort the whole step inside the guard.
826
- const panelShown = current !== undefined && current.kind === 'panel' && current.id === linked.id
827
- if (!panelShown) {
828
- // Selecting a panel can be refused or swallowed while the shell is still
829
- // settling, so the request is retried (on later notifications and on a
830
- // timer) with a bound.
831
- if (source !== 'hash' && panelAttempts < INNER_ATTEMPT_LIMIT) {
832
- panelAttempts += 1
833
- // A panel switch is a navigation of its own: abort whatever navigation is
834
- // in flight first (the layout exposes exactly that pair), otherwise the
835
- // selection can be swallowed by the boot restore.
836
- host.beginNavigation()
837
- host.selectPanel(linked.id)
838
- }
839
- armRetry()
840
- return
841
- }
842
- // The panel is on screen; if the link names an inner page, drive it and let
843
- // the DOM observer report back (the panel owns how its pages open).
844
- // An inner page is driven through the panel's own control, which only exists
845
- // once that panel has rendered: the request is retried on every later
846
- // notification (not on the hash read itself, to avoid a double request),
847
- // with a bound so an unopenable page cannot turn into a click loop.
848
- if (linked.inner !== undefined && routeKey(current) !== routeKey(linked) && source !== 'hash' && innerAttempts < INNER_ATTEMPT_LIMIT) {
849
- innerAttempts += 1
850
- host.openPanelPage(linked.id, linked.inner)
851
- }
852
- armRetry()
853
- return
854
- }
855
- // A session link: refuse immediately when the conversation cannot be opened at
856
- // all, otherwise hand the navigation over once and wait for the view to land.
857
- if (host.isArchived(linked.id)) {
858
- host.warn(`${TAG} ${routeHash(linked.kind, linked.id)} is archived; the link was ignored`)
859
- dropLink()
860
- reflect(canonical())
861
- return
862
- }
863
- if ((!claimed || moved) && host.isOpenable(linked.id)) {
864
- claimed = true
865
- host.openSession(linked.id)
866
- }
867
- }
868
- /** Adopt the surface the address bar names, if it names one. */
869
- const readLink = () => {
870
- if (disposed) return
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)
886
- if (parsed === undefined) {
887
- dropLink()
888
- reconcile('hash')
889
- return
890
- }
891
- if (parsed.unknown !== undefined) {
892
- // Another router's route (or a typo): leave the address bar alone and say so
893
- // once, so a router owning more kinds can take over later.
894
- if (!observed.ignored.includes(parsed.unknown)) observed.ignored.push(parsed.unknown)
895
- if (linked === undefined) host.warn(`${TAG} ignoring unknown route kind "${parsed.unknown}"`)
896
- return
897
- }
898
- if (linked === undefined || linked.kind !== parsed.kind || linked.id !== parsed.id) {
899
- linked = { kind: parsed.kind, id: parsed.id, inner: parsed.inner, deadline: host.now() + LINK_ENFORCE_WINDOW_MS }
900
- if (parsed.kind === 'panel' && parsed.inner !== undefined) panelScope = innerPath(parsed.inner)[0]
901
- claimed = false
902
- panelAttempts = 0
903
- innerAttempts = 0
904
- seen = 'none'
905
- }
906
- dropTimer()
907
- cancelTimer = host.setTimer(() => {
908
- cancelTimer = undefined
909
- // Fires from the browser, outside any notification: guard it too.
910
- guard(() => reconcile('timer'))
911
- }, LINK_ENFORCE_WINDOW_MS)
912
- reconcile('boot')
913
- }
914
-
915
- /** Whether an internal error was already reported (one diagnostic per instance). */
916
- let crashed = false
917
- /**
918
- * Run a machine step so that nothing it meets can escape into the service that
919
- * notified us: a broken face or an unexpected value must not take the page down.
920
- */
921
- const guard = step => {
922
- try {
923
- step()
924
- } catch (error) {
925
- if (crashed) return
926
- crashed = true
927
- host.warn(`${TAG} internal error contained: ${error?.message ?? String(error)}`)
928
- }
929
- }
930
- const safeReconcile = source => guard(() => reconcile(source))
931
- /**
932
- * Keep retrying an unsatisfied link on a timer.
933
- *
934
- * Notifications alone are not enough: after boot the shell can go quiet, so a claim
935
- * that was refused once (a panel registered a moment later, a control not rendered
936
- * yet) would never be retried and the link would sit there doing nothing. The timer
937
- * stops as soon as the link lands, is dropped, or its window expires.
938
- */
939
- const armRetry = () => {
940
- if (disposed || linked === undefined || retryTimer !== undefined) return
941
- retryTimer = host.setTimer(() => {
942
- retryTimer = undefined
943
- safeReconcile('retry')
944
- }, LINK_RETRY_MS)
945
- }
946
- const dropRetry = () => {
947
- retryTimer?.()
948
- retryTimer = undefined
949
- }
950
- const safeReadLink = () => guard(readLink)
951
- const offPanel = host.onPanelChange(() => safeReconcile('panel'))
952
- const offCatalog = host.onCatalogChange(() => safeReconcile('catalog'))
953
- const offDom = host.onDomChange(() => safeReconcile('dom'))
954
- const offHashChange = host.onHashChange(() => safeReadLink())
955
- safeReadLink()
956
- // Pointing at a turn records a position, never a navigation: the listener is passive
957
- // and it refuses to act on any interactive element, so clicks keep working exactly as
958
- // the chat expects. Clicking a turn is how the address bar learns "which turn".
959
- const onDocumentClick = event => {
960
- if (disposed || event.button !== 0 || event.defaultPrevented) return
961
- if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
962
- const target = event.target
963
- if (typeof target?.closest !== 'function') return
964
- // A trajectory row names itself first: it carries its own identity (and is often a
965
- // button), while a chat turn carries only a turn number. Both paths are pure observers
966
- // — nothing is prevented, so clicks keep behaving exactly as the views expect.
967
- const session = host.mainViewSessionId()
968
- const rowOwner = attemptValue(() => host.rowIdentityOf(target))
969
- if (rowOwner !== undefined) {
970
- if (session !== undefined) {
971
- if (rowOwner[0] === TURN_SEGMENT && rowOwner.length === 2) {
972
- turnAnchor = { session, turn: rowOwner[1] }
973
- rowAnchor = undefined
974
- } else {
975
- // The view's own key carries the turn for assistant nodes but not for tool
976
- // calls; when the row sits between two rendered turn boundaries the turn is
977
- // provable, so the link may say it. Otherwise it stays out of the route.
978
- const turn = rowOwner.includes(TURN_SEGMENT) ? undefined : attemptValue(() => host.turnOfRow(target))
979
- rowAnchor = turn === undefined || turn === '' ? { session, parts: rowOwner } : { session, turn, parts: rowOwner }
980
- turnAnchor = undefined
981
- }
982
- safeReconcile('row')
983
- }
984
- return
985
- }
986
- // One selector at a time: a comma list is the kind of thing a small DOM stub (and a
987
- // future minimal engine) may not implement, and the answer must be the same.
988
- for (const interactive of ['button', 'a', 'input', 'textarea', 'select', '[role="button"]', '[contenteditable="true"]']) {
989
- if (target.closest(interactive) !== null) return
990
- }
991
- const node = target.closest(`[${TURN_ATTRIBUTE}]`)
992
- if (node === null) return
993
- const id = session
994
- if (id === undefined) return
995
- const raw = node.getAttribute?.(TURN_ATTRIBUTE)
996
- const turn = typeof raw === 'string' ? raw.trim() : ''
997
- if (turn === '') return
998
- turnAnchor = { session: id, turn }
999
- rowAnchor = undefined
1000
- safeReconcile('turn') }
1001
- try {
1002
- window.document?.addEventListener?.('click', onDocumentClick, true)
1003
- } catch {
1004
- // A composition without the DOM listener is still a working router.
1005
- }
1006
-
1007
- /**
1008
- * The route health report: every layer this router knows about, the shape it is in right
1009
- * now, and everything it refused to touch. Reachable as `window.__dshUrlRouter.report()`
1010
- * — a debug seam, so it never throws and never writes anything.
1011
- * @returns {object} the report.
1012
- */
1013
- const report = () => {
1014
- const current = attemptValue(() => canonical())
1015
- const document = window.document
1016
- const count = selector => attemptValue(() => document?.querySelectorAll?.(selector)?.length) ?? '\u2014'
1017
- return {
1018
- version: ROUTER_VERSION,
1019
- hash: attemptValue(() => host.readLocation().hash) ?? '',
1020
- canonical: current === undefined ? null : { kind: current.kind, id: current.id, inner: innerPath(current.inner) },
1021
- link: linked === undefined ? null : { hash: routeHash(linked.kind, linked.id, linked.inner), attempts: { panel: panelAttempts, inner: innerAttempts } },
1022
- surfaces: {
1023
- conversationRows: count(`[data-row-key^="${ROW_KEY_PREFIX}"]`),
1024
- workspaceRows: count(`[data-row-key^="${WORKSPACE_ROW_PREFIX}"]`),
1025
- viewTabs: count(`[${CONVERSATION_TABS_ATTRIBUTE}]`),
1026
- viewIds: attemptValue(() => host.conversationViewIds?.()) ?? [],
1027
- turns: count(`[${TURN_ATTRIBUTE}]`),
1028
- trajectoryRows: count(`[${TRAJECTORY_ROW_ATTRIBUTE}]`),
1029
- turnBoundaries: count(`[${TRAJECTORY_TURN_START_ATTRIBUTE}]`),
1030
- pluginPackages: count('[data-plugin-package]'),
1031
- officialItems: count('[data-plugin-item]'),
1032
- settingsOverlay: count(`[${SETTINGS_SURFACE_ATTRIBUTE}="${SETTINGS_SURFACE_VALUE}"]`),
1033
- settingsSection: attemptValue(() => host.activeSettingsSection()) ?? null,
1034
- taskBoardOpen: count(`[${TASK_BOARD_OPEN_ATTRIBUTE}]`),
1035
- taskBoardNew: count(`[${TASK_BOARD_NEW_ATTRIBUTE}]`),
1036
- paneActive: attemptValue(() => host.activePaneKind()) ?? null,
1037
- paneKinds: attemptValue(() => host.paneKinds?.()) ?? null,
1038
- },
1039
- compat: observed.compat,
1040
- foreignFragment: observed.foreignFragment,
1041
- ignoredKinds: observed.ignored,
1042
- warnings: observed.warnings,
1043
- }
1044
- }
1045
- guard(() => {
1046
- window.__dshUrlRouter = { version: ROUTER_VERSION, report, when: host.now() }
1047
- })
1048
-
1049
- return () => {
1050
- disposed = true
1051
- try {
1052
- window.document?.removeEventListener?.('click', onDocumentClick, true)
1053
- } catch {
1054
- // Nothing to undo when the listener was never attached.
1055
- }
1056
- dropTimer()
1057
- dropRetry()
1058
- offPanel()
1059
- offCatalog()
1060
- offDom()
1061
- offHashChange()
1062
- }
1063
- }
1064
-
1065
- // ---------------------------------------------------------------------
1066
- // row links: every conversation row in the sidebar becomes a real anchor.
1067
- //
1068
- // A real <a href="#/session/<id>" target="_blank"> is what makes the link a
1069
- // first-class thing instead of an address-bar convention: left click and
1070
- // middle click open the conversation in a new tab, and the browser's own
1071
- // context menu offers "open link in new tab", "copy link", "save link as" —
1072
- // no custom menu to maintain.
1073
- //
1074
- // The row's identity rides its `data-row-key="session:<id>"` attribute (the
1075
- // official sidebar list is virtualized, so rows come and go; a
1076
- // MutationObserver re-scans). Anything unexpected — no document, no rows, no
1077
- // observer — degrades to doing nothing at all.
1078
- // ---------------------------------------------------------------------
1079
- /** Row attribute carrying the conversation identity. */
1080
- const ROW_KEY_ATTRIBUTE = 'data-row-key'
1081
- /** Prefix of that attribute's value for a conversation row. */
1082
- const ROW_KEY_PREFIX = 'session:'
1083
- /** Rows this plugin looks at. */
1084
- const ROW_SELECTOR = `[${ROW_KEY_ATTRIBUTE}^="${ROW_KEY_PREFIX}"]`
1085
- /** Marks the anchor this plugin owns inside a row. */
1086
- const ANCHOR_PART = 'url-router-link'
1087
- /** Id of the stylesheet this plugin injects. */
1088
- const STYLE_ID = 'dsh-url-router-style'
1089
- /** Marks a row whose positioning context this plugin had to establish. */
1090
- const HOST_ATTRIBUTE = 'data-dsh-url-router-host'
1091
- /** Tooltip naming both the click and the right-click behaviour. */
1092
- const LINK_TITLE = '在新标签页中打开 · 右键可复制链接 / Open in a new tab · right-click to copy the link'
1093
- /** One stylesheet for every anchor; the icon stays invisible until the row is hovered. */
1094
- const STYLE_TEXT = [
1095
- // The anchor covers the whole row, so a right-click anywhere on the row
1096
- // reaches the browser's link menu exactly like any other link. It is
1097
- // transparent, sits under everything (z-index 0), and the row's own controls
1098
- // are lifted above it so their clicks keep working.
1099
- // z-index 3 keeps the overlay above the row's own hover mask; the row's
1100
- // controls are lifted to 4. The :not() is load-bearing: without it this
1101
- // rule also matches THE OVERLAY ITSELF (it is an <a>), turns it into a
1102
- // zero-size relative element, and the right-click never reaches it.
1103
- '.dsh-url-router-link{position:absolute;inset:0;z-index:3;display:block;border-radius:inherit;cursor:pointer;text-decoration:none}',
1104
- `[${HOST_ATTRIBUTE}] :is(button,[role="button"],a,select,input,textarea):not(.dsh-url-router-link){position:relative;z-index:4}`,
1105
- ].join('')
1106
-
1107
- /**
1108
- * Read the conversation identity out of one row.
1109
- * @param {object} row - a candidate row element.
1110
- * @returns {string|undefined} the session id, or undefined when the row names none.
1111
- */
1112
- function sessionIdOfRow(row) {
1113
- if (row === null || row === undefined || typeof row.getAttribute !== 'function') return undefined
1114
- const key = row.getAttribute(ROW_KEY_ATTRIBUTE)
1115
- if (typeof key !== 'string' || !key.startsWith(ROW_KEY_PREFIX)) return undefined
1116
- const id = key.slice(ROW_KEY_PREFIX.length).trim()
1117
- return id === '' ? undefined : id
1118
- }
1119
-
1120
- /**
1121
- * Build the row-wide anchor for one conversation.
1122
- *
1123
- * It covers the row, so a right-click anywhere reaches the browser's link menu
1124
- * (open in new tab / copy link / save link as) and a middle or modified click
1125
- * opens a new tab natively. A plain left click opens the conversation HERE, by
1126
- * calling the official navigation with the same id the row would use: the
1127
- * address bar therefore keeps being written with replaceState instead of
1128
- * gaining a history entry per click, and the behaviour does not depend on the
1129
- * row's own hit testing.
1130
- * @param {object} document - the document to create the node in.
1131
- * @param {string} id - session identity the link names.
1132
- * @returns {object} the anchor node.
1133
- */
1134
- function rowAnchor(document, href, open) {
1135
- const anchor = document.createElement('a')
1136
- anchor.setAttribute('href', href)
1137
- anchor.setAttribute('target', '_blank')
1138
- anchor.setAttribute('rel', 'noreferrer')
1139
- anchor.setAttribute('title', LINK_TITLE)
1140
- anchor.setAttribute('aria-label', LINK_TITLE)
1141
- anchor.setAttribute('data-dsh-plugin', 'dsh-url-router')
1142
- anchor.setAttribute('data-dsh-part', ANCHOR_PART)
1143
- anchor.className = 'dsh-url-router-link'
1144
- anchor.addEventListener('click', event => {
1145
- if (event === null || event.defaultPrevented === true) return
1146
- const plain = (event.button === undefined || event.button === 0)
1147
- && event.metaKey !== true && event.ctrlKey !== true
1148
- && event.shiftKey !== true && event.altKey !== true
1149
- if (!plain) return
1150
- if (typeof event.preventDefault === 'function') event.preventDefault()
1151
- if (typeof event.stopPropagation === 'function') event.stopPropagation()
1152
- try {
1153
- open()
1154
- } catch {
1155
- // Navigation belongs to the view owner: if it refuses, the link has
1156
- // still done nothing harmful to the shell.
1157
- }
1158
- })
1159
- return anchor
1160
- }
1161
-
1162
- /**
1163
- * Give one row its anchor, if it needs one.
1164
- * @param {object} document - the document the row lives in.
1165
- * @param {object} row - candidate row element.
1166
- * @returns {boolean} whether the row now carries an anchor.
1167
- */
1168
- function mountAnchor(document, row, href, open) {
1169
- if (typeof row.querySelector !== 'function' || typeof row.appendChild !== 'function') return false
1170
- if (row.querySelector(`[data-dsh-part="${ANCHOR_PART}"]`) !== null) return true
1171
- // The anchor is absolutely positioned, so the row must be a positioning
1172
- // context; only take that over when the row does not already establish one,
1173
- // and remember which case it was so disposal only undoes our own change.
1174
- const style = typeof window.getComputedStyle === 'function' ? window.getComputedStyle(row) : undefined
1175
- const owns = style === undefined || style === null || style.position === 'static'
1176
- if (owns && row.style !== undefined) row.style.position = 'relative'
1177
- if (typeof row.setAttribute === 'function') row.setAttribute(HOST_ATTRIBUTE, owns ? 'own' : 'shared')
1178
- const anchor = rowAnchor(document, href, open)
1179
- // Prepended so the row's own controls — lifted by the stylesheet — stay in
1180
- // front of the overlay; appended, the overlay would swallow them.
1181
- if (typeof row.insertBefore === 'function') row.insertBefore(anchor, row.firstChild ?? null)
1182
- else row.appendChild(anchor)
1183
- return true
1184
- }
1185
-
1186
- /** Slot the sidebar renders its main-panel rows from. */
1187
- const PANEL_LIST_SLOT = 'sidebar.panellist'
1188
-
1189
- /**
1190
- * Read the registered main panels in the order the sidebar renders them.
1191
- *
1192
- * The registry is the only source of a panel's identity: the row itself carries
1193
- * its label and nothing else. A label may be a function (resolved per locale by
1194
- * the shell), so it is only used for matching when it is a plain string; rows are
1195
- * aligned by `order` otherwise.
1196
- * @param {object} slots - the client slots service, when the composition has one.
1197
- * @returns {Array<{id: string, order: number, label: string|undefined}>} registry rows.
1198
- */
1199
- function readPanels(slots) {
1200
- if (typeof slots?.entries !== 'function') return []
1201
- let stored
1202
- try {
1203
- stored = slots.entries(PANEL_LIST_SLOT)
1204
- } catch {
1205
- return []
1206
- }
1207
- if (!Array.isArray(stored)) return []
1208
- return stored
1209
- .map((entry, index) => {
1210
- const options = entry?.options ?? {}
1211
- const id = typeof options.id === 'string' ? options.id : undefined
1212
- const order = typeof options.order === 'number' ? options.order : index
1213
- const label = typeof options.label === 'string' ? options.label : undefined
1214
- return { id, order, label, index }
1215
- })
1216
- .filter(entry => entry.id !== undefined && entry.id !== '')
1217
- .sort((left, right) => left.order - right.order || left.index - right.index)
1218
- }
1219
-
1220
- /**
1221
- * The main-panel rows the official sidebar renders.
1222
- *
1223
- * Located structurally (`data-slot`) rather than by a localized accessible name,
1224
- * so the same hook works in every language.
1225
- * @param {object} document - the document to scan.
1226
- * @returns {Array} the panel row elements, in render order.
1227
- */
1228
- function panelRows(document) {
1229
- const rows = []
1230
- try {
1231
- for (const button of document.querySelectorAll('button')) {
1232
- if (typeof button.querySelector === 'function' && button.querySelector(`[data-slot="${PANEL_LIST_SLOT}"]`) !== null) rows.push(button)
1233
- }
1234
- } catch {
1235
- return []
1236
- }
1237
- return rows
1238
- }
1239
-
1240
- /**
1241
- * Give one conversation row its link to the conversation route.
1242
- * @param {object} document - the document the row lives in.
1243
- * @param {object} row - candidate row element.
1244
- * @param {Function} openSession - navigation for a plain left click.
1245
- * @returns {boolean} whether the row now carries an anchor.
1246
- */
1247
- function enhanceRow(document, row, openSession) {
1248
- const id = sessionIdOfRow(row)
1249
- if (id === undefined) return false
1250
- // The workspace the row sits under leads the link, so a copied link says where the
1251
- // conversation lives. No workspace row in the tree means no guess: the plain route.
1252
- const workspaceName = workspaceNameOf(row, document)
1253
- const href = workspaceName === undefined
1254
- ? routeHash('session', id)
1255
- : routeHash('workspace', workspaceName, innerPath([WORKSPACE_SESSION_SEGMENT, id]))
1256
- return mountAnchor(document, row, href, () => openSession(id))
1257
- }
1258
-
1259
- /**
1260
- * Give one main-panel row its link to the panel route.
1261
- *
1262
- * A panel row carries no identity in the DOM, so the id has to come from the
1263
- * registry the sidebar renders from; rows are matched by accessible name when
1264
- * the registered label is a plain string, and by position otherwise. A row that
1265
- * cannot be matched is left exactly as it was.
1266
- * @param {object} document - the document the row lives in.
1267
- * @param {object} row - candidate row element.
1268
- * @param {Array} panels - the registry rows, in render order.
1269
- * @param {Function} selectPanel - panel selection for a plain left click.
1270
- * @returns {boolean} whether the row now carries an anchor.
1271
- */
1272
- function enhancePanelRow(document, row, panels, index, useIndex, selectPanel) {
1273
- const name = typeof row.getAttribute === 'function' ? row.getAttribute('aria-label') : undefined
1274
- let target
1275
- if (typeof name === 'string' && name !== '') target = panels.find(entry => entry.label === name)
1276
- if (target === undefined && useIndex) target = panels[index]
1277
- if (target === undefined) return false
1278
- return mountAnchor(document, row, routeHash('panel', target.id), () => selectPanel(target.id))
1279
- }
1280
-
1281
- /**
1282
- * Turn every conversation row into a link, and keep doing so as the list
1283
- * virtualizes.
1284
- * @param {object} document - the document to enhance (may be absent).
1285
- * @returns {() => void} the disposer removing every anchor, the stylesheet and the observer.
1286
- */
1287
- function startRowLinks(document, openSession, slots, selectPanel) {
1288
- if (document === undefined || document === null) return () => {}
1289
- if (typeof document.createElement !== 'function' || typeof document.querySelectorAll !== 'function') return () => {}
1290
- const scan = () => {
1291
- let linked = 0
1292
- try {
1293
- for (const row of document.querySelectorAll(ROW_SELECTOR)) if (enhanceRow(document, row, openSession)) linked += 1
1294
- } catch {
1295
- // A DOM that cannot be scanned is left untouched: the address-bar sync
1296
- // is the plugin's contract, the row links are an addition to it.
1297
- }
1298
- try {
1299
- for (const row of document.querySelectorAll('li[data-plugin-item]')) {
1300
- const id = typeof row.getAttribute === 'function' ? row.getAttribute('data-plugin-item') : null
1301
- if (typeof id !== 'string' || id === '') continue
1302
- const open = () => {
1303
- selectPanel(PLUGINS_PANEL_ID)
1304
- const button = typeof row.querySelector === 'function' ? row.querySelector('button') : null
1305
- if (typeof button?.click === 'function') button.click()
1306
- }
1307
- if (mountAnchor(document, row, routeHash('panel', PLUGINS_PANEL_ID, id), open)) linked += 1
1308
- }
1309
- } catch {
1310
- // Same policy as every other row pass.
1311
- }
1312
- try {
1313
- for (const row of document.querySelectorAll(`li[data-plugin-package]`)) {
1314
- const name = typeof row.getAttribute === 'function' ? row.getAttribute('data-plugin-package') : null
1315
- if (typeof name !== 'string' || name === '') continue
1316
- const open = () => {
1317
- // Select the owning panel first, then let its own control open the page:
1318
- // the panel stays the only thing that knows how a page opens.
1319
- selectPanel(PLUGINS_PANEL_ID)
1320
- const button = typeof row.querySelector === 'function' ? row.querySelector('button') : null
1321
- if (typeof button?.click === 'function') button.click()
1322
- }
1323
- if (mountAnchor(document, row, routeHash('panel', PLUGINS_PANEL_ID, name), open)) linked += 1
1324
- }
1325
- } catch {
1326
- // Same policy: a list row that cannot be read stays as it was.
1327
- }
1328
- try {
1329
- const panels = readPanels(slots)
1330
- const rows = panelRows(document)
1331
- // Index alignment is only trustworthy when every registered panel has a
1332
- // row; otherwise a row is linked only when its label matches exactly.
1333
- const useIndex = panels.length > 0 && panels.length === rows.length
1334
- for (let index = 0; index < rows.length; index += 1) {
1335
- if (enhancePanelRow(document, rows[index], panels, index, useIndex, selectPanel)) linked += 1
1336
- }
1337
- } catch {
1338
- // Same policy for the panel list: an unmatched row stays as it was.
1339
- }
1340
- return linked
1341
- }
1342
- let style
1343
- try {
1344
- style = document.createElement('style')
1345
- style.id = STYLE_ID
1346
- style.textContent = STYLE_TEXT
1347
- const parent = document.head ?? document.body
1348
- if (parent !== undefined && parent !== null && typeof parent.appendChild === 'function') parent.appendChild(style)
1349
- } catch {
1350
- style = undefined
1351
- }
1352
- let observer
1353
- try {
1354
- const root = document.body ?? document.documentElement
1355
- if (root !== undefined && root !== null && typeof window.MutationObserver === 'function') {
1356
- observer = new window.MutationObserver(scan)
1357
- observer.observe(root, { childList: true, subtree: true })
1358
- }
1359
- } catch {
1360
- observer = undefined
1361
- }
1362
- const linked = scan()
1363
- if (linked === undefined) return () => {}
1364
- 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)`)
1365
- return () => {
1366
- try {
1367
- observer?.disconnect()
1368
- style?.remove()
1369
- for (const row of [...document.querySelectorAll(ROW_SELECTOR), ...panelRows(document)]) {
1370
- row.querySelector(`[data-dsh-part="${ANCHOR_PART}"]`)?.remove()
1371
- const host = typeof row.getAttribute === 'function' ? row.getAttribute(HOST_ATTRIBUTE) : null
1372
- if (host !== null && typeof row.removeAttribute === 'function') {
1373
- row.removeAttribute(HOST_ATTRIBUTE)
1374
- // Only undo the positioning context this plugin established itself.
1375
- if (host === 'own' && row.style !== undefined) row.style.position = ''
1376
- }
1377
- }
1378
- } catch {
1379
- // Disposal is best effort; nothing here may break the shell.
1380
- }
1381
- }
1382
- }
1383
- // ---------------------------------------------------------------------
1384
- // browser wiring: probe the official faces, then drive the machine.
1385
- // ---------------------------------------------------------------------
1386
- /** Services this plugin needs before apply runs; `layout` is optional and read through `ctx.get`. */
1387
- const inject = ['sessions', 'workspaces', 'uiWorkspace', 'slots', 'sidebarRightTabs']
1388
-
1389
- /**
1390
- * Probe the faces the router needs.
1391
- *
1392
- * A changed surface returns null so the plugin leaves the official behavior
1393
- * untouched. The layout face is optional: without it the plugin still routes
1394
- * conversations and simply never claims a panel.
1395
- * @param {object} scope - client context.
1396
- * @returns {{faces: object}|null} the probed faces, or null when the session faces are unusable.
1397
- */
1398
- function probeFaces(scope) {
1399
- const list = scope.sessions?.list
1400
- const registry = scope.workspaces?.list
1401
- const navigation = scope.uiWorkspace
1402
- if (typeof list !== 'object' || list === null) return null
1403
- if (typeof list.getSnapshot !== 'function' || typeof list.subscribe !== 'function') return null
1404
- if (typeof registry !== 'object' || registry === null) return null
1405
- if (typeof registry.getSnapshot !== 'function') return null
1406
- if (typeof navigation !== 'object' || navigation === null) return null
1407
- const openSession = navigation.openSession
1408
- if (typeof openSession !== 'function') return null
1409
- // `slots` is declared in `inject`, but a composition may still omit the package:
1410
- // reading it defensively keeps the panel links optional rather than fatal.
1411
- let slots
1412
- try {
1413
- slots = scope.slots
1414
- } catch {
1415
- slots = undefined
1416
- }
1417
- const layout = typeof scope.get === 'function' ? scope.get('layout') : undefined
1418
- const panelInfo = layout?.panelInfo
1419
- const panels = typeof panelInfo?.getSnapshot === 'function' && typeof panelInfo?.subscribe === 'function' ? panelInfo : undefined
1420
- const selectPanel = typeof layout?.selectPanel === 'function' ? layout.selectPanel.bind(layout) : undefined
1421
- const beginNavigation = typeof layout?.beginNavigation === 'function' ? layout.beginNavigation.bind(layout) : undefined
1422
- const openRightbar = typeof layout?.openRightbar === 'function' ? layout.openRightbar.bind(layout) : undefined
1423
- const sidebarRightTabs = typeof scope.get === 'function' ? scope.get('sidebarRightTabs') : undefined
1424
- const sidebarRight = typeof scope.get === 'function' ? scope.get('sidebarRight') : undefined
1425
- return {
1426
- faces: {
1427
- catalog: list,
1428
- archivedSessionIds: () => registry.getSnapshot().archivedSessionIds,
1429
- openSession: id => openSession.call(navigation, id),
1430
- selectPanel: selectPanel === undefined ? undefined : id => selectPanel(id),
1431
- beginNavigation: beginNavigation === undefined ? undefined : () => beginNavigation(),
1432
- openRightbar: openRightbar === undefined ? undefined : track => openRightbar(track),
1433
- sidebarRightTabs,
1434
- sidebarRight,
1435
- panels,
1436
- slots,
1437
- },
1438
- }
1439
- }
1440
-
1441
- /**
1442
- * Build the live browser host over the probed faces.
1443
- * @param {object} faces - the probed faces.
1444
- * @returns {object} the host the state machine drives.
1445
- */
1446
- function browserHost(faces) {
1447
- /**
1448
- * Call a host face defensively: a refused navigation or a service that
1449
- * changed its mind must never take the page down with it.
1450
- */
1451
- const attempt = (fn, fallback) => {
1452
- try {
1453
- return fn()
1454
- } catch {
1455
- return fallback
1456
- }
1457
- }
1458
- const archived = () => {
1459
- const ids = attempt(() => faces.archivedSessionIds(), [])
1460
- return Array.isArray(ids) ? ids : []
1461
- }
1462
- return {
1463
- mainViewSessionId: () => attempt(() => mainViewSessionId(faces.catalog.getSnapshot().byId), undefined),
1464
- // The sidebar lists workspaces and their sessions in one tree, in order, so the
1465
- // workspace a conversation belongs to is the nearest workspace row before it.
1466
- workspaceNameOf: sessionId => {
1467
- const rows = window.document?.querySelectorAll?.('[data-row-key]')
1468
- if (rows === undefined || rows === null) return undefined
1469
- let name
1470
- for (const row of rows) {
1471
- const key = typeof row.getAttribute === 'function' ? row.getAttribute('data-row-key') : null
1472
- if (typeof key !== 'string') continue
1473
- if (key.startsWith(WORKSPACE_ROW_PREFIX)) {
1474
- name = (row.textContent ?? '').trim()
1475
- continue
1476
- }
1477
- if (key !== `${ROW_KEY_PREFIX}${sessionId}`) continue
1478
- return name === undefined || name === '' ? undefined : name
1479
- }
1480
- return undefined
1481
- },
1482
- activePanelId: () => (faces.panels === undefined ? undefined : attempt(() => faces.panels.getSnapshot()?.activePanelId, undefined)),
1483
- // The panel that owns inner pages advertises them with a data attribute; the
1484
- // router only reads it, never the panel's internals.
1485
- // The inner page a panel is showing, in that panel's own vocabulary. Each panel
1486
- // publishes it with a data attribute; reading it is the whole reflect direction.
1487
- panelPage: (panelId, scope) => {
1488
- if (panelId === TASK_BOARD_PANEL_ID) {
1489
- // The form and a task are mutually exclusive, and the form wins when both
1490
- // would be reported (it is the page the user is looking at).
1491
- if (window.document?.querySelector?.(`[${TASK_BOARD_NEW_ATTRIBUTE}]`) != null) return ['new']
1492
- const value = attributeOf(`[${TASK_BOARD_OPEN_ATTRIBUTE}]`, TASK_BOARD_OPEN_ATTRIBUTE)
1493
- return value === undefined ? undefined : ['task', value]
1494
- }
1495
- if (panelId !== PLUGINS_PANEL_ID) return undefined
1496
- const row = splitRowHook(attributeOf('[data-plugin-row-detail]', 'data-plugin-row-detail'))
1497
- if (row !== undefined) {
1498
- // The page names its package when it can; otherwise the package is the deepest
1499
- // segment the address bar already carried.
1500
- const pkg = row.packageName ?? scope
1501
- return pkg === undefined ? [row.rowId] : innerPath([pkg, row.rowId])
1502
- }
1503
- // The built-in plugins (the official group) have their own page hook.
1504
- const item = attributeOf('[data-plugin-item-detail]', 'data-plugin-item-detail')
1505
- if (item !== undefined) return [item]
1506
- const detail = attributeOf('[data-plugin-detail]', 'data-plugin-detail')
1507
- return detail === undefined ? undefined : [detail]
1508
- },
1509
- openPanelPage: (panelId, inner) => {
1510
- const asked = innerPath(inner)
1511
- if (panelId === TASK_BOARD_PANEL_ID) {
1512
- // The task board owns how its overlays open: we hand over the request on its
1513
- // own contract (window events) and nothing else crosses the seam.
1514
- if (asked[0] === 'new' && asked.length === 1) {
1515
- attempt(() => window.dispatchEvent(new window.CustomEvent(TASK_BOARD_NEW_EVENT)))
1516
- return
1517
- }
1518
- if (asked[0] !== 'task' || asked.length < 2) return
1519
- attempt(() =>
1520
- window.dispatchEvent(
1521
- new window.CustomEvent(TASK_BOARD_OPEN_EVENT, { detail: { taskId: asked.slice(1).join('/') } }),
1522
- ),
1523
- )
1524
- return
1525
- }
1526
- if (panelId !== PLUGINS_PANEL_ID) return
1527
- const segments = asked
1528
- if (segments.length === 0) return
1529
- // The component page lives inside its package page, so that page is opened
1530
- // first and the row is clicked once it exists.
1531
- if (segments.length === 1 || window.document?.querySelector?.('[data-plugin-detail]') === null) {
1532
- // A segment names either an installed package or a built-in plugin.
1533
- const pkg =
1534
- window.document?.querySelector?.(`li[data-plugin-package="${segments[0]}"]`) ??
1535
- window.document?.querySelector?.(`li[data-plugin-item="${segments[0]}"]`)
1536
- const button = pkg?.querySelector?.('button')
1537
- if (typeof button?.click === 'function') button.click()
1538
- if (segments.length === 1) return
1539
- }
1540
- const rowId = segments[segments.length - 1]
1541
- const rows = window.document?.querySelectorAll?.('li[data-plugin-row]') ?? []
1542
- for (const row of rows) {
1543
- const parsed = splitRowHook(typeof row.getAttribute === 'function' ? row.getAttribute('data-plugin-row') : null)
1544
- if (parsed === undefined || parsed.rowId !== rowId) continue
1545
- const button = row.querySelector?.('button')
1546
- if (typeof button?.click === 'function') button.click()
1547
- return
1548
- }
1549
- },
1550
- isArchived: id => archived().includes(id),
1551
- isOpenable: id =>
1552
- attempt(() => {
1553
- const snapshot = faces.catalog.getSnapshot()
1554
- if (snapshot.phase !== 'ready') return false
1555
- if (archived().includes(id)) return false
1556
- return snapshot.ids.includes(id) || snapshot.byId[id] !== undefined
1557
- }, false),
1558
- // A panel id nobody registered is refused by the layout by THROWING
1559
- // (`selectPanel: main panel "x" is not registered`), so the claim is
1560
- // swallowed here and reported as "the view did not move" at expiry.
1561
- openSession: id => {
1562
- attempt(() => faces.openSession(id))
1563
- },
1564
- // A row's own identity, in the shape the trajectory views publish it. The value is a
1565
- // percent-encoded NUL-separated triple, so the route can read as a plain path.
1566
- rowIdentityOf: element => {
1567
- let node = element
1568
- for (let depth = 0; node !== null && node !== undefined && depth < 12; depth += 1, node = node.parentElement) {
1569
- if (typeof node.getAttribute !== 'function') break
1570
- const key = node.getAttribute(TRAJECTORY_ROW_ATTRIBUTE)
1571
- if (typeof key === 'string' && key !== '') {
1572
- const parts = key.split('%00').map(part => part.trim()).filter(part => part !== '')
1573
- if (parts.length >= 2) return parts
1574
- }
1575
- const turn = node.getAttribute(TRAJECTORY_TURN_ATTRIBUTE)
1576
- if (typeof turn === 'string' && turn.trim() !== '') return [TURN_SEGMENT, turn.trim()]
1577
- }
1578
- return undefined
1579
- },
1580
- // The turn a row belongs to, but only when it is *provable*: the trajectory view
1581
- // virtualizes, so the evidence is a turn boundary rendered before the row, and that is
1582
- // trustworthy exactly when the next boundary is also rendered after it. Otherwise the
1583
- // answer is undefined — never a guess.
1584
- turnOfRow: element => {
1585
- if (typeof element?.compareDocumentPosition !== 'function') return undefined
1586
- const boundaries = window.document?.querySelectorAll?.(`[${TRAJECTORY_TURN_START_ATTRIBUTE}]`)
1587
- if (boundaries === undefined || boundaries === null || boundaries.length === 0) return undefined
1588
- const following = window.Node?.DOCUMENT_POSITION_FOLLOWING ?? 4
1589
- const preceding = window.Node?.DOCUMENT_POSITION_PRECEDING ?? 2
1590
- let before
1591
- let after
1592
- for (const boundary of boundaries) {
1593
- const position = attemptValue(() => element.compareDocumentPosition(boundary)) ?? 0
1594
- const turn = (boundary.getAttribute?.(TRAJECTORY_TURN_START_ATTRIBUTE) ?? '').trim()
1595
- if (turn === '') continue
1596
- if ((position & following) !== 0) after ??= turn
1597
- if ((position & preceding) !== 0) before = turn
1598
- }
1599
- if (before === undefined || after === undefined || before === after) return undefined
1600
- return before
1601
- },
1602
- // Best effort: scroll to the named row when it is rendered.
1603
- revealRow: parts => {
1604
- const wanted = innerPath(parts)
1605
- const document = window.document
1606
- if (wanted[0] === ROW_SEGMENT && wanted.length >= 3) {
1607
- const key = wanted.slice(1).join('%00')
1608
- for (const candidate of document?.querySelectorAll?.(`[${TRAJECTORY_ROW_ATTRIBUTE}]`) ?? []) {
1609
- if (candidate.getAttribute?.(TRAJECTORY_ROW_ATTRIBUTE) !== key) continue
1610
- attempt(() => candidate.scrollIntoView?.({ block: 'center' }))
1611
- return true
1612
- }
1613
- return false
1614
- }
1615
- if (wanted[0] === TURN_SEGMENT) {
1616
- for (const candidate of document?.querySelectorAll?.(`[${TRAJECTORY_TURN_ATTRIBUTE}]`) ?? []) {
1617
- if (candidate.getAttribute?.(TRAJECTORY_TURN_ATTRIBUTE) !== wanted[1]) continue
1618
- attempt(() => candidate.scrollIntoView?.({ block: 'center' }))
1619
- return true
1620
- }
1621
- }
1622
- return false
1623
- },
1624
- // Which conversation view is showing. The registry is the source of truth for ids
1625
- // (`ctx.uiConversation.views`), the header renders its tabs in registration order,
1626
- // and the active tab is the aria-selected one — so the position maps to an id.
1627
- activeConversationView: () => {
1628
- // One selector at a time: the container first, then its tabs inside it.
1629
- const row = window.document?.querySelector?.(`[${CONVERSATION_TABS_ATTRIBUTE}]`)
1630
- const tabs = typeof row?.querySelectorAll === 'function' ? row.querySelectorAll('[role="tab"]') : undefined
1631
- if (tabs === undefined || tabs === null || tabs.length === 0) return undefined
1632
- let index = -1
1633
- for (let position = 0; position < tabs.length; position += 1) {
1634
- const tab = tabs[position]
1635
- if (typeof tab.getAttribute !== 'function') continue
1636
- if (tab.getAttribute('aria-selected') !== 'true') continue
1637
- index = position
1638
- break
1639
- }
1640
- if (index < 0) return undefined
1641
- const ids = conversationViewIdsOf(faces)
1642
- if (ids === undefined || ids.length !== tabs.length) return undefined
1643
- return { id: ids[index], index, total: ids.length }
1644
- },
1645
- openConversationView: viewId => {
1646
- const row = window.document?.querySelector?.(`[${CONVERSATION_TABS_ATTRIBUTE}]`)
1647
- const tabs = typeof row?.querySelectorAll === 'function' ? row.querySelectorAll('[role="tab"]') : undefined
1648
- if (tabs === undefined || tabs === null) return
1649
- const ids = conversationViewIdsOf(faces)
1650
- if (ids === undefined) return
1651
- const index = ids.indexOf(viewId)
1652
- const tab = index < 0 ? undefined : tabs[index]
1653
- if (typeof tab?.click === 'function') attempt(() => tab.click())
1654
- },
1655
- // The right-hand pane names the tab in front. The service is the source of truth;
1656
- // the DOM marker is a fallback for a composition where the service is absent.
1657
- rightBarSessionId: () => rightBarSessionIdOf(window.document),
1658
- activePaneTab: () => activePaneTabOf(window.document),
1659
- activePaneKind: () => {
1660
- // Both services answer `active`; the controller is authoritative when it does, and
1661
- // `active` may be exposed as a value or as a method. Neither exposes `getSnapshot`.
1662
- // `active` can be a string, a tab descriptor or a method returning either, and the
1663
- // bar's own tab objects carry the kind under a few plausible names.
1664
- const kindOf = value => {
1665
- if (typeof value === 'string') return value === '' ? undefined : value
1666
- if (value === null || typeof value !== 'object') return undefined
1667
- const nested = value.tab ?? value.entry ?? value.options
1668
- const candidates = [value.kind, value.id, value.tabId, value.name, nested?.kind, nested?.id]
1669
- for (const candidate of candidates) {
1670
- if (typeof candidate === 'string' && candidate !== '' && candidate !== 'pane1') return candidate
1671
- }
1672
- return undefined
1673
- }
1674
- const read = owner => {
1675
- if (owner === undefined || owner === null) return undefined
1676
- const value = typeof owner.active === 'function' ? attemptValue(() => owner.active()) : owner.active
1677
- const direct = kindOf(value)
1678
- if (direct !== undefined) return direct
1679
- const snapshot = attemptValue(() => (typeof owner.getSnapshot === 'function' ? owner.getSnapshot() : undefined))
1680
- return snapshot === undefined ? undefined : kindOf(snapshot.activeTabId ?? snapshot.activeTab ?? snapshot.active)
1681
- }
1682
- const fromStrip = activePaneTabOf(window.document, paneRegistryOf(faces))
1683
- if (fromStrip !== undefined) return fromStrip
1684
- const fromController = read(faces.sidebarRight)
1685
- if (fromController !== undefined) return fromController
1686
- const fromRegistry = read(faces.sidebarRightTabs)
1687
- if (fromRegistry !== undefined) return fromRegistry
1688
- // DOM fallback, deliberately narrow: the active-pane marker is shared with the
1689
- // main dockkit, so it only counts when it really belongs to the right bar.
1690
- const active = window.document?.querySelector?.('[data-dockkit-pane-active]')
1691
- if (active === null || active === undefined) return undefined
1692
- const own = typeof active.getAttribute === 'function' ? active.getAttribute('data-sidebar-right-tab') : null
1693
- if (typeof own === 'string' && own !== '') return own
1694
- const inside = typeof active.querySelector === 'function' ? active.querySelector('[data-sidebar-right-tab]') : null
1695
- const inner = typeof inside?.getAttribute === 'function' ? inside.getAttribute('data-sidebar-right-tab') : null
1696
- return typeof inner === 'string' && inner !== '' ? inner : undefined
1697
- },
1698
- // The section the overlay shows: the nav item the platform marks aria-current.
1699
- // One selector at a time (a comma list is beyond a small DOM stub) and the `nav`
1700
- // ancestor check keeps unrelated aria-current controls out of it.
1701
- activeSettingsSection: () => {
1702
- const overlay = window.document?.querySelector?.(`[${SETTINGS_SURFACE_ATTRIBUTE}="${SETTINGS_SURFACE_VALUE}"]`)
1703
- if (overlay === null || overlay === undefined || typeof overlay.querySelectorAll !== 'function') return undefined
1704
- for (const candidate of overlay.querySelectorAll('button')) {
1705
- if (typeof candidate.getAttribute !== 'function') continue
1706
- if (candidate.getAttribute('aria-current') !== 'true') continue
1707
- if (typeof candidate.closest === 'function' && candidate.closest('nav') === null) continue
1708
- const label = (candidate.textContent ?? '').trim()
1709
- if (label !== '') return label
1710
- }
1711
- return undefined
1712
- },
1713
- // The tab a settings section is showing: the same ARIA contract one level deeper.
1714
- activeSettingsTab: () => {
1715
- const overlay = window.document?.querySelector?.(`[${SETTINGS_SURFACE_ATTRIBUTE}="${SETTINGS_SURFACE_VALUE}"]`)
1716
- if (overlay === null || overlay === undefined || typeof overlay.querySelectorAll !== 'function') return undefined
1717
- for (const candidate of overlay.querySelectorAll('[role="tab"]')) {
1718
- if (typeof candidate.getAttribute !== 'function') continue
1719
- if (candidate.getAttribute('aria-selected') !== 'true') continue
1720
- const label = (candidate.textContent ?? '').trim()
1721
- if (label !== '') return label
1722
- }
1723
- return undefined
1724
- },
1725
- openSettings: (section, tab) => {
1726
- if (typeof section === 'string' && section !== '') {
1727
- // A section is named by the label the user reads, so the request is a label match.
1728
- const overlay = window.document?.querySelector?.(`[${SETTINGS_SURFACE_ATTRIBUTE}="${SETTINGS_SURFACE_VALUE}"]`)
1729
- const scope = overlay ?? window.document
1730
- if (typeof scope?.querySelectorAll === 'function') {
1731
- const candidates = [...scope.querySelectorAll('button')].filter(candidate => {
1732
- if (typeof candidate.closest === 'function' && candidate.closest('nav') === null) return false
1733
- return (candidate.textContent ?? '').trim() !== ''
1734
- })
1735
- const wanted = section.trim().toLowerCase()
1736
- const match = candidates.find(candidate => (candidate.textContent ?? '').trim().toLowerCase() === wanted)
1737
- if (match !== undefined && typeof match.click === 'function') {
1738
- attempt(() => match.click())
1739
- // The section may own tabs: a named one is clicked once the section is up.
1740
- if (typeof tab === 'string' && tab !== '') {
1741
- const wantedTab = tab.trim().toLowerCase()
1742
- const tabs = [...scope.querySelectorAll('[role="tab"]')]
1743
- const matchTab = tabs.find(candidate => (candidate.textContent ?? '').trim().toLowerCase() === wantedTab)
1744
- if (matchTab !== undefined && typeof matchTab.click === 'function') attempt(() => matchTab.click())
1745
- }
1746
- return
1747
- }
1748
- }
1749
- }
1750
- const slot = window.document?.querySelector?.(`[data-slot="${SETTINGS_TRIGGER_SLOT}"]`)
1751
- const button =
1752
- (typeof slot?.querySelector === 'function' ? slot.querySelector('button') : null) ??
1753
- (typeof slot?.closest === 'function' ? slot.closest('button') : null)
1754
- if (typeof button?.click === 'function') {
1755
- attempt(() => button.click())
1756
- return
1757
- }
1758
- // Fallback for a composition without that slot: the labelled entry.
1759
- const labelled = typeof window.document?.querySelectorAll === 'function'
1760
- ? [...window.document.querySelectorAll('button')].find(candidate => /^(settings|设置)$/i.test((candidate.textContent ?? '').trim()))
1761
- : undefined
1762
- if (typeof labelled?.click === 'function') attempt(() => labelled.click())
1763
- },
1764
- openPane: (kind, track, warn) => {
1765
- if (typeof kind !== 'string' || kind === '') return
1766
- // A pane link is a claim like any other, so it is checked before it is made: the
1767
- // registry names every kind the bar can show, and asking for one it does not know
1768
- // would leave the link sitting there doing nothing. (Measured live: guide,
1769
- // subagentchat, changes-review, plan, files, text, terminal.)
1770
- const known = attemptValue(() => paneRegistryOf(faces))?.map(row => row.kind)
1771
- if (Array.isArray(known) && !known.includes(kind)) {
1772
- warn(`${TAG} the right bar has no pane type "${kind}"; it knows: ${known.join(', ')}`)
1773
- return
1774
- }
1775
- // `openTab(kind, options)` belongs to the controller (`sidebarRight`); the registry
1776
- // only knows the kinds. Asking the registry to open one fails silently.
1777
- const controller = faces.sidebarRight
1778
- // Present the right column first: a session's store is adopted only once the bar is
1779
- // shown, and every open on an unadopted session is a silent no-op.
1780
- const expanded = attemptValue(() => (typeof controller?.isExpanded === 'function' ? controller.isExpanded() : undefined))
1781
- if (expanded === false) {
1782
- const wanted = typeof track === 'string' && track !== '' ? track : (attemptValue(() => faces.mainViewSessionId()) !== undefined ? 'session' : 'panel')
1783
- attempt(() => faces.openRightbar?.(wanted))
1784
- }
1785
- const session = rightBarSessionIdOf(window.document)
1786
- const expand = () => {
1787
- const expanded = attemptValue(() => (typeof controller?.isExpanded === 'function' ? controller.isExpanded() : true))
1788
- if (expanded === false) attempt(() => controller?.toggleExpanded?.())
1789
- }
1790
- if (controller !== undefined && controller !== null) {
1791
- // The controller a root plugin reaches is not "mounted" for a session, and `openTab(kind)`
1792
- // throws exactly then ("no session surface is mounted") — so the per-session path is the
1793
- // real one, and it takes the session **first**: `openTabIn(sessionId, kind, options)`.
1794
- // (Passed the other way round it looks up a session called "files" and does nothing.)
1795
- const opened = attemptValue(() => {
1796
- if (typeof controller.openTabIn === 'function' && typeof session === 'string' && session !== '') {
1797
- controller.openTabIn(session, kind, {})
1798
- return true
1799
- }
1800
- if (typeof controller.openTab === 'function') {
1801
- controller.openTab(kind, {})
1802
- return true
1803
- }
1804
- return false
1805
- })
1806
- if (opened === true) {
1807
- expand()
1808
- return
1809
- }
1810
-
1811
- }
1812
- // Last resort: the same action the right bar's empty-state card runs. It is the
1813
- // bar's own "open this kind" entry, so using it is not a workaround.
1814
- const guides = attemptValue(() => {
1815
- const registry = faces.sidebarRightTabs
1816
- if (registry === undefined || registry === null) return undefined
1817
- const rows = typeof registry.guideEntries === 'function' ? registry.guideEntries() : registry.guideEntries
1818
- if (Array.isArray(rows)) return rows
1819
- const guide = typeof registry.guide === 'function' ? registry.guide() : registry.guide
1820
- return Array.isArray(guide) ? guide : undefined
1821
- })
1822
- if (!Array.isArray(guides)) return
1823
- for (const entry of guides) {
1824
- if (entry === null || typeof entry !== 'object') continue
1825
- const entryKind = entry.kind ?? entry.options?.kind ?? entry.id
1826
- if (typeof entryKind !== 'string' || entryKind !== kind) continue
1827
- const run = entry.onPick ?? entry.action ?? entry.select ?? entry.open ?? entry.run
1828
- if (typeof run === 'function') {
1829
- attempt(() => run())
1830
- expand()
1831
- return
1832
- }
1833
- }
1834
- },
1835
- // The kinds the bar can show: link targets are checked against this before claiming.
1836
- paneKinds: () => paneRegistryOf(faces)?.map(row => row.kind),
1837
- // The rows the registry publishes, for the report and for the drive's check.
1838
- paneRegistry: () => paneRegistryOf(faces),
1839
- // The conversation view ids the registry publishes, for the report.
1840
- conversationViewIds: () => conversationViewIdsOf(faces),
1841
- beginNavigation: () => {
1842
- if (faces.beginNavigation === undefined) return
1843
- attempt(() => faces.beginNavigation())
1844
- },
1845
- selectPanel: id => {
1846
- if (faces.selectPanel === undefined) return
1847
- attempt(() => faces.selectPanel(id))
1848
- },
1849
- readLocation: () =>
1850
- attempt(() => ({
1851
- pathname: window.location.pathname,
1852
- search: window.location.search,
1853
- hash: window.location.hash,
1854
- }), { pathname: '', search: '', hash: '' }),
1855
- replaceUrl: url => {
1856
- attempt(() => window.history.replaceState(null, '', url))
1857
- },
1858
- onPanelChange: handler => (faces.panels === undefined ? () => {} : attempt(() => faces.panels.subscribe(handler), () => {})),
1859
- onCatalogChange: handler => attempt(() => faces.catalog.subscribe(handler), () => {}),
1860
- // A panel's inner page is reported by attributes the panel itself publishes, so
1861
- // DOM changes are a notification source of their own (list <-> detail).
1862
- onDomChange: handler =>
1863
- attempt(() => {
1864
- const root = window.document?.body ?? window.document?.documentElement
1865
- if (root === undefined || root === null || typeof window.MutationObserver !== 'function') return () => {}
1866
- const observer = new window.MutationObserver(handler)
1867
- // Every published page hook must be watched: a filter that names only some of
1868
- // them silently starves the retry loop of notifications (real bug: the task
1869
- // board's drive looked flaky because its attributes were not observed).
1870
- observer.observe(root, {
1871
- childList: true,
1872
- subtree: true,
1873
- attributes: true,
1874
- attributeFilter: [
1875
- 'data-plugin-detail',
1876
- 'data-plugin-item-detail',
1877
- 'data-plugin-row-detail',
1878
- TASK_BOARD_OPEN_ATTRIBUTE,
1879
- TASK_BOARD_NEW_ATTRIBUTE,
1880
- // Switching a settings section only moves this marker, so it must be
1881
- // watched or the address bar keeps the section it saw first.
1882
- 'aria-current',
1883
- // Switching a settings *tab* moves this one, for the same reason.
1884
- 'aria-selected',
1885
- SETTINGS_SURFACE_ATTRIBUTE,
1886
- ],
1887
- })
1888
- return () => observer.disconnect()
1889
- }, () => {}),
1890
- onHashChange: handler =>
1891
- attempt(() => {
1892
- window.addEventListener('hashchange', handler)
1893
- window.addEventListener('popstate', handler)
1894
- return () => {
1895
- window.removeEventListener('hashchange', handler)
1896
- window.removeEventListener('popstate', handler)
1897
- }
1898
- }, () => {}),
1899
- now: () => attempt(() => Date.now(), 0),
1900
- setTimer: (handler, delayMs) =>
1901
- attempt(() => {
1902
- const timer = window.setTimeout(handler, delayMs)
1903
- return () => window.clearTimeout(timer)
1904
- }, () => {}),
1905
- warn: message => console.warn(message),
1906
- }
1907
- }
1908
-
1909
- /**
1910
- * Register the URL routing on the running GUI.
1911
- * @param {object} ctx - client root context.
1912
- */
1913
- function apply(ctx) {
1914
- // Fragment routing: the address bar names the view, and a link opens it.
1915
- ctx.effect(() => {
1916
- if (typeof window === 'undefined') return () => {}
1917
- const probed = probeFaces(ctx)
1918
- if (probed === null) {
1919
- console.warn(`${TAG} disabled: the sessions/workspaces/uiWorkspace service shape changed`)
1920
- return () => {}
1921
- }
1922
- if (probed.faces.panels === undefined) {
1923
- console.warn(`${TAG} the layout service exposes no panel store; only conversation routes are routed`)
1924
- }
1925
- return startUrlRouting(browserHost(probed.faces))
1926
- }, 'url-router: fragment routing')
1927
- // Row links: every conversation row is exposed as a real link to its route, so
1928
- // the browser's own link menu (open in new tab / copy link) works on it.
1929
- ctx.effect(() => {
1930
- if (typeof window === 'undefined' || window.document === undefined) return () => {}
1931
- const probed = probeFaces(ctx)
1932
- if (probed === null) return () => {}
1933
- const openSession = id => {
1934
- try {
1935
- probed.faces.openSession(id)
1936
- } catch {
1937
- // A refused navigation must not escape into the anchor's click handler.
1938
- }
1939
- }
1940
- const selectPanel = id => {
1941
- try {
1942
- probed.faces.selectPanel(id)
1943
- } catch {
1944
- // The layout throws for an id nobody registered; a link must not.
1945
- }
1946
- }
1947
- const slots = probed.faces.slots
1948
- return startRowLinks(window.document, openSession, slots, selectPanel)
1949
- }, 'url-router: row links')
1950
- }
1951
-
1952
- exports.apply = apply
1953
- exports.inject = inject
1954
- return module.exports
1955
- },
1956
- })
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', settings: 'settings', workspace: 'workspace', workspaces: 'workspace' }
67
+ /** Attribute the settings overlay puts on its root element. */
68
+ const SETTINGS_SURFACE_ATTRIBUTE = 'data-dsh-surface'
69
+ /** Value of that attribute for the settings overlay. */
70
+ const SETTINGS_SURFACE_VALUE = 'settings'
71
+ /** Slot the settings entry is rendered into (its button opens the overlay). */
72
+ const SETTINGS_TRIGGER_SLOT = 'sidebar.settings'
73
+ /** Canonical fragment shape, plus the bare `#/<kind>` form a modal surface uses. */
74
+ const ROUTE_PATTERN = /^#\/?([a-z][a-z0-9-]*)(?:\/(.+))?$/
75
+ /** How often an unsatisfied link is retried while it is authoritative. */
76
+ const LINK_RETRY_MS = 500
77
+ /** How many times an inner page may be requested while its link is authoritative. */
78
+ const INNER_ATTEMPT_LIMIT = 40
79
+ /** How long a linked surface stays authoritative over the view owner's own restore. */
80
+ const LINK_ENFORCE_WINDOW_MS = 8000
81
+ /** Panel id of the task board, whose open-task contract this router drives. */
82
+ const TASK_BOARD_PANEL_ID = 'task-board'
83
+ /** Attribute the task board publishes the open task on. */
84
+ const TASK_BOARD_OPEN_ATTRIBUTE = 'data-dsh-taskboard-open-task'
85
+ /** Window event that asks the task board to open a task. */
86
+ const TASK_BOARD_OPEN_EVENT = 'dsh-taskboard-open-task'
87
+ /** Attribute the task board publishes while its new-task form is open. */
88
+ const TASK_BOARD_NEW_ATTRIBUTE = 'data-dsh-taskboard-new-task'
89
+ /** Window event that asks the task board for its new-task form. */
90
+ const TASK_BOARD_NEW_EVENT = 'dsh-taskboard-new-task'
91
+ /** Sidebar row key prefix for a workspace (the value is its id; the row text is its name). */
92
+ const WORKSPACE_ROW_PREFIX = 'workspace:'
93
+ /** Stable container the conversation header renders its view tabs into. */
94
+ const CONVERSATION_TABS_ATTRIBUTE = 'data-conversation-tabs'
95
+ /** Attribute each trajectory row carries: `<kind>%00<scope>%00<id>`. */
96
+ const TRAJECTORY_ROW_ATTRIBUTE = 'data-trajectory-row-key'
97
+ /** Attribute the family trajectory view puts on a row's turn cell. */
98
+ const TRAJECTORY_TURN_ATTRIBUTE = 'data-dshts-turn'
99
+ /** The right bar's own root: it carries the session the bar belongs to. */
100
+ const RIGHT_BAR_ATTRIBUTE = 'data-sidebar-right-session'
101
+ /** The right bar's tab strip: only *tabs* live here (the empty state has a bare pane). */
102
+ const RIGHT_BAR_STRIP_ATTRIBUTE = 'data-dockkit-strip-tabs'
103
+ /** Attribute the trajectory view puts on the first row of a turn. */
104
+ const TRAJECTORY_TURN_START_ATTRIBUTE = 'data-turn-start'
105
+ /** Inner segment that names one row inside a conversation view. */
106
+ const ROW_SEGMENT = 'row'
107
+ /** Inner segment that names which conversation view is showing. */
108
+ const VIEW_SEGMENT = 'view'
109
+ /** Inner segment that marks the conversation inside a workspace route. */
110
+ const WORKSPACE_SESSION_SEGMENT = 'session'
111
+ /** Attribute the chat view puts on every rendered turn node (the turn number). */
112
+ const TURN_ATTRIBUTE = 'data-chat-turn'
113
+ /** Inner segment that names the turn a conversation is showing. */
114
+ const TURN_SEGMENT = 'turn'
115
+ /** Inner segment that names the right-hand pane in front. */
116
+ const PANE_SEGMENT = 'pane'
117
+ /** Service the right-hand pane registers, whose snapshot names the active tab. */
118
+ const SIDEBAR_RIGHT_SERVICE = 'sidebarRightTabs'
119
+ /** Panel id of the official plugin manager, whose list rows and detail pages are routed. */
120
+ const PLUGINS_PANEL_ID = 'plugins'
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.2'
127
+
128
+ const TAG = '[url-router]'
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
+ /**
137
+ * The conversation view ids the registry publishes, in registration order.
138
+ *
139
+ * `uiConversation` is a **root** client service (measured: `new UiConversation(ctx)` with
140
+ * `super(ctx, "uiConversation")`), and `views` is a public field on it — so the ids come
141
+ * straight from the registry instead of being aligned to the slot by position.
142
+ * @param {object} faces - the probed host faces.
143
+ * @returns {string[]|undefined} the ids, or undefined when the registry is not readable.
144
+ */
145
+ function conversationViewIdsOf(faces) {
146
+ const rows = attemptValue(() => faces.uiConversation?.views?.entries?.())
147
+ if (!Array.isArray(rows)) return undefined
148
+ const ids = rows
149
+ .map(row => (row === null || typeof row !== 'object' ? undefined : row.target ?? row.id))
150
+ .filter(id => typeof id === 'string' && id !== '')
151
+ return ids.length === 0 ? undefined : ids
152
+ }
153
+
154
+ /**
155
+ * Read a value, or undefined when reading it throws.
156
+ * @param {Function} read - the reader.
157
+ * @returns {*} the value, or undefined.
158
+ */
159
+ function attemptValue(read) {
160
+ try {
161
+ return read()
162
+ } catch {
163
+ return undefined
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Resolve the workspace name for every row in one pass over the tree, instead of
169
+ * walking the tree again for every row that needs a name.
170
+ * @param {object} document - the document the tree lives in.
171
+ * @returns {Map<object, string|undefined>} row -> its workspace display name.
172
+ */
173
+ function workspaceNamesOfRows(document) {
174
+ const names = new Map()
175
+ const rows = document?.querySelectorAll?.('[data-row-key]')
176
+ if (rows === undefined || rows === null) return names
177
+ let name
178
+ for (const candidate of rows) {
179
+ const key = typeof candidate.getAttribute === 'function' ? candidate.getAttribute('data-row-key') : null
180
+ if (typeof key !== 'string') continue
181
+ if (key.startsWith(WORKSPACE_ROW_PREFIX)) {
182
+ name = (candidate.textContent ?? '').trim()
183
+ continue
184
+ }
185
+ names.set(candidate, name === undefined || name === '' ? undefined : name)
186
+ }
187
+ return names
188
+ }
189
+
190
+ /**
191
+ * The workspace a sidebar row belongs to: the nearest workspace row before it, read from the
192
+ * row text. Returns undefined when the tree says nothing, so no caller has to guess.
193
+ * @param {object} row - the conversation row.
194
+ * @param {object} document - the document the tree lives in.
195
+ * @returns {string|undefined} the workspace display name.
196
+ */
197
+ function workspaceNameOf(row, document) {
198
+ const rows = document?.querySelectorAll?.('[data-row-key]')
199
+ if (rows === undefined || rows === null) return undefined
200
+ let name
201
+ for (const candidate of rows) {
202
+ const key = typeof candidate.getAttribute === 'function' ? candidate.getAttribute('data-row-key') : null
203
+ if (typeof key !== 'string') continue
204
+ if (key.startsWith(WORKSPACE_ROW_PREFIX)) {
205
+ name = (candidate.textContent ?? '').trim()
206
+ continue
207
+ }
208
+ if (candidate !== row) continue
209
+ return name === undefined || name === '' ? undefined : name
210
+ }
211
+ return undefined
212
+ }
213
+
214
+ /**
215
+ * The kind inside a tab id: the bar builds one as `sidebar://<kind>` and appends a uuid for
216
+ * kinds that may be opened more than once, so the kind is right there in the address.
217
+ * @param {string} value - a tab id.
218
+ * @returns {string|undefined} the kind, when the value is one of these addresses.
219
+ */
220
+ /**
221
+ * The right bar's registry, normalised: one row per page type with its kind, the label
222
+ * the tab shows, whether more than one may be open, and its empty-state card (measured
223
+ * live: `{kind, title(): () => string, multiple?: true, guide?: [{id, order}]}`).
224
+ * @param {object} faces - the probed host faces.
225
+ * @returns {{kind: string, label: string|undefined, multiple: boolean}[]|undefined} the rows.
226
+ */
227
+ /**
228
+ * The seq of a turn's start event, from the `turnOutline` projection.
229
+ *
230
+ * The projection is a public read on the session controller (the core reads it the same
231
+ * way: `session.projections.faceOf("turnOutline").getSnapshot()`), and each entry carries
232
+ * the `seq` of that turn's `turn/start` — which is exactly what `loadThrough` pages back
233
+ * to. Reading it is what makes a turn link work for a turn that is not loaded yet.
234
+ * @param {object} faces - the probed host faces.
235
+ * @param {string|undefined} sessionId - the session the turn belongs to.
236
+ * @param {string} turn - the turn number.
237
+ * @returns {number|undefined} the seq, when the projection names that turn.
238
+ */
239
+ function turnSeqOf(faces, sessionId, turn) {
240
+ if (typeof sessionId !== 'string' || sessionId === '') return undefined
241
+ // Reached by retaining the session's scope: `sessions.binding(id)` answers undefined
242
+ // for a session nobody materialised yet (measured), while `retainScope(id, source)`
243
+ // materialises it and returns a reference whose `binding` is the live one. Released here
244
+ // because only the projection is read.
245
+ const reference = attemptValue(() => faces.sessions?.retainScope?.(sessionId, 'url-router'))
246
+ const outline = attemptValue(() => session?.projections?.faceOf?.('turnOutline')?.getSnapshot?.())
247
+ attempt(() => reference?.release?.())
248
+ if (!Array.isArray(outline)) return undefined
249
+ const wanted = String(turn)
250
+ for (const entry of outline) {
251
+ if (entry === null || typeof entry !== 'object') continue
252
+ if (String(entry.turn) !== wanted) continue
253
+ return typeof entry.seq === 'number' ? entry.seq : undefined
254
+ }
255
+ return undefined
256
+ }
257
+
258
+ function paneRegistryOf(faces) {
259
+ const rows = attemptValue(() => faces.sidebarRightTabs?.entries?.())
260
+ if (!Array.isArray(rows)) return undefined
261
+ const out = []
262
+ for (const row of rows) {
263
+ if (row === null || typeof row !== 'object') continue
264
+ const kind = row.kind ?? row.options?.kind ?? row.id
265
+ if (typeof kind !== 'string' || kind === '') continue
266
+ const label = attemptValue(() => (typeof row.title === 'function' ? row.title() : row.title))
267
+ out.push({ kind, label: typeof label === 'string' && label !== '' ? label : undefined, multiple: row.multiple === true })
268
+ }
269
+ return out.length === 0 ? undefined : out
270
+ }
271
+
272
+ /**
273
+ * A tab label compared without the noise a visible label can carry (padding, an underline
274
+ * marker, a shortcut hint), so a registry label can be matched to what the tab shows.
275
+ * @param {string} value - the text to flatten.
276
+ * @returns {string} the flattened text.
277
+ */
278
+ function flatLabel(value) {
279
+ return String(value).replace(/\s+/g, ' ').trim()
280
+ }
281
+
282
+ function kindOfTabId(value) {
283
+ if (typeof value !== 'string' || value === '') return undefined
284
+ const match = /^sidebar:\/\/([^/?#]+)/.exec(value)
285
+ return match === null ? undefined : decodeURIComponent(match[1])
286
+ }
287
+
288
+ /**
289
+ * The session the right bar belongs to: the bar is per session and says so on its own root.
290
+ * @param {object} document - the document to read.
291
+ * @returns {string|undefined} the session id.
292
+ */
293
+ function rightBarSessionIdOf(document) {
294
+ const root = attemptValue(() => document?.querySelector?.(`[${RIGHT_BAR_ATTRIBUTE}]`))
295
+ const id = root === null || root === undefined ? null : attemptValue(() => root.getAttribute?.(RIGHT_BAR_ATTRIBUTE))
296
+ return typeof id === 'string' && id !== '' ? id : undefined
297
+ }
298
+
299
+ /**
300
+ * The right bar's active tab kind, read from its *strip*: the empty state also renders a
301
+ * `data-dockkit-pane`, so any pane-active marker would report a tab that is not there.
302
+ * @param {object} document - the document to read.
303
+ * @returns {string|undefined} the active kind.
304
+ */
305
+ /**
306
+ * Notes already said, across reconciles: a compatibility note is useful the first time, and
307
+ * a plugin that repeats it every reconcile is a log spammer.
308
+ */
309
+ const paneNotes = new Set()
310
+ /**
311
+ * Say a pane diagnostic once.
312
+ * @param {Function} warn - the host's warning sink.
313
+ * @param {string} key - deduplication key.
314
+ * @param {string} message - what to say.
315
+ */
316
+ const notePaneShape = (warn, key, message) => {
317
+ if (paneNotes.has(key)) return
318
+ paneNotes.add(key)
319
+ warn(`${TAG} ${message}`)
320
+ }
321
+
322
+ function activePaneTabOf(document, registry) {
323
+ // Scoped to the right bar's own root: the same dockkit attributes dress the main area
324
+ // too, and reading them unscoped reports the main area's panes, not this bar's tabs.
325
+ const right = attemptValue(() => document?.querySelector?.(`[${RIGHT_BAR_ATTRIBUTE}]`))
326
+ const scope = right === null || right === undefined ? document : right
327
+ const strip = typeof scope?.querySelector === 'function' ? attemptValue(() => scope.querySelector(`[${RIGHT_BAR_STRIP_ATTRIBUTE}]`)) : undefined
328
+ if (strip === null || strip === undefined) return undefined
329
+ // A tab is `role="tab"` with its id in `data-dockkit-tab` — and that id *is* the page
330
+ // address, so the kind sits inside it. Pane markers belong to the content area.
331
+ // Remember what the strip holds, *before* trying to read it: this is what a compatibility
332
+ // note has to name when nothing can be read, so it must be recorded either way.
333
+ const described = attemptValue(() => {
334
+ const child = strip.children?.[0]
335
+ if (child === undefined) return undefined
336
+ // An Attr list and a Map both show up (the DOM makes the former, a stub the latter).
337
+ const pairs = [...child.attributes].map(attribute => Array.isArray(attribute) ? { name: attribute[0], value: attribute[1] } : attribute)
338
+ return pairs.map(attribute => `${attribute.name}="${String(attribute.value).slice(0, 24)}"`).join(' ')
339
+ })
340
+ if (typeof described === 'string') activePaneTabOf.observed = described
341
+ const tabs = typeof strip.querySelectorAll === 'function' ? attemptValue(() => [...strip.querySelectorAll('[role="tab"]')]) : undefined
342
+ if (!Array.isArray(tabs) || tabs.length === 0) return undefined
343
+ const chosen = tabs.find(tab => attemptValue(() => tab.getAttribute?.('aria-selected')) === 'true') ?? tabs[0]
344
+ // The tab's own name is the reliable source: measured live, `data-dockkit-tab` and
345
+ // `data-dockkit-content` both carry the strip's tab id (`tab3`), which says nothing about
346
+ // the kind — while the visible label *is* the kind's name (`Files`, `Start`, `Terminal`).
347
+ const nameOfTab = tab => {
348
+ const labelled = attemptValue(() => tab.getAttribute?.('aria-label')) ?? attemptValue(() => tab.getAttribute?.('title'))
349
+ if (typeof labelled === 'string' && labelled.trim() !== '') return labelled.trim()
350
+ const text = attemptValue(() => (tab.textContent ?? '').trim())
351
+ return typeof text === 'string' && text !== '' && text.length < 40 ? text : undefined
352
+ }
353
+ // 1) the tab's name, resolved to a kind through the registry whenever it can be: the
354
+ // registry publishes the very label each tab shows, so a link can carry the kind
355
+ // (`pane/files`) instead of a sentence in whatever language is on screen.
356
+ const named = nameOfTab(chosen)
357
+ if (named !== undefined) {
358
+ const wanted = flatLabel(named).toLowerCase()
359
+ const match = Array.isArray(registry) ? registry.find(row => typeof row.label === 'string' && flatLabel(row.label).toLowerCase() === wanted) : undefined
360
+ return match === undefined ? named : match.kind
361
+ }
362
+ // 2) an address-shaped id, for a future build that puts one there
363
+ const fromTab = kindOfTabId(attemptValue(() => chosen.getAttribute?.('data-dockkit-tab')))
364
+ if (fromTab !== undefined) return fromTab
365
+ // 3) the tab host section, likewise
366
+ const hosts = typeof scope.querySelectorAll === 'function' ? attemptValue(() => [...scope.querySelectorAll('[data-dockkit-content]')]) : []
367
+ for (const host of hosts) {
368
+ const kind = kindOfTabId(attemptValue(() => host.getAttribute?.('data-dockkit-content')))
369
+ if (kind !== undefined) return kind
370
+ }
371
+ return undefined
372
+ }
373
+
374
+ /**
375
+ * Read a route out of a location fragment.
376
+ * @param {string} hash - the raw `window.location.hash`, including the leading `#`.
377
+ * @returns {{kind: string, id: string}|{unknown: string}|undefined} the route, an unknown-kind report, or undefined for "no route here".
378
+ */
379
+ function decodePart(raw) {
380
+ try {
381
+ return decodeURIComponent(raw)
382
+ } catch {
383
+ return raw
384
+ }
385
+ }
386
+
387
+ /**
388
+ * The fragment, when it belongs to some other writer.
389
+ *
390
+ * A URL has exactly one fragment, so two plugins that both write it can only take turns:
391
+ * whichever wrote last owns it, and the other one's link is gone. This router never
392
+ * pretends otherwise — it names the fragment it did not write and leaves it alone.
393
+ * @param {string} hash - the current fragment.
394
+ * @returns {string|undefined} the foreign fragment, or undefined when it is ours (or empty).
395
+ */
396
+ function foreignFragmentOf(hash) {
397
+ if (typeof hash !== 'string') return undefined
398
+ const trimmed = hash.trim()
399
+ if (trimmed === '' || trimmed === '#') return undefined
400
+ const match = ROUTE_PATTERN.exec(trimmed)
401
+ if (match !== null && ROUTE_ALIASES[match[1]] !== undefined) return undefined
402
+ return trimmed
403
+ }
404
+
405
+ function parseRoute(hash) {
406
+ const match = ROUTE_PATTERN.exec(hash)
407
+ if (match === null) return undefined
408
+ const kind = ROUTE_ALIASES[match[1]]
409
+ if (kind === undefined) return { unknown: match[1] }
410
+ // A modal surface needs no identity: `#/settings` is the whole route.
411
+ if (match[2] === undefined) return kind === 'settings' ? { kind, id: '' } : undefined
412
+ // A conversation can be named inside its workspace — the shape the sidebar itself shows,
413
+ // and the only way a link says *where* a conversation lives.
414
+ if (kind === 'workspace') {
415
+ const parts = match[2].replace(/\/+$/, '').split('/')
416
+ const name = decodePart(parts.shift() ?? '').trim()
417
+ if (name === '') return { unknown: `workspace/${match[2]}` }
418
+ const rest = parts.map(decodePart).filter(segment => segment.trim() !== '')
419
+ if (rest.length < 2 || rest[0] !== WORKSPACE_SESSION_SEGMENT) return { unknown: `workspace/${match[2]}` }
420
+ const sessionId = rest[1].trim()
421
+ if (sessionId === '') return { unknown: `workspace/${match[2]}` }
422
+ // The view a conversation is showing is part of the conversation, so it may follow.
423
+ const tail = rest.slice(2)
424
+ // `turn` is a position like `row` and `pane`, and it composes with `row`
425
+ // (`turn/<n>/row/…`): without this branch a workspace-shaped turn link lands in
426
+ // `unknown` and is never claimed at all.
427
+ const turnAt = tail.indexOf(TURN_SEGMENT)
428
+ if (turnAt >= 0 && tail.length > turnAt + 1 && tail[turnAt + 1].trim() !== '') return { kind: 'workspace', id: name, inner: innerPath([WORKSPACE_SESSION_SEGMENT, sessionId, ...tail]) }
429
+ if (tail.length === 0) return { kind: 'workspace', id: name, inner: innerPath([WORKSPACE_SESSION_SEGMENT, sessionId]) }
430
+ if (tail.length < 2 || tail[1].trim() === '') return { unknown: `workspace/${match[2]}` }
431
+ if (tail[0] === VIEW_SEGMENT && tail.length === 2) return { kind: 'workspace', id: name, inner: innerPath([WORKSPACE_SESSION_SEGMENT, sessionId, VIEW_SEGMENT, tail[1]]) }
432
+ if (tail[0] === ROW_SEGMENT && tail.length >= 3) return { kind: 'workspace', id: name, inner: innerPath([WORKSPACE_SESSION_SEGMENT, sessionId, ...tail]) }
433
+ if (tail[0] === PANE_SEGMENT && tail.length === 2) return { kind: 'workspace', id: name, inner: innerPath([WORKSPACE_SESSION_SEGMENT, sessionId, PANE_SEGMENT, tail[1]]) }
434
+ return { unknown: `workspace/${match[2]}` }
435
+ }
436
+ const segments = match[2].replace(/\/+$/, '').split('/')
437
+ const head = segments.shift() ?? ''
438
+ if (head === '') return kind === 'settings' ? { kind, id: '' } : undefined
439
+ const id = decodePart(head).trim()
440
+ if (id === '') return kind === 'settings' ? { kind, id: '' } : undefined
441
+ // A settings section has no stable id in the DOM (measured: the nav item carries only
442
+ // aria-current), so its identity is its *label* — the string the user actually sees and
443
+ // clicks. Round-tripping the label keeps the link self-consistent; a locale change
444
+ // renames the sections and the address bar simply writes the new label next time.
445
+ if (kind === 'settings') {
446
+ // A section may hold tabs of its own (measured: `role="tab"` + `aria-selected`), so
447
+ // the rest of the path names the tab — again by its label.
448
+ const rest = segments.map(decodePart).filter(segment => segment.trim() !== '')
449
+ return rest.length === 0 ? { kind, id } : { kind, id, inner: rest }
450
+ }
451
+ // A panel may have an inner page (a plugin's detail view, for instance): the
452
+ // rest of the path names it, per panel.
453
+ const inner = segments.map(decodePart).filter(segment => segment.trim() !== '')
454
+ return { kind, id, inner: inner.length === 0 ? undefined : inner }
455
+ }
456
+
457
+ /**
458
+ * Build the canonical fragment for a route.
459
+ * @param {string} kind - route kind.
460
+ * @param {string} id - route identity.
461
+ * @returns {string} the `#/<kind>/<encoded id>` fragment.
462
+ */
463
+ function routeHash(kind, id, inner) {
464
+ // A modal surface has no identity: `#/settings`, not `#/settings/`.
465
+ if (kind === 'settings' && (id === undefined || id === '')) return '#/settings'
466
+ if (kind === 'workspace') {
467
+ const rest = innerPath(inner)
468
+ const tail = rest.length === 0 ? '' : `/${rest.map(encodeURIComponent).join('/')}`
469
+ return `#/workspace/${encodeURIComponent(id)}${tail}`
470
+ }
471
+ if (kind === 'settings') {
472
+ const head = `#/settings/${encodeURIComponent(id)}`
473
+ const rest = innerPath(inner)
474
+ return rest.length === 0 ? head : `${head}/${rest.map(encodeURIComponent).join('/')}`
475
+ }
476
+ const base = `#/${kind}/${encodeURIComponent(id)}`
477
+ if (inner === undefined || inner === '') return base
478
+ const segments = innerPath(inner)
479
+ return segments.length === 0 ? base : `${base}/${segments.map(encodeURIComponent).join('/')}`
480
+ }
481
+
482
+ /**
483
+ * Normalise an inner path into its segments.
484
+ *
485
+ * A panel's inner page may nest (`<package>/<component>`), and each segment is
486
+ * encoded on its own, so a raw `/` in the path is always a separator while a `/`
487
+ * inside a name survives as `%2F`.
488
+ * @param {string|Array<string>} value - the inner path or its segments.
489
+ * @returns {Array<string>} the segments, empty when there is no inner page.
490
+ */
491
+ /**
492
+ * Split a row hook value into the package it belongs to and the row id.
493
+ *
494
+ * Two spellings exist in the panel's DOM: a page-level `<package>#<rowId>` and a
495
+ * row-level `<kind>:<rowId>`. Only the row id is routed; the package is used when
496
+ * the page reports one.
497
+ * @param {unknown} value - the raw attribute value.
498
+ * @returns {{packageName: string|undefined, rowId: string}|undefined} the parts, or undefined when there is nothing usable.
499
+ */
500
+ function splitRowHook(value) {
501
+ if (typeof value !== 'string' || value.trim() === '') return undefined
502
+ const hash = value.indexOf('#')
503
+ if (hash !== -1) {
504
+ const rowId = value.slice(hash + 1).trim()
505
+ if (rowId === '') return undefined
506
+ const packageName = value.slice(0, hash).trim()
507
+ return { packageName: packageName === '' ? undefined : packageName, rowId }
508
+ }
509
+ const colon = value.lastIndexOf(':')
510
+ const rowId = (colon === -1 ? value : value.slice(colon + 1)).trim()
511
+ return rowId === '' ? undefined : { packageName: undefined, rowId }
512
+ }
513
+
514
+ function innerPath(value) {
515
+ const parts = Array.isArray(value) ? value : String(value ?? '').split('/')
516
+ return parts.map(part => String(part).trim()).filter(part => part !== '')
517
+ }
518
+
519
+ /**
520
+ * Build the address-bar URL for one view state, preserving path and query.
521
+ * @param {string} pathname - current `location.pathname` (the mount path is preserved).
522
+ * @param {string} search - current `location.search` (a launch token, when present).
523
+ * @param {{kind: string, id: string}|undefined} route - the view to name, or undefined for "no view named".
524
+ * @returns {string} the URL to write.
525
+ */
526
+ function routeUrl(pathname, search, route) {
527
+ const base = `${pathname}${search}`
528
+ return route === undefined ? base : `${base}${routeHash(route.kind, route.id, route.inner)}`
529
+ }
530
+
531
+ /**
532
+ * Read one data attribute anywhere in the document.
533
+ * @param {string} selector - the attribute selector to find the owner by.
534
+ * @param {string} name - the attribute to read.
535
+ * @returns {string|undefined} the value, when it is a non-empty string.
536
+ */
537
+ function attributeOf(selector, name) {
538
+ const node = window.document?.querySelector?.(selector)
539
+ const value = typeof node?.getAttribute === 'function' ? node.getAttribute(name) : null
540
+ return typeof value === 'string' && value !== '' ? value : undefined
541
+ }
542
+
543
+ /** Serialise a route for cheap comparison. */
544
+ const routeKey = route =>
545
+ route === undefined ? 'none' : `${route.kind}:${route.id}:${innerPath(route.inner).map(part => encodeURIComponent(part)).join('/')}`
546
+
547
+ // ---------------------------------------------------------------------
548
+ // the router: two directions, one writer.
549
+ //
550
+ // - Fragment to view: a link names a surface, so loading (or pasting)
551
+ // `#/panel/plugins` selects that panel and `#/session/<id>` opens that
552
+ // conversation.
553
+ // - View to fragment: the address bar always names what is on screen — a
554
+ // panel when one is selected, otherwise the conversation the main view
555
+ // shows.
556
+ //
557
+ // A link needs an enforcement window because both owners restore their own
558
+ // remembered state while loading, which can land after the link was read.
559
+ // Within the window the link wins; after it the view owns the URL again, so a
560
+ // click during boot is never fought for longer than a boot.
561
+ //
562
+ // The state machine is host-injected (panel store, catalog, navigation,
563
+ // location, clock, timer) and every notification carries its source, so it is
564
+ // deterministic in tests and a claim can be retried on the notification that
565
+ // actually matters (a panel registering late) without retrying on unrelated
566
+ // catalog churn.
567
+ // ---------------------------------------------------------------------
568
+
569
+ /**
570
+ * Start routing the fragment.
571
+ * @param {object} host - the injected faces.
572
+ * @returns {() => void} the disposer removing every subscription and timer.
573
+ */
574
+ function startUrlRouting(host) {
575
+ /** The route the address bar claims, with its enforcement deadline. */
576
+ let linked
577
+ /** Whether the claimed route was already handed to its view owner. */
578
+ let claimed = false
579
+ /** The package segment of the deepest panel page the address bar carried. */
580
+ let panelScope
581
+ /** The turn the user last clicked inside a conversation, with its session. */
582
+ /** What the report can say about this run: fragments it refused, kinds it left alone. */
583
+ const observed = { foreignFragment: undefined, ignored: [], warnings: [] }
584
+ let rowAnchor
585
+ /** The pane kind this claim already opened (one drive per claim, see the pane branch). */
586
+ let paneOpenedFor
587
+ /** A position the previous step asked to bring into view, driven on the next reconcile. */
588
+ let turnAnchor
589
+ /** How many times the linked panel was selected (bounded, see below). */
590
+ let panelAttempts = 0
591
+ /** How many times a panel link's inner page was requested (bounded, see below). */
592
+ let innerAttempts = 0
593
+ /** The view state observed on the previous notification. */
594
+ let seen = 'none'
595
+ let cancelTimer
596
+ let retryTimer
597
+ let disposed = false
598
+
599
+ const dropTimer = () => {
600
+ cancelTimer?.()
601
+ cancelTimer = undefined
602
+ }
603
+ const dropLink = () => {
604
+ dropRetry()
605
+ linked = undefined
606
+ claimed = false
607
+ panelAttempts = 0
608
+ innerAttempts = 0
609
+ paneOpenedFor = undefined
610
+ dropTimer()
611
+ }
612
+ /**
613
+ * The route the current view deserves: a selected panel outranks the
614
+ * conversation behind it, because the panel is what fills the screen.
615
+ */
616
+ const canonical = () => {
617
+ // The settings overlay covers the main column, so while it is up it is what is on
618
+ // screen; when it closes the ordinary surface takes the address bar back.
619
+ if (window.document?.querySelector?.(`[${SETTINGS_SURFACE_ATTRIBUTE}="${SETTINGS_SURFACE_VALUE}"]`) != null) {
620
+ const section = host.activeSettingsSection() ?? ''
621
+ const tab = section === '' ? undefined : host.activeSettingsTab()
622
+ return tab === undefined ? { kind: 'settings', id: section } : { kind: 'settings', id: section, inner: [tab] }
623
+ }
624
+ const panelId = host.activePanelId()
625
+ if (panelId !== undefined && panelId !== null) {
626
+ const id = String(panelId)
627
+ // A right-hand pane in front is part of what is on screen, so it is named first:
628
+ // it is true whether or not the panel itself reports an inner page.
629
+ const paneInPanel = host.activePaneKind()
630
+ if (paneInPanel !== undefined) return { kind: 'panel', id, inner: innerPath([PANE_SEGMENT, paneInPanel]) }
631
+ const page = host.panelPage(id, panelScope)
632
+ if (page === undefined) return { kind: 'panel', id, inner: undefined }
633
+ const segments = innerPath(page)
634
+ // Remember the package a page was opened under, so a bare component page (which
635
+ // reports only its row) can still be named fully.
636
+ if (id === PLUGINS_PANEL_ID && segments.length === 1) panelScope = segments[0]
637
+ return { kind: 'panel', id, inner: segments }
638
+ }
639
+ const session = host.mainViewSessionId()
640
+ if (session !== undefined && session !== null) {
641
+ const id = String(session)
642
+ // The workspace is part of *where* the conversation lives, so it leads the route.
643
+ // Reading it from the sidebar keeps this honest: no sidebar (or no matching row)
644
+ // means the plain conversation route, never a guess.
645
+ // Which conversation view is showing, when it is not the default one: a plain
646
+ // conversation keeps its short route, any other view says which one it is.
647
+ // A pointed-at row is the most specific position there is, then the view, then the
648
+ // turn: they are all "where inside this conversation", so one of them is written.
649
+ const view = host.activeConversationView()
650
+ // A pointed-at row is a position, then the view. A *turn* deliberately is not: while a
651
+ // turn link is in flight the address bar belongs to it, and once it lands the link hands
652
+ // the address bar back — naming the turn here as well is what made a link look "landed"
653
+ // before it had ever driven (a real bug, live-measured).
654
+ // A pointed-at row is a position, then the view, then the turn the user pointed at —
655
+ // but never while a turn link is in flight: that link owns the address bar until it lands,
656
+ // and naming the turn here too is what made it look "landed" before it ever drove.
657
+ const anchoredTurn = linked === undefined && turnAnchor !== undefined && turnAnchor.session === id && turnAnchor.turn !== undefined && turnAnchor.turn !== '' ? [TURN_SEGMENT, turnAnchor.turn] : []
658
+ const position = [
659
+ ...(rowAnchor !== undefined && rowAnchor.session === id ? (rowAnchor.turn === undefined || rowAnchor.turn === '' ? [ROW_SEGMENT, ...rowAnchor.parts] : [TURN_SEGMENT, rowAnchor.turn, ROW_SEGMENT, ...rowAnchor.parts]) : []),
660
+ ...(anchoredTurn.length > 0 && rowAnchor !== undefined && rowAnchor.session === id ? [] : anchoredTurn),
661
+ ...(view !== undefined && view.index !== 0 ? [VIEW_SEGMENT, view.id] : []),
662
+ ]
663
+ // The right-hand pane is its own layer, not a position inside the conversation:
664
+ // it is named next to whatever the main column shows, never instead of it — so it
665
+ // is read *before* the workspace branch, or the workspace would swallow it.
666
+ const pane = host.activePaneKind()
667
+ const tail = pane === undefined ? [] : [PANE_SEGMENT, pane]
668
+ const workspaceName = host.workspaceNameOf(id)
669
+ if (workspaceName !== undefined) {
670
+ return { kind: 'workspace', id: workspaceName, inner: innerPath([WORKSPACE_SESSION_SEGMENT, id, ...position, ...tail]) }
671
+ }
672
+ if (tail.length === 0 && position.length > 0) return { kind: 'session', id, inner: innerPath(position) }
673
+ if (tail.length > 0) return { kind: 'session', id, inner: innerPath([...position, ...tail]) }
674
+ // The turn the user last pointed at is a position inside this conversation;
675
+ // it is only named while the same conversation is the one on screen.
676
+ return { kind: 'session', id }
677
+ }
678
+ return undefined
679
+ }
680
+ // Two routes are the same surface when they compare equal — plus one refinement: a bare
681
+ // `#/settings` names the overlay, and whichever section it happens to show is that same
682
+ // surface, so the link lands at once instead of sitting there until its window expires.
683
+ // A *named* section against a different named section is a real difference, so a section
684
+ // link still drives the overlay to the section it asks for.
685
+ // Whether the view on screen already *is* what a link asked for.
686
+ //
687
+ // The rule is directional on purpose: a link may be less specific than the view (a bare
688
+ // `#/settings` is satisfied by any section, `#/session/<id>` by any view of it), but a view
689
+ // that is less specific than the link never satisfies it — otherwise a link naming a view
690
+ // would look "already there" and never be driven.
691
+ const sessionIdOfRoute = route => {
692
+ if (route?.kind === 'workspace') return innerPath(route.inner)[1]
693
+ if (route?.kind === 'session') return route.id
694
+ return undefined
695
+ }
696
+ const viewIdOfRoute = route => {
697
+ const parts = innerPath(route?.inner)
698
+ const at = parts.indexOf(VIEW_SEGMENT)
699
+ return at < 0 ? undefined : parts[at + 1]
700
+ }
701
+
702
+ const paneOf = route => {
703
+ const parts = innerPath(route?.inner)
704
+ const at = parts.indexOf(PANE_SEGMENT)
705
+ return at < 0 ? undefined : parts[at + 1]
706
+ }
707
+ const sameRoute = (current, linked) => {
708
+ // A drive re-enters this machine, and re-entry can find the link already dropped: a link
709
+ // that is no longer in flight is never "the same route" as anything.
710
+ if (linked === undefined || linked === null) return false
711
+ if (routeKey(current) === routeKey(linked)) return true
712
+ const currentSession = sessionIdOfRoute(current)
713
+ const linkedSession = sessionIdOfRoute(linked)
714
+ if (currentSession !== undefined && linkedSession !== undefined) {
715
+ if (currentSession !== linkedSession) return false
716
+ const linkedView = viewIdOfRoute(linked)
717
+ if (linkedView === undefined) {
718
+ // A row or turn link is a position inside the conversation, so it stays exact; any other
719
+ // route that names the same conversation is satisfied by it being on screen —
720
+ // the workspace name is decoration, not a condition.
721
+ const parts = innerPath(linked.inner)
722
+ if (parts.includes(TURN_SEGMENT)) {
723
+ // A turn link lands when that turn is really in view. Comparing routes here called it
724
+ // landed while the seat was still outside the viewport (live-measured): the address bar
725
+ // is what the *link* already guarantees, so the route comparison proved nothing.
726
+ // Unknown geometry counts as "not in view" — the safe side: it keeps the drive alive
727
+ // rather than dropping a link that has not moved anything yet.
728
+ const turnWanted = parts[parts.indexOf(TURN_SEGMENT) + 1]
729
+ const seat = attemptValue(() => document?.querySelector?.(`[${TURN_ATTRIBUTE}="${turnWanted}"]`))
730
+ if (seat === null || seat === undefined) return false
731
+ // The official viewport reads exactly this: a seat that is hidden, or inside anything
732
+ // hidden, is not on screen — and the virtual list does render such rows (measured: the
733
+ // seat for the wanted turn existed while the turn was plainly not loaded).
734
+ if (seat.hidden === true) return false
735
+ if (attemptValue(() => seat.closest?.('[hidden]')) !== null && attemptValue(() => seat.closest?.('[hidden]')) !== undefined) return false
736
+ const rect = attemptValue(() => seat.getBoundingClientRect?.())
737
+ if (rect === null || rect === undefined) return false
738
+ const viewHeight = attemptValue(() => window.innerHeight)
739
+ if (typeof viewHeight !== 'number' || !(viewHeight > 0)) return false
740
+ return rect.height > 0 && rect.bottom > 0 && rect.top < viewHeight
741
+ }
742
+ if (parts.includes(ROW_SEGMENT)) return routeKey(current) === routeKey(linked)
743
+ // A pane is its own layer, named next to the conversation: the route *shape* (with or
744
+ // without the workspace) is decoration, exactly like the workspace name — what has to
745
+ // match is the pane kind itself.
746
+ if (parts.includes(PANE_SEGMENT)) return paneOf(current) === paneOf(linked)
747
+ return true
748
+ }
749
+ // A link that names a view has to see that view.
750
+ return viewIdOfRoute(current) === linkedView
751
+ }
752
+ if (current?.kind !== 'settings' || linked?.kind !== 'settings') return false
753
+ if (linked.id === '') return true
754
+ if (current.id !== linked.id) return false
755
+ const linkedTab = innerPath(linked.inner)[0] ?? ''
756
+ return linkedTab === '' || innerPath(current.inner)[0] === linkedTab
757
+ }
758
+ /** Write the fragment for the current view, leaving path and query untouched. */
759
+ const reflect = route => {
760
+ const { pathname, search, hash } = host.readLocation()
761
+ // Compare against the fragment, not the whole URL: the hash never carries the
762
+ // path or the launch query, and comparing them made every call look like a change.
763
+ const wantedHash = route === undefined ? '' : routeHash(route.kind, route.id, route.inner)
764
+ if (hash === wantedHash) return
765
+ // Somebody else's fragment is on screen: taking it over would erase their link, so
766
+ // this router stays quiet and says why instead.
767
+ const foreign = foreignFragmentOf(hash)
768
+ if (foreign !== undefined) {
769
+ observed.foreignFragment = foreign
770
+ const known = observed.warnings.includes(`refused to write over: ${foreign}`)
771
+ if (!known) {
772
+ observed.warnings.push(`refused to write over: ${foreign}`)
773
+ host.warn(`${TAG} not writing a route while ${foreign} is in the address bar: that fragment belongs to another plugin`)
774
+ }
775
+ return
776
+ }
777
+ // Nothing to name and nothing named: leave the address bar exactly as it is
778
+ // (this also leaves a foreign fragment alone, which the caller never reaches).
779
+ if (route === undefined && hash === '') return
780
+ host.replaceUrl(routeUrl(pathname, search, route))
781
+ }
782
+ /**
783
+ * Re-read the view state and move towards the state the address bar describes.
784
+ * @param {string} source - what notified: `boot`, `panel`, `catalog`, `hash`, or `timer`.
785
+ */
786
+ const reconcile = (source = 'boot') => {
787
+ if (disposed) return
788
+
789
+ const current = canonical()
790
+ const currentKey = routeKey(current)
791
+ const moved = currentKey !== seen
792
+ seen = currentKey
793
+ // The right bar holds a tab this build cannot name: say it once, with the tab's own
794
+ // attributes. A silent pane layer is the worst of both worlds — this is how the shape
795
+ // reaches a bug report instead of a guess.
796
+ const paneTabs = attemptValue(() => window.document?.querySelector?.(`[${RIGHT_BAR_STRIP_ATTRIBUTE}]`)?.querySelectorAll?.('[role="tab"]')?.length) ?? 0
797
+ if (paneTabs > 0 && host.activePaneKind() === undefined) {
798
+ 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'}`)
799
+ }
800
+ // A position the previous step asked for: it is driven here, one step later, so the step
801
+ // that claimed it never drives while it is still running.
802
+ if (linked === undefined) {
803
+ reflect(current)
804
+ return
805
+ }
806
+ // Snapshot the link for the rest of this step. Driving a position re-enters this machine
807
+ // (and re-entry can drop the link), so every read below must use the snapshot: reading the
808
+ // live binding again is how a drive turned into "Cannot read properties of undefined".
809
+ const link = linked
810
+
811
+ if (sameRoute(current, link)) {
812
+ dropRetry()
813
+ // The link has landed: it stops being authoritative right here, so the user
814
+ // keeps owning the address bar afterwards (walking deeper inside a panel
815
+ // updates the URL instead of being pulled back to the link).
816
+ dropLink()
817
+ reflect(current)
818
+ return
819
+ }
820
+ if (host.now() >= link.deadline) {
821
+ const hash = routeHash(link.kind, link.id, link.inner)
822
+ host.warn(`${TAG} ${hash} did not become the shown view within ${LINK_ENFORCE_WINDOW_MS} ms; the link was ignored`)
823
+ dropLink()
824
+ reflect(canonical())
825
+ return
826
+ }
827
+ // A conversation link may name which view it wants, on either route shape; the view is
828
+ // driven before the plain conversation/workspace branches, or they would swallow it.
829
+ const rowParts = innerPath(link.inner)
830
+ const rowAt = rowParts.indexOf(ROW_SEGMENT)
831
+ const wantedTurn = rowParts.indexOf(TURN_SEGMENT)
832
+ const rowSession = link.kind === 'workspace' ? rowParts[1] : link.kind === 'session' ? link.id : undefined
833
+ if (rowSession !== undefined && rowAt >= 0 && rowParts.length > rowAt + 2) {
834
+ const shown = host.mainViewSessionId()
835
+ if (shown !== rowSession && panelAttempts < INNER_ATTEMPT_LIMIT) {
836
+ panelAttempts += 1
837
+ host.openSession(rowSession)
838
+ } else if (shown === rowSession && innerAttempts < INNER_ATTEMPT_LIMIT) {
839
+ innerAttempts += 1
840
+ if (!host.revealPosition(rowParts.slice(rowAt)) && wantedTurn >= 0) host.revealPosition(rowParts.slice(wantedTurn, wantedTurn + 2), rowSession)
841
+ }
842
+ armRetry()
843
+ return
844
+ }
845
+ const wantedView = link.kind === 'session' || link.kind === 'workspace' ? viewIdOfRoute(link) : undefined
846
+ const wantedSession = link.kind === 'workspace' ? innerPath(link.inner)[1] : link.kind === 'session' ? link.id : undefined
847
+ if (wantedView !== undefined && wantedSession !== undefined) {
848
+ // Unlike the boot restore, a named view is driven whatever brought the route in —
849
+ // a pasted view link exists precisely to switch the view, so it must not be skipped.
850
+ const shown = host.mainViewSessionId()
851
+ if (shown !== wantedSession && panelAttempts < INNER_ATTEMPT_LIMIT) {
852
+ panelAttempts += 1
853
+ host.openSession(wantedSession)
854
+ } else if (shown === wantedSession && innerAttempts < INNER_ATTEMPT_LIMIT) {
855
+ innerAttempts += 1
856
+ host.openConversationView(wantedView)
857
+ }
858
+ armRetry()
859
+ return
860
+ }
861
+ if (link.kind === 'workspace') {
862
+ // The workspace is where the conversation lives, not a separate surface: the link
863
+ // is satisfied by that conversation being on screen, whatever the name says.
864
+ const target = innerPath(link.inner)[1]
865
+ const shown = host.mainViewSessionId()
866
+ if (shown !== target && source !== 'hash' && panelAttempts < INNER_ATTEMPT_LIMIT) {
867
+ panelAttempts += 1
868
+ host.openSession(target)
869
+ }
870
+ armRetry()
871
+ return
872
+ }
873
+ if (link.kind === 'settings') {
874
+ if (source !== 'hash' && innerAttempts < INNER_ATTEMPT_LIMIT) {
875
+ innerAttempts += 1
876
+ host.openSettings(link.id, innerPath(link.inner)[0])
877
+ }
878
+ armRetry()
879
+ return
880
+ }
881
+ if (link.kind === 'session' && link.inner !== undefined && innerPath(link.inner)[0] === PANE_SEGMENT) {
882
+ // A pasted pane link is exactly what this drive is for, so the source is not a filter
883
+ // here (the same lesson as the view branch); the attempt bound still limits it.
884
+ const wantedPane = innerPath(link.inner)[1]
885
+ // A page type that may be opened more than once (measured: `multiple: true`, the
886
+ // terminal) makes *every* attempt open another tab, so one claim drives once and the
887
+ // retry then only waits for the bar to report the tab it opened.
888
+ if (innerAttempts < INNER_ATTEMPT_LIMIT && paneOpenedFor !== wantedPane) {
889
+ innerAttempts += 1
890
+ paneOpenedFor = wantedPane
891
+ host.openPane(wantedPane, link.kind === 'panel' ? 'panel' : 'session', message => host.warn(message))
892
+ }
893
+ armRetry()
894
+ return
895
+ }
896
+ if (link.kind === 'session' && link.inner !== undefined && innerPath(link.inner)[0] === TURN_SEGMENT) {
897
+ // A turn link is a position, and a drivable one: the conversation is opened and the turn
898
+ // is brought into view in the same step, because the link lands as soon as the address
899
+ // bar names it. A turn that is not loaded yet is paged back through its seq.
900
+ //
901
+ // The position is recorded only *after* the drive: recording it first would make the
902
+ // canonical form equal the link already, so a link that has not moved anything would
903
+ // look "landed" and never be driven (a real bug, live-measured).
904
+ const wantedTurn = innerPath(link.inner)[1]
905
+ const wantedSession = link.id
906
+ // Drive first, record second. Recording first would make the canonical form equal the
907
+ // link on the *next* reconcile — before the drive ever ran, so the link would look
908
+ // "landed" and the position would never be brought into view (live-measured). Driving
909
+ // first means the work is done by the time the form can match.
910
+ if (innerAttempts < INNER_ATTEMPT_LIMIT) {
911
+ innerAttempts += 1
912
+ // The conversation has to be on screen before anything inside it can be brought into
913
+ // view: driving a turn against a conversation that is not shown is a silent no-op, which
914
+ // is what a cold-opened turn link used to do. So navigate first, and drive only once the
915
+ // conversation is really the main view.
916
+ // Bring the conversation up *and* drive the position, the way a row link does: the drive is
917
+ // idempotent on a conversation that is not shown yet (no seats, nothing to scroll to, and
918
+ // the paging path is a no-op too), and waiting for the main-view counter to catch up is
919
+ // what left a cold-opened turn link doing nothing at all.
920
+ host.openSession(wantedSession)
921
+ host.revealPosition(innerPath([TURN_SEGMENT, wantedTurn]), wantedSession)
922
+ // A row may follow the turn (`turn/<n>/row/…`): reveal it after the turn.
923
+ const rowAt = innerPath(link.inner).indexOf(ROW_SEGMENT)
924
+ if (rowAt >= 0) host.revealPosition(innerPath(link.inner).slice(rowAt), wantedSession)
925
+ }
926
+ turnAnchor = { session: wantedSession, turn: wantedTurn }
927
+ return
928
+ }
929
+ if (link.kind === 'panel') {
930
+ if (link.inner !== undefined && innerPath(link.inner)[0] === PANE_SEGMENT) {
931
+ if (innerAttempts < INNER_ATTEMPT_LIMIT) {
932
+ innerAttempts += 1
933
+ host.openPane(innerPath(link.inner)[1], link.kind === 'panel' ? 'panel' : 'session', message => host.warn(message))
934
+ }
935
+ armRetry()
936
+ return
937
+ }
938
+ // Panels are selected by id and report back through the panel store; an id
939
+ // nobody registered never becomes active, so the claim is retried when the
940
+ // panel store moves (a late-loading plugin), not on every catalog tick.
941
+ // `current` may be undefined when nothing is on screen yet; reading through it
942
+ // unconditionally would abort the whole step inside the guard.
943
+ const panelShown = current !== undefined && current.kind === 'panel' && current.id === link.id
944
+ if (!panelShown) {
945
+ // Selecting a panel can be refused or swallowed while the shell is still
946
+ // settling, so the request is retried (on later notifications and on a
947
+ // timer) with a bound.
948
+ if (source !== 'hash' && panelAttempts < INNER_ATTEMPT_LIMIT) {
949
+ panelAttempts += 1
950
+ // A panel switch is a navigation of its own: abort whatever navigation is
951
+ // in flight first (the layout exposes exactly that pair), otherwise the
952
+ // selection can be swallowed by the boot restore.
953
+ host.beginNavigation()
954
+ host.selectPanel(link.id)
955
+ }
956
+ armRetry()
957
+ return
958
+ }
959
+ // The panel is on screen; if the link names an inner page, drive it and let
960
+ // the DOM observer report back (the panel owns how its pages open).
961
+ // An inner page is driven through the panel's own control, which only exists
962
+ // once that panel has rendered: the request is retried on every later
963
+ // notification (not on the hash read itself, to avoid a double request),
964
+ // with a bound so an unopenable page cannot turn into a click loop.
965
+ if (link.inner !== undefined && routeKey(current) !== routeKey(link) && source !== 'hash' && innerAttempts < INNER_ATTEMPT_LIMIT) {
966
+ innerAttempts += 1
967
+ host.openPanelPage(link.id, link.inner)
968
+ }
969
+ armRetry()
970
+ return
971
+ }
972
+ // A session link: refuse immediately when the conversation cannot be opened at
973
+ // all, otherwise hand the navigation over once and wait for the view to land.
974
+ if (host.isArchived(link.id)) {
975
+ host.warn(`${TAG} ${routeHash(link.kind, link.id)} is archived; the link was ignored`)
976
+ dropLink()
977
+ reflect(canonical())
978
+ return
979
+ }
980
+ if ((!claimed || moved) && host.isOpenable(link.id)) {
981
+ claimed = true
982
+ host.openSession(link.id)
983
+ }
984
+ }
985
+ /** Adopt the surface the address bar names, if it names one. */
986
+ const readLink = () => {
987
+ if (disposed) return
988
+ const hash = host.readLocation().hash
989
+ // Another writer owns the fragment: name it once and step aside. Overwriting it here
990
+ // is how two routers silently erase each other's links.
991
+ const foreign = foreignFragmentOf(hash)
992
+ if (foreign !== undefined) {
993
+ const known = observed.foreignFragment === foreign
994
+ observed.foreignFragment = foreign
995
+ dropLink()
996
+ if (!known) {
997
+ 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`)
998
+ observed.warnings.push(`foreign fragment: ${foreign}`)
999
+ }
1000
+ return
1001
+ }
1002
+ const parsed = parseRoute(hash)
1003
+ if (parsed === undefined) {
1004
+ dropLink()
1005
+ reconcile('hash')
1006
+ return
1007
+ }
1008
+ if (parsed.unknown !== undefined) {
1009
+ // Another router's route (or a typo): leave the address bar alone and say so
1010
+ // once, so a router owning more kinds can take over later.
1011
+ if (!observed.ignored.includes(parsed.unknown)) observed.ignored.push(parsed.unknown)
1012
+ if (linked === undefined) host.warn(`${TAG} ignoring unknown route kind "${parsed.unknown}"`)
1013
+ return
1014
+ }
1015
+ if (linked === undefined || linked.kind !== parsed.kind || linked.id !== parsed.id) {
1016
+ linked = { kind: parsed.kind, id: parsed.id, inner: parsed.inner, deadline: host.now() + LINK_ENFORCE_WINDOW_MS }
1017
+ if (parsed.kind === 'panel' && parsed.inner !== undefined) panelScope = innerPath(parsed.inner)[0]
1018
+ claimed = false
1019
+ panelAttempts = 0
1020
+ innerAttempts = 0
1021
+ seen = 'none'
1022
+ }
1023
+ dropTimer()
1024
+ cancelTimer = host.setTimer(() => {
1025
+ cancelTimer = undefined
1026
+ // Fires from the browser, outside any notification: guard it too.
1027
+ guard(() => reconcile('timer'))
1028
+ }, LINK_ENFORCE_WINDOW_MS)
1029
+ reconcile('boot')
1030
+ }
1031
+
1032
+ /** Whether an internal error was already reported (one diagnostic per instance). */
1033
+ let crashed = false
1034
+ /**
1035
+ * Run a machine step so that nothing it meets can escape into the service that
1036
+ * notified us: a broken face or an unexpected value must not take the page down.
1037
+ */
1038
+ const guard = step => {
1039
+ try {
1040
+ step()
1041
+ } catch (error) {
1042
+ if (crashed) return
1043
+ crashed = true
1044
+ host.warn(`${TAG} internal error contained: ${error?.message ?? String(error)}`)
1045
+ }
1046
+ }
1047
+ const safeReconcile = source => guard(() => reconcile(source))
1048
+ /**
1049
+ * Keep retrying an unsatisfied link on a timer.
1050
+ *
1051
+ * Notifications alone are not enough: after boot the shell can go quiet, so a claim
1052
+ * that was refused once (a panel registered a moment later, a control not rendered
1053
+ * yet) would never be retried and the link would sit there doing nothing. The timer
1054
+ * stops as soon as the link lands, is dropped, or its window expires.
1055
+ */
1056
+ const armRetry = () => {
1057
+ if (disposed || linked === undefined || retryTimer !== undefined) return
1058
+ retryTimer = host.setTimer(() => {
1059
+ retryTimer = undefined
1060
+ safeReconcile('retry')
1061
+ }, LINK_RETRY_MS)
1062
+ }
1063
+ const dropRetry = () => {
1064
+ retryTimer?.()
1065
+ retryTimer = undefined
1066
+ }
1067
+ const safeReadLink = () => guard(readLink)
1068
+ const offPanel = host.onPanelChange(() => safeReconcile('panel'))
1069
+ const offCatalog = host.onCatalogChange(() => safeReconcile('catalog'))
1070
+ const offDom = host.onDomChange(() => safeReconcile('dom'))
1071
+ const offHashChange = host.onHashChange(() => safeReadLink())
1072
+ safeReadLink()
1073
+ // Pointing at a turn records a position, never a navigation: the listener is passive
1074
+ // and it refuses to act on any interactive element, so clicks keep working exactly as
1075
+ // the chat expects. Clicking a turn is how the address bar learns "which turn".
1076
+ const onDocumentClick = event => {
1077
+ if (disposed || event.button !== 0 || event.defaultPrevented) return
1078
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
1079
+ const target = event.target
1080
+ if (typeof target?.closest !== 'function') return
1081
+ // A trajectory row names itself first: it carries its own identity (and is often a
1082
+ // button), while a chat turn carries only a turn number. Both paths are pure observers
1083
+ // — nothing is prevented, so clicks keep behaving exactly as the views expect.
1084
+ const session = host.mainViewSessionId()
1085
+ const rowOwner = attemptValue(() => host.rowIdentityOf(target))
1086
+ if (rowOwner !== undefined) {
1087
+ if (session !== undefined) {
1088
+ if (rowOwner[0] === TURN_SEGMENT && rowOwner.length === 2) {
1089
+ turnAnchor = { session, turn: rowOwner[1] }
1090
+ rowAnchor = undefined
1091
+ } else {
1092
+ // The view's own key carries the turn for assistant nodes but not for tool
1093
+ // calls; when the row sits between two rendered turn boundaries the turn is
1094
+ // provable, so the link may say it. Otherwise it stays out of the route.
1095
+ const turn = rowOwner.includes(TURN_SEGMENT) ? undefined : attemptValue(() => host.turnOfRow(target))
1096
+ rowAnchor = turn === undefined || turn === '' ? { session, parts: rowOwner } : { session, turn, parts: rowOwner }
1097
+ turnAnchor = undefined
1098
+ }
1099
+ safeReconcile('row')
1100
+ }
1101
+ return
1102
+ }
1103
+ // One selector at a time: a comma list is the kind of thing a small DOM stub (and a
1104
+ // future minimal engine) may not implement, and the answer must be the same.
1105
+ for (const interactive of ['button', 'a', 'input', 'textarea', 'select', '[role="button"]', '[contenteditable="true"]']) {
1106
+ if (target.closest(interactive) !== null) return
1107
+ }
1108
+ const node = target.closest(`[${TURN_ATTRIBUTE}]`)
1109
+ if (node === null) return
1110
+ const id = session
1111
+ if (id === undefined) return
1112
+ const raw = node.getAttribute?.(TURN_ATTRIBUTE)
1113
+ const turn = typeof raw === 'string' ? raw.trim() : ''
1114
+ if (turn === '') return
1115
+ turnAnchor = { session: id, turn }
1116
+ rowAnchor = undefined
1117
+ safeReconcile('turn') }
1118
+ try {
1119
+ window.document?.addEventListener?.('click', onDocumentClick, true)
1120
+ } catch {
1121
+ // A composition without the DOM listener is still a working router.
1122
+ }
1123
+
1124
+ /**
1125
+ * The route health report: every layer this router knows about, the shape it is in right
1126
+ * now, and everything it refused to touch. Reachable as `window.__dshUrlRouter.report()`
1127
+ * — a debug seam, so it never throws and never writes anything.
1128
+ * @returns {object} the report.
1129
+ */
1130
+ const report = () => {
1131
+ const current = attemptValue(() => canonical())
1132
+ const document = window.document
1133
+ const count = selector => attemptValue(() => document?.querySelectorAll?.(selector)?.length) ?? '\u2014'
1134
+ return {
1135
+ version: ROUTER_VERSION,
1136
+ hash: attemptValue(() => host.readLocation().hash) ?? '',
1137
+ canonical: current === undefined ? null : { kind: current.kind, id: current.id, inner: innerPath(current.inner) },
1138
+ link: linked === undefined ? null : { hash: routeHash(linked.kind, linked.id, linked.inner), attempts: { panel: panelAttempts, inner: innerAttempts } },
1139
+ surfaces: {
1140
+ conversationRows: count(`[data-row-key^="${ROW_KEY_PREFIX}"]`),
1141
+ workspaceRows: count(`[data-row-key^="${WORKSPACE_ROW_PREFIX}"]`),
1142
+ viewTabs: count(`[${CONVERSATION_TABS_ATTRIBUTE}]`),
1143
+ viewIds: attemptValue(() => host.conversationViewIds?.()) ?? [],
1144
+ turns: count(`[${TURN_ATTRIBUTE}]`),
1145
+ trajectoryRows: count(`[${TRAJECTORY_ROW_ATTRIBUTE}]`),
1146
+ turnBoundaries: count(`[${TRAJECTORY_TURN_START_ATTRIBUTE}]`),
1147
+ pluginPackages: count('[data-plugin-package]'),
1148
+ officialItems: count('[data-plugin-item]'),
1149
+ settingsOverlay: count(`[${SETTINGS_SURFACE_ATTRIBUTE}="${SETTINGS_SURFACE_VALUE}"]`),
1150
+ settingsSection: attemptValue(() => host.activeSettingsSection()) ?? null,
1151
+ taskBoardOpen: count(`[${TASK_BOARD_OPEN_ATTRIBUTE}]`),
1152
+ taskBoardNew: count(`[${TASK_BOARD_NEW_ATTRIBUTE}]`),
1153
+ paneActive: attemptValue(() => host.activePaneKind()) ?? null,
1154
+ paneKinds: attemptValue(() => host.paneKinds?.()) ?? null,
1155
+ },
1156
+ compat: observed.compat,
1157
+ foreignFragment: observed.foreignFragment,
1158
+ ignoredKinds: observed.ignored,
1159
+ warnings: observed.warnings,
1160
+ }
1161
+ }
1162
+ guard(() => {
1163
+ window.__dshUrlRouter = { version: ROUTER_VERSION, report, when: host.now() }
1164
+ })
1165
+
1166
+ return () => {
1167
+ disposed = true
1168
+ try {
1169
+ window.document?.removeEventListener?.('click', onDocumentClick, true)
1170
+ } catch {
1171
+ // Nothing to undo when the listener was never attached.
1172
+ }
1173
+ dropTimer()
1174
+ dropRetry()
1175
+ offPanel()
1176
+ offCatalog()
1177
+ offDom()
1178
+ offHashChange()
1179
+ }
1180
+ }
1181
+
1182
+ // ---------------------------------------------------------------------
1183
+ // row links: every conversation row in the sidebar becomes a real anchor.
1184
+ //
1185
+ // A real <a href="#/session/<id>" target="_blank"> is what makes the link a
1186
+ // first-class thing instead of an address-bar convention: left click and
1187
+ // middle click open the conversation in a new tab, and the browser's own
1188
+ // context menu offers "open link in new tab", "copy link", "save link as" —
1189
+ // no custom menu to maintain.
1190
+ //
1191
+ // The row's identity rides its `data-row-key="session:<id>"` attribute (the
1192
+ // official sidebar list is virtualized, so rows come and go; a
1193
+ // MutationObserver re-scans). Anything unexpected — no document, no rows, no
1194
+ // observer — degrades to doing nothing at all.
1195
+ // ---------------------------------------------------------------------
1196
+ /** Row attribute carrying the conversation identity. */
1197
+ const ROW_KEY_ATTRIBUTE = 'data-row-key'
1198
+ /** Prefix of that attribute's value for a conversation row. */
1199
+ const ROW_KEY_PREFIX = 'session:'
1200
+ /** Rows this plugin looks at. */
1201
+ const ROW_SELECTOR = `[${ROW_KEY_ATTRIBUTE}^="${ROW_KEY_PREFIX}"]`
1202
+ /** Marks the anchor this plugin owns inside a row. */
1203
+ const ANCHOR_PART = 'url-router-link'
1204
+ /** Id of the stylesheet this plugin injects. */
1205
+ const STYLE_ID = 'dsh-url-router-style'
1206
+ /** Marks a row whose positioning context this plugin had to establish. */
1207
+ const HOST_ATTRIBUTE = 'data-dsh-url-router-host'
1208
+ /** Tooltip naming both the click and the right-click behaviour. */
1209
+ const LINK_TITLE = '在新标签页中打开 · 右键可复制链接 / Open in a new tab · right-click to copy the link'
1210
+ /** One stylesheet for every anchor; the icon stays invisible until the row is hovered. */
1211
+ const STYLE_TEXT = [
1212
+ // The anchor covers the whole row, so a right-click anywhere on the row
1213
+ // reaches the browser's link menu exactly like any other link. It is
1214
+ // transparent, sits under everything (z-index 0), and the row's own controls
1215
+ // are lifted above it so their clicks keep working.
1216
+ // z-index 3 keeps the overlay above the row's own hover mask; the row's
1217
+ // controls are lifted to 4. The :not() is load-bearing: without it this
1218
+ // rule also matches THE OVERLAY ITSELF (it is an <a>), turns it into a
1219
+ // zero-size relative element, and the right-click never reaches it.
1220
+ '.dsh-url-router-link{position:absolute;inset:0;z-index:3;display:block;border-radius:inherit;cursor:pointer;text-decoration:none}',
1221
+ `[${HOST_ATTRIBUTE}] :is(button,[role="button"],a,select,input,textarea):not(.dsh-url-router-link){position:relative;z-index:4}`,
1222
+ ].join('')
1223
+
1224
+ /**
1225
+ * Read the conversation identity out of one row.
1226
+ * @param {object} row - a candidate row element.
1227
+ * @returns {string|undefined} the session id, or undefined when the row names none.
1228
+ */
1229
+ function sessionIdOfRow(row) {
1230
+ if (row === null || row === undefined || typeof row.getAttribute !== 'function') return undefined
1231
+ const key = row.getAttribute(ROW_KEY_ATTRIBUTE)
1232
+ if (typeof key !== 'string' || !key.startsWith(ROW_KEY_PREFIX)) return undefined
1233
+ const id = key.slice(ROW_KEY_PREFIX.length).trim()
1234
+ return id === '' ? undefined : id
1235
+ }
1236
+
1237
+ /**
1238
+ * Build the row-wide anchor for one conversation.
1239
+ *
1240
+ * It covers the row, so a right-click anywhere reaches the browser's link menu
1241
+ * (open in new tab / copy link / save link as) and a middle or modified click
1242
+ * opens a new tab natively. A plain left click opens the conversation HERE, by
1243
+ * calling the official navigation with the same id the row would use: the
1244
+ * address bar therefore keeps being written with replaceState instead of
1245
+ * gaining a history entry per click, and the behaviour does not depend on the
1246
+ * row's own hit testing.
1247
+ * @param {object} document - the document to create the node in.
1248
+ * @param {string} id - session identity the link names.
1249
+ * @returns {object} the anchor node.
1250
+ */
1251
+ function rowAnchor(document, href, open) {
1252
+ const anchor = document.createElement('a')
1253
+ anchor.setAttribute('href', href)
1254
+ anchor.setAttribute('target', '_blank')
1255
+ anchor.setAttribute('rel', 'noreferrer')
1256
+ anchor.setAttribute('title', LINK_TITLE)
1257
+ anchor.setAttribute('aria-label', LINK_TITLE)
1258
+ anchor.setAttribute('data-dsh-plugin', 'dsh-url-router')
1259
+ anchor.setAttribute('data-dsh-part', ANCHOR_PART)
1260
+ anchor.className = 'dsh-url-router-link'
1261
+ anchor.addEventListener('click', event => {
1262
+ if (event === null || event.defaultPrevented === true) return
1263
+ const plain = (event.button === undefined || event.button === 0)
1264
+ && event.metaKey !== true && event.ctrlKey !== true
1265
+ && event.shiftKey !== true && event.altKey !== true
1266
+ if (!plain) return
1267
+ if (typeof event.preventDefault === 'function') event.preventDefault()
1268
+ if (typeof event.stopPropagation === 'function') event.stopPropagation()
1269
+ try {
1270
+ open()
1271
+ } catch {
1272
+ // Navigation belongs to the view owner: if it refuses, the link has
1273
+ // still done nothing harmful to the shell.
1274
+ }
1275
+ })
1276
+ return anchor
1277
+ }
1278
+
1279
+ /**
1280
+ * Give one row its anchor, if it needs one.
1281
+ * @param {object} document - the document the row lives in.
1282
+ * @param {object} row - candidate row element.
1283
+ * @returns {boolean} whether the row now carries an anchor.
1284
+ */
1285
+ function mountAnchor(document, row, href, open) {
1286
+ if (typeof row.querySelector !== 'function' || typeof row.appendChild !== 'function') return false
1287
+ if (row.querySelector(`[data-dsh-part="${ANCHOR_PART}"]`) !== null) return true
1288
+ // The anchor is absolutely positioned, so the row must be a positioning
1289
+ // context; only take that over when the row does not already establish one,
1290
+ // and remember which case it was so disposal only undoes our own change.
1291
+ const style = typeof window.getComputedStyle === 'function' ? window.getComputedStyle(row) : undefined
1292
+ const owns = style === undefined || style === null || style.position === 'static'
1293
+ if (owns && row.style !== undefined) row.style.position = 'relative'
1294
+ if (typeof row.setAttribute === 'function') row.setAttribute(HOST_ATTRIBUTE, owns ? 'own' : 'shared')
1295
+ const anchor = rowAnchor(document, href, open)
1296
+ // Prepended so the row's own controls — lifted by the stylesheet — stay in
1297
+ // front of the overlay; appended, the overlay would swallow them.
1298
+ if (typeof row.insertBefore === 'function') row.insertBefore(anchor, row.firstChild ?? null)
1299
+ else row.appendChild(anchor)
1300
+ return true
1301
+ }
1302
+
1303
+ /** Slot the sidebar renders its main-panel rows from. */
1304
+ const PANEL_LIST_SLOT = 'sidebar.panellist'
1305
+
1306
+ /**
1307
+ * Read the registered main panels in the order the sidebar renders them.
1308
+ *
1309
+ * The registry is the only source of a panel's identity: the row itself carries
1310
+ * its label and nothing else. A label may be a function (resolved per locale by
1311
+ * the shell), so it is only used for matching when it is a plain string; rows are
1312
+ * aligned by `order` otherwise.
1313
+ * @param {object} slots - the client slots service, when the composition has one.
1314
+ * @returns {Array<{id: string, order: number, label: string|undefined}>} registry rows.
1315
+ */
1316
+ function readPanels(slots) {
1317
+ if (typeof slots?.entries !== 'function') return []
1318
+ let stored
1319
+ try {
1320
+ stored = slots.entries(PANEL_LIST_SLOT)
1321
+ } catch {
1322
+ return []
1323
+ }
1324
+ if (!Array.isArray(stored)) return []
1325
+ return stored
1326
+ .map((entry, index) => {
1327
+ const options = entry?.options ?? {}
1328
+ const id = typeof options.id === 'string' ? options.id : undefined
1329
+ const order = typeof options.order === 'number' ? options.order : index
1330
+ const label = typeof options.label === 'string' ? options.label : undefined
1331
+ return { id, order, label, index }
1332
+ })
1333
+ .filter(entry => entry.id !== undefined && entry.id !== '')
1334
+ .sort((left, right) => left.order - right.order || left.index - right.index)
1335
+ }
1336
+
1337
+ /**
1338
+ * The main-panel rows the official sidebar renders.
1339
+ *
1340
+ * Located structurally (`data-slot`) rather than by a localized accessible name,
1341
+ * so the same hook works in every language.
1342
+ * @param {object} document - the document to scan.
1343
+ * @returns {Array} the panel row elements, in render order.
1344
+ */
1345
+ function panelRows(document) {
1346
+ const rows = []
1347
+ try {
1348
+ // One attribute query beats walking every button in the document and
1349
+ // querying each button's subtree: the panel list publishes its slot on
1350
+ // exactly one element per row, so the slot itself is the index.
1351
+ for (const slot of document.querySelectorAll(`[data-slot="${PANEL_LIST_SLOT}"]`)) {
1352
+ const host = typeof slot.closest === 'function' ? slot.closest('button') : null
1353
+ if (host !== null && !rows.includes(host)) rows.push(host)
1354
+ }
1355
+ } catch {
1356
+ return []
1357
+ }
1358
+ return rows
1359
+ }
1360
+
1361
+ /**
1362
+ * Give one conversation row its link to the conversation route.
1363
+ * @param {object} document - the document the row lives in.
1364
+ * @param {object} row - candidate row element.
1365
+ * @param {Function} openSession - navigation for a plain left click.
1366
+ * @returns {boolean} whether the row now carries an anchor.
1367
+ */
1368
+ function enhanceRow(document, row, openSession, knownWorkspaceName) {
1369
+ const id = sessionIdOfRow(row)
1370
+ if (id === undefined) return false
1371
+ // The workspace the row sits under leads the link, so a copied link says where the
1372
+ // conversation lives. No workspace row in the tree means no guess: the plain route.
1373
+ // A caller that already resolved the tree hands the name in, so N rows cost one
1374
+ // pass over the tree instead of N.
1375
+ const workspaceName = arguments.length > 3 ? knownWorkspaceName : workspaceNameOf(row, document)
1376
+ const href = workspaceName === undefined
1377
+ ? routeHash('session', id)
1378
+ : routeHash('workspace', workspaceName, innerPath([WORKSPACE_SESSION_SEGMENT, id]))
1379
+ return mountAnchor(document, row, href, () => openSession(id))
1380
+ }
1381
+
1382
+ /**
1383
+ * Give one main-panel row its link to the panel route.
1384
+ *
1385
+ * A panel row carries no identity in the DOM, so the id has to come from the
1386
+ * registry the sidebar renders from; rows are matched by accessible name when
1387
+ * the registered label is a plain string, and by position otherwise. A row that
1388
+ * cannot be matched is left exactly as it was.
1389
+ * @param {object} document - the document the row lives in.
1390
+ * @param {object} row - candidate row element.
1391
+ * @param {Array} panels - the registry rows, in render order.
1392
+ * @param {Function} selectPanel - panel selection for a plain left click.
1393
+ * @returns {boolean} whether the row now carries an anchor.
1394
+ */
1395
+ function enhancePanelRow(document, row, panels, index, useIndex, selectPanel) {
1396
+ const name = typeof row.getAttribute === 'function' ? row.getAttribute('aria-label') : undefined
1397
+ let target
1398
+ if (typeof name === 'string' && name !== '') target = panels.find(entry => entry.label === name)
1399
+ if (target === undefined && useIndex) target = panels[index]
1400
+ if (target === undefined) return false
1401
+ return mountAnchor(document, row, routeHash('panel', target.id), () => selectPanel(target.id))
1402
+ }
1403
+
1404
+ /**
1405
+ * Turn every conversation row into a link, and keep doing so as the list
1406
+ * virtualizes.
1407
+ * @param {object} document - the document to enhance (may be absent).
1408
+ * @returns {() => void} the disposer removing every anchor, the stylesheet and the observer.
1409
+ */
1410
+ function startRowLinks(document, openSession, slots, selectPanel) {
1411
+ if (document === undefined || document === null) return () => {}
1412
+ if (typeof document.createElement !== 'function' || typeof document.querySelectorAll !== 'function') return () => {}
1413
+ const scan = () => {
1414
+ let linked = 0
1415
+ try {
1416
+ const rows = document.querySelectorAll(ROW_SELECTOR)
1417
+ let names
1418
+ for (const row of rows) {
1419
+ // Already a link, and the anchor is still in the row: remounting it
1420
+ // costs a computed style and a tree walk and buys nothing. The check is a
1421
+ // query inside one row (a handful of nodes), never a sweep of the document,
1422
+ // so a rescan stays proportional to the rows that need one — and a row whose
1423
+ // anchor a re-render dropped is healed on the next pass.
1424
+ if (typeof row.querySelector === 'function' && row.querySelector(`[data-dsh-part="${ANCHOR_PART}"]`) !== null) continue
1425
+ names ??= workspaceNamesOfRows(document)
1426
+ if (enhanceRow(document, row, openSession, names.get(row))) linked += 1
1427
+ }
1428
+ } catch {
1429
+ // A DOM that cannot be scanned is left untouched: the address-bar sync
1430
+ // is the plugin's contract, the row links are an addition to it.
1431
+ }
1432
+ try {
1433
+ for (const row of document.querySelectorAll('li[data-plugin-item]')) {
1434
+ const id = typeof row.getAttribute === 'function' ? row.getAttribute('data-plugin-item') : null
1435
+ if (typeof id !== 'string' || id === '') continue
1436
+ const open = () => {
1437
+ selectPanel(PLUGINS_PANEL_ID)
1438
+ const button = typeof row.querySelector === 'function' ? row.querySelector('button') : null
1439
+ if (typeof button?.click === 'function') button.click()
1440
+ }
1441
+ if (mountAnchor(document, row, routeHash('panel', PLUGINS_PANEL_ID, id), open)) linked += 1
1442
+ }
1443
+ } catch {
1444
+ // Same policy as every other row pass.
1445
+ }
1446
+ try {
1447
+ for (const row of document.querySelectorAll(`li[data-plugin-package]`)) {
1448
+ const name = typeof row.getAttribute === 'function' ? row.getAttribute('data-plugin-package') : null
1449
+ if (typeof name !== 'string' || name === '') continue
1450
+ const open = () => {
1451
+ // Select the owning panel first, then let its own control open the page:
1452
+ // the panel stays the only thing that knows how a page opens.
1453
+ selectPanel(PLUGINS_PANEL_ID)
1454
+ const button = typeof row.querySelector === 'function' ? row.querySelector('button') : null
1455
+ if (typeof button?.click === 'function') button.click()
1456
+ }
1457
+ if (mountAnchor(document, row, routeHash('panel', PLUGINS_PANEL_ID, name), open)) linked += 1
1458
+ }
1459
+ } catch {
1460
+ // Same policy: a list row that cannot be read stays as it was.
1461
+ }
1462
+ try {
1463
+ const panels = readPanels(slots)
1464
+ const rows = panelRows(document)
1465
+ // Index alignment is only trustworthy when every registered panel has a
1466
+ // row; otherwise a row is linked only when its label matches exactly.
1467
+ const useIndex = panels.length > 0 && panels.length === rows.length
1468
+ for (let index = 0; index < rows.length; index += 1) {
1469
+ if (enhancePanelRow(document, rows[index], panels, index, useIndex, selectPanel)) linked += 1
1470
+ }
1471
+ } catch {
1472
+ // Same policy for the panel list: an unmatched row stays as it was.
1473
+ }
1474
+ return linked
1475
+ }
1476
+ let style
1477
+ try {
1478
+ style = document.createElement('style')
1479
+ style.id = STYLE_ID
1480
+ style.textContent = STYLE_TEXT
1481
+ const parent = document.head ?? document.body
1482
+ if (parent !== undefined && parent !== null && typeof parent.appendChild === 'function') parent.appendChild(style)
1483
+ } catch {
1484
+ style = undefined
1485
+ }
1486
+ let observer
1487
+ let frame
1488
+ let queued = false
1489
+ const runScan = () => {
1490
+ queued = false
1491
+ frame = undefined
1492
+ scan()
1493
+ }
1494
+ /**
1495
+ * Coalesce mutation batches into one scan per frame: a streaming answer mutates
1496
+ * the transcript many times per frame, and every batch used to buy a whole-document
1497
+ * scan. Without animation frames the scan runs straight away, so a minimal DOM
1498
+ * never waits on a frame that will not come.
1499
+ */
1500
+ const scheduleScan = () => {
1501
+ if (queued) return
1502
+ if (typeof window.requestAnimationFrame !== 'function') {
1503
+ scan()
1504
+ return
1505
+ }
1506
+ queued = true
1507
+ frame = window.requestAnimationFrame(runScan)
1508
+ }
1509
+ /**
1510
+ * Only a mutation batch that can introduce a row is worth a scan. Text nodes and
1511
+ * ordinary elements arrive constantly (streaming, hover, tool output) and never
1512
+ * carry a row.
1513
+ * @param {object[]} mutations - the batch the observer delivered.
1514
+ * @returns {boolean} whether a scan is warranted.
1515
+ */
1516
+ const rowMayHaveArrived = mutations => {
1517
+ // A batch delivered empty says nothing about what changed: scan rather than guess.
1518
+ if (!Array.isArray(mutations) || mutations.length === 0) return true
1519
+ for (const mutation of mutations) {
1520
+ if (mutation === null || mutation === undefined) continue
1521
+ if (mutation.type !== 'childList') continue
1522
+ const added = mutation.addedNodes ?? []
1523
+ for (const node of added) {
1524
+ // Skip only what is provably not an element: a text or comment node can never
1525
+ // carry a row, while a DOM without nodeType still gets looked at.
1526
+ if (node === null || node === undefined) continue
1527
+ if (node.nodeType !== undefined && node.nodeType !== 1) continue
1528
+ if (typeof node.matches !== 'function') return true
1529
+ if (node.matches(ROW_SELECTOR) || node.matches('[data-plugin-item],[data-plugin-package]')) return true
1530
+ if (node.matches(`[data-slot="${PANEL_LIST_SLOT}"]`)) return true
1531
+ if (typeof node.querySelector === 'function') {
1532
+ if (node.querySelector(ROW_SELECTOR) !== null) return true
1533
+ if (node.querySelector('[data-plugin-item],[data-plugin-package]') !== null) return true
1534
+ }
1535
+ }
1536
+ }
1537
+ return false
1538
+ }
1539
+ try {
1540
+ const root = document.body ?? document.documentElement
1541
+ if (root !== undefined && root !== null && typeof window.MutationObserver === 'function') {
1542
+ observer = new window.MutationObserver(mutations => {
1543
+ if (rowMayHaveArrived(mutations)) scheduleScan()
1544
+ })
1545
+ observer.observe(root, { childList: true, subtree: true })
1546
+ }
1547
+ } catch {
1548
+ observer = undefined
1549
+ }
1550
+ const linked = scan()
1551
+ if (linked === undefined) return () => {}
1552
+ 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)`)
1553
+ return () => {
1554
+ try {
1555
+ observer?.disconnect()
1556
+ if (queued && typeof window.cancelAnimationFrame === 'function' && frame !== undefined) window.cancelAnimationFrame(frame)
1557
+ queued = false
1558
+ frame = undefined
1559
+ style?.remove()
1560
+ for (const row of [...document.querySelectorAll(ROW_SELECTOR), ...panelRows(document)]) {
1561
+ row.querySelector(`[data-dsh-part="${ANCHOR_PART}"]`)?.remove()
1562
+ const host = typeof row.getAttribute === 'function' ? row.getAttribute(HOST_ATTRIBUTE) : null
1563
+ if (host !== null && typeof row.removeAttribute === 'function') {
1564
+ row.removeAttribute(HOST_ATTRIBUTE)
1565
+ // Only undo the positioning context this plugin established itself.
1566
+ if (host === 'own' && row.style !== undefined) row.style.position = ''
1567
+ }
1568
+ }
1569
+ } catch {
1570
+ // Disposal is best effort; nothing here may break the shell.
1571
+ }
1572
+ }
1573
+ }
1574
+ // ---------------------------------------------------------------------
1575
+ // browser wiring: probe the official faces, then drive the machine.
1576
+ // ---------------------------------------------------------------------
1577
+ /** Services this plugin needs before apply runs; `layout` is optional and read through `ctx.get`. */
1578
+ const inject = ['sessions', 'workspaces', 'uiWorkspace', 'slots', 'sidebarRightTabs', 'uiConversation']
1579
+
1580
+ /**
1581
+ * Probe the faces the router needs.
1582
+ *
1583
+ * A changed surface returns null so the plugin leaves the official behavior
1584
+ * untouched. The layout face is optional: without it the plugin still routes
1585
+ * conversations and simply never claims a panel.
1586
+ * @param {object} scope - client context.
1587
+ * @returns {{faces: object}|null} the probed faces, or null when the session faces are unusable.
1588
+ */
1589
+ function probeFaces(scope) {
1590
+ const list = scope.sessions?.list
1591
+ // The session controller itself (not just its catalog): the turn drive needs
1592
+ // `retainScope(id, source)` to reach a session that is not loaded yet. It lives in `inject`
1593
+ // for the same reason `uiConversation` does — cordis only hands a service to a plugin that
1594
+ // declared it — and reading it here is what makes the paging path reachable at all.
1595
+ const sessions = scope.sessions
1596
+ const registry = scope.workspaces?.list
1597
+ const navigation = scope.uiWorkspace
1598
+ if (typeof list !== 'object' || list === null) return null
1599
+ if (typeof list.getSnapshot !== 'function' || typeof list.subscribe !== 'function') return null
1600
+ if (typeof registry !== 'object' || registry === null) return null
1601
+ if (typeof registry.getSnapshot !== 'function') return null
1602
+ if (typeof navigation !== 'object' || navigation === null) return null
1603
+ const openSession = navigation.openSession
1604
+ if (typeof openSession !== 'function') return null
1605
+ // `slots` is declared in `inject`, but a composition may still omit the package:
1606
+ // reading it defensively keeps the panel links optional rather than fatal.
1607
+ let slots
1608
+ try {
1609
+ slots = scope.slots
1610
+ } catch {
1611
+ slots = undefined
1612
+ }
1613
+ const layout = typeof scope.get === 'function' ? scope.get('layout') : undefined
1614
+ const panelInfo = layout?.panelInfo
1615
+ const panels = typeof panelInfo?.getSnapshot === 'function' && typeof panelInfo?.subscribe === 'function' ? panelInfo : undefined
1616
+ const selectPanel = typeof layout?.selectPanel === 'function' ? layout.selectPanel.bind(layout) : undefined
1617
+ const beginNavigation = typeof layout?.beginNavigation === 'function' ? layout.beginNavigation.bind(layout) : undefined
1618
+ const openRightbar = typeof layout?.openRightbar === 'function' ? layout.openRightbar.bind(layout) : undefined
1619
+ const sidebarRightTabs = typeof scope.get === 'function' ? scope.get('sidebarRightTabs') : undefined
1620
+ const sidebarRight = typeof scope.get === 'function' ? scope.get('sidebarRight') : undefined
1621
+ // `uiConversation` is a root service, but cordis only hands it to a plugin that
1622
+ // declared it in `inject` — declaring it is what makes the view registry readable.
1623
+ const uiConversation = typeof scope.get === 'function' ? scope.get('uiConversation') : undefined
1624
+ return {
1625
+ faces: {
1626
+ catalog: list,
1627
+ archivedSessionIds: () => registry.getSnapshot().archivedSessionIds,
1628
+ openSession: id => openSession.call(navigation, id),
1629
+ selectPanel: selectPanel === undefined ? undefined : id => selectPanel(id),
1630
+ beginNavigation: beginNavigation === undefined ? undefined : () => beginNavigation(),
1631
+ openRightbar: openRightbar === undefined ? undefined : track => openRightbar(track),
1632
+ sidebarRightTabs,
1633
+ sidebarRight,
1634
+ uiConversation,
1635
+ sessions,
1636
+ panels,
1637
+ slots,
1638
+ },
1639
+ }
1640
+ }
1641
+
1642
+ /**
1643
+ * Build the live browser host over the probed faces.
1644
+ * @param {object} faces - the probed faces.
1645
+ * @returns {object} the host the state machine drives.
1646
+ */
1647
+ function browserHost(faces) {
1648
+ /**
1649
+ * Call a host face defensively: a refused navigation or a service that
1650
+ * changed its mind must never take the page down with it.
1651
+ */
1652
+ const attempt = (fn, fallback) => {
1653
+ try {
1654
+ return fn()
1655
+ } catch {
1656
+ return fallback
1657
+ }
1658
+ }
1659
+ const archived = () => {
1660
+ const ids = attempt(() => faces.archivedSessionIds(), [])
1661
+ return Array.isArray(ids) ? ids : []
1662
+ }
1663
+ const host = {
1664
+ mainViewSessionId: () => attempt(() => mainViewSessionId(faces.catalog.getSnapshot().byId), undefined),
1665
+ // The sidebar lists workspaces and their sessions in one tree, in order, so the
1666
+ // workspace a conversation belongs to is the nearest workspace row before it.
1667
+ workspaceNameOf: sessionId => {
1668
+ const rows = window.document?.querySelectorAll?.('[data-row-key]')
1669
+ if (rows === undefined || rows === null) return undefined
1670
+ let name
1671
+ for (const row of rows) {
1672
+ const key = typeof row.getAttribute === 'function' ? row.getAttribute('data-row-key') : null
1673
+ if (typeof key !== 'string') continue
1674
+ if (key.startsWith(WORKSPACE_ROW_PREFIX)) {
1675
+ name = (row.textContent ?? '').trim()
1676
+ continue
1677
+ }
1678
+ if (key !== `${ROW_KEY_PREFIX}${sessionId}`) continue
1679
+ return name === undefined || name === '' ? undefined : name
1680
+ }
1681
+ return undefined
1682
+ },
1683
+ activePanelId: () => (faces.panels === undefined ? undefined : attempt(() => faces.panels.getSnapshot()?.activePanelId, undefined)),
1684
+ // The panel that owns inner pages advertises them with a data attribute; the
1685
+ // router only reads it, never the panel's internals.
1686
+ // The inner page a panel is showing, in that panel's own vocabulary. Each panel
1687
+ // publishes it with a data attribute; reading it is the whole reflect direction.
1688
+ panelPage: (panelId, scope) => {
1689
+ if (panelId === TASK_BOARD_PANEL_ID) {
1690
+ // The form and a task are mutually exclusive, and the form wins when both
1691
+ // would be reported (it is the page the user is looking at).
1692
+ if (window.document?.querySelector?.(`[${TASK_BOARD_NEW_ATTRIBUTE}]`) != null) return ['new']
1693
+ const value = attributeOf(`[${TASK_BOARD_OPEN_ATTRIBUTE}]`, TASK_BOARD_OPEN_ATTRIBUTE)
1694
+ return value === undefined ? undefined : ['task', value]
1695
+ }
1696
+ if (panelId !== PLUGINS_PANEL_ID) return undefined
1697
+ const row = splitRowHook(attributeOf('[data-plugin-row-detail]', 'data-plugin-row-detail'))
1698
+ if (row !== undefined) {
1699
+ // The page names its package when it can; otherwise the package is the deepest
1700
+ // segment the address bar already carried.
1701
+ const pkg = row.packageName ?? scope
1702
+ return pkg === undefined ? [row.rowId] : innerPath([pkg, row.rowId])
1703
+ }
1704
+ // The built-in plugins (the official group) have their own page hook.
1705
+ const item = attributeOf('[data-plugin-item-detail]', 'data-plugin-item-detail')
1706
+ if (item !== undefined) return [item]
1707
+ const detail = attributeOf('[data-plugin-detail]', 'data-plugin-detail')
1708
+ return detail === undefined ? undefined : [detail]
1709
+ },
1710
+ openPanelPage: (panelId, inner) => {
1711
+ const asked = innerPath(inner)
1712
+ if (panelId === TASK_BOARD_PANEL_ID) {
1713
+ // The task board owns how its overlays open: we hand over the request on its
1714
+ // own contract (window events) and nothing else crosses the seam.
1715
+ if (asked[0] === 'new' && asked.length === 1) {
1716
+ attempt(() => window.dispatchEvent(new window.CustomEvent(TASK_BOARD_NEW_EVENT)))
1717
+ return
1718
+ }
1719
+ if (asked[0] !== 'task' || asked.length < 2) return
1720
+ attempt(() =>
1721
+ window.dispatchEvent(
1722
+ new window.CustomEvent(TASK_BOARD_OPEN_EVENT, { detail: { taskId: asked.slice(1).join('/') } }),
1723
+ ),
1724
+ )
1725
+ return
1726
+ }
1727
+ if (panelId !== PLUGINS_PANEL_ID) return
1728
+ const segments = asked
1729
+ if (segments.length === 0) return
1730
+ // The component page lives inside its package page, so that page is opened
1731
+ // first and the row is clicked once it exists.
1732
+ if (segments.length === 1 || window.document?.querySelector?.('[data-plugin-detail]') === null) {
1733
+ // A segment names either an installed package or a built-in plugin.
1734
+ const pkg =
1735
+ window.document?.querySelector?.(`li[data-plugin-package="${segments[0]}"]`) ??
1736
+ window.document?.querySelector?.(`li[data-plugin-item="${segments[0]}"]`)
1737
+ const button = pkg?.querySelector?.('button')
1738
+ if (typeof button?.click === 'function') button.click()
1739
+ if (segments.length === 1) return
1740
+ }
1741
+ const rowId = segments[segments.length - 1]
1742
+ const rows = window.document?.querySelectorAll?.('li[data-plugin-row]') ?? []
1743
+ for (const row of rows) {
1744
+ const parsed = splitRowHook(typeof row.getAttribute === 'function' ? row.getAttribute('data-plugin-row') : null)
1745
+ if (parsed === undefined || parsed.rowId !== rowId) continue
1746
+ const button = row.querySelector?.('button')
1747
+ if (typeof button?.click === 'function') button.click()
1748
+ return
1749
+ }
1750
+ },
1751
+ isArchived: id => archived().includes(id),
1752
+ isOpenable: id =>
1753
+ attempt(() => {
1754
+ const snapshot = faces.catalog.getSnapshot()
1755
+ if (snapshot.phase !== 'ready') return false
1756
+ if (archived().includes(id)) return false
1757
+ return snapshot.ids.includes(id) || snapshot.byId[id] !== undefined
1758
+ }, false),
1759
+ // A panel id nobody registered is refused by the layout by THROWING
1760
+ // (`selectPanel: main panel "x" is not registered`), so the claim is
1761
+ // swallowed here and reported as "the view did not move" at expiry.
1762
+ openSession: id => {
1763
+ attempt(() => faces.openSession(id))
1764
+ },
1765
+ // A row's own identity, in the shape the trajectory views publish it. The value is a
1766
+ // percent-encoded NUL-separated triple, so the route can read as a plain path.
1767
+ rowIdentityOf: element => {
1768
+ let node = element
1769
+ for (let depth = 0; node !== null && node !== undefined && depth < 12; depth += 1, node = node.parentElement) {
1770
+ if (typeof node.getAttribute !== 'function') break
1771
+ const key = node.getAttribute(TRAJECTORY_ROW_ATTRIBUTE)
1772
+ if (typeof key === 'string' && key !== '') {
1773
+ const parts = key.split('%00').map(part => part.trim()).filter(part => part !== '')
1774
+ if (parts.length >= 2) return parts
1775
+ }
1776
+ const turn = node.getAttribute(TRAJECTORY_TURN_ATTRIBUTE)
1777
+ if (typeof turn === 'string' && turn.trim() !== '') return [TURN_SEGMENT, turn.trim()]
1778
+ }
1779
+ return undefined
1780
+ },
1781
+ // The turn a row belongs to, but only when it is *provable*: the trajectory view
1782
+ // virtualizes, so the evidence is a turn boundary rendered before the row, and that is
1783
+ // trustworthy exactly when the next boundary is also rendered after it. Otherwise the
1784
+ // answer is undefined — never a guess.
1785
+ turnOfRow: element => {
1786
+ if (typeof element?.compareDocumentPosition !== 'function') return undefined
1787
+ const boundaries = window.document?.querySelectorAll?.(`[${TRAJECTORY_TURN_START_ATTRIBUTE}]`)
1788
+ if (boundaries === undefined || boundaries === null || boundaries.length === 0) return undefined
1789
+ const following = window.Node?.DOCUMENT_POSITION_FOLLOWING ?? 4
1790
+ const preceding = window.Node?.DOCUMENT_POSITION_PRECEDING ?? 2
1791
+ let before
1792
+ let after
1793
+ for (const boundary of boundaries) {
1794
+ const position = attemptValue(() => element.compareDocumentPosition(boundary)) ?? 0
1795
+ const turn = (boundary.getAttribute?.(TRAJECTORY_TURN_START_ATTRIBUTE) ?? '').trim()
1796
+ if (turn === '') continue
1797
+ if ((position & following) !== 0) after ??= turn
1798
+ if ((position & preceding) !== 0) before = turn
1799
+ }
1800
+ if (before === undefined || after === undefined || before === after) return undefined
1801
+ return before
1802
+ },
1803
+ // Best effort: scroll to the named row when it is rendered.
1804
+ /**
1805
+ * Bring the named position into view.
1806
+ *
1807
+ * A trajectory row is scrolled to when it is rendered. A *turn* needs history paged
1808
+ * back first, and that part is a public slot contract rather than a guess:
1809
+ * `session.loadThrough(seq)` pages backwards until the window covers the seq of that
1810
+ * turn's start event (measured in the core, and the same path the chat view uses). The
1811
+ * seq comes from the `turnOutline` projection, which is readable through the session
1812
+ * controller's projections face.
1813
+ * @param {Array} parts - the inner path segments.
1814
+ * @returns {boolean} whether the position was brought into view.
1815
+ */
1816
+ revealPosition: (parts, wantedSession) => {
1817
+ const wanted = innerPath(parts)
1818
+ const document = window.document
1819
+ if (wanted[0] === ROW_SEGMENT && wanted.length >= 3) {
1820
+ const key = wanted.slice(1).join('%00')
1821
+ for (const candidate of document?.querySelectorAll?.(`[${TRAJECTORY_ROW_ATTRIBUTE}]`) ?? []) {
1822
+ if (candidate.getAttribute?.(TRAJECTORY_ROW_ATTRIBUTE) !== key) continue
1823
+ attempt(() => candidate.scrollIntoView?.({ block: 'center' }))
1824
+ return true
1825
+ }
1826
+ return false
1827
+ }
1828
+ if (wanted[0] !== TURN_SEGMENT) return false
1829
+ const turn = wanted[1]
1830
+ // 1) Already rendered: the chat seats carry `data-chat-turn` (what the official
1831
+ // ChatNodeSeat prints), so scroll to it and be done.
1832
+ // Official viewport selector: a seat that is hidden, or inside anything hidden, is not on
1833
+ // screen — and the virtual list renders such seats for turns that are plainly not loaded.
1834
+ for (const candidate of document?.querySelectorAll?.(`[${TURN_ATTRIBUTE}]:not([hidden])`) ?? []) {
1835
+ if (candidate.getAttribute?.(TURN_ATTRIBUTE) !== turn) continue
1836
+ attempt(() => candidate.scrollIntoView?.({ block: 'center' }))
1837
+ return true
1838
+ }
1839
+ // 2) Not loaded yet: page history back through that turn's seq, then its seat renders.
1840
+ //
1841
+ // The session controller is reached by *retaining* its scope: `sessions.binding(id)`
1842
+ // answers undefined for a session nobody materialised yet (measured), while
1843
+ // `retainScope(id, source)` materialises it and returns a reference whose `binding`
1844
+ // is the live one. The reference is released as soon as the page-back is asked for, so
1845
+ // retaining costs nothing when the turn turns out to be unloadable.
1846
+ // The session the position belongs to is passed in by the caller, because the turn that
1847
+ // drove it may not be the main view *yet* (a link that just opened it): reading it back
1848
+ // from the catalog here is how this step used to look up "no session".
1849
+ const reference = attemptValue(() => faces.sessions?.retainScope?.(wantedSession, 'url-router'))
1850
+ const session = attemptValue(() => reference?.binding?.session)
1851
+ const loadThrough = attemptValue(() => (session === undefined || session === null ? undefined : session.loadThrough))
1852
+ const outline = attemptValue(() => session?.projections?.faceOf?.('turnOutline')?.getSnapshot?.())
1853
+ const seq = Array.isArray(outline) ? outline.find(entry => entry && String(entry.turn) === String(turn))?.seq : undefined
1854
+ const usable = typeof loadThrough === 'function' && typeof reference?.release === 'function'
1855
+ if (typeof seq !== 'number' || !usable) {
1856
+ attemptValue(() => reference?.release?.())
1857
+ return false
1858
+ }
1859
+ attemptValue(() => loadThrough(seq))
1860
+ attemptValue(() => reference.release())
1861
+ return true
1862
+ },
1863
+ // Which conversation view is showing. The registry is the source of truth for ids
1864
+ // (`ctx.uiConversation.views`), the header renders its tabs in registration order,
1865
+ // and the active tab is the aria-selected one — so the position maps to an id.
1866
+ activeConversationView: () => {
1867
+ // One selector at a time: the container first, then its tabs inside it.
1868
+ const row = window.document?.querySelector?.(`[${CONVERSATION_TABS_ATTRIBUTE}]`)
1869
+ const tabs = typeof row?.querySelectorAll === 'function' ? row.querySelectorAll('[role="tab"]') : undefined
1870
+ if (tabs === undefined || tabs === null || tabs.length === 0) return undefined
1871
+ let index = -1
1872
+ for (let position = 0; position < tabs.length; position += 1) {
1873
+ const tab = tabs[position]
1874
+ if (typeof tab.getAttribute !== 'function') continue
1875
+ if (tab.getAttribute('aria-selected') !== 'true') continue
1876
+ index = position
1877
+ break
1878
+ }
1879
+ if (index < 0) return undefined
1880
+ const ids = conversationViewIdsOf(faces)
1881
+ if (ids === undefined || ids.length !== tabs.length) return undefined
1882
+ return { id: ids[index], index, total: ids.length }
1883
+ },
1884
+ openConversationView: viewId => {
1885
+ const row = window.document?.querySelector?.(`[${CONVERSATION_TABS_ATTRIBUTE}]`)
1886
+ const tabs = typeof row?.querySelectorAll === 'function' ? row.querySelectorAll('[role="tab"]') : undefined
1887
+ if (tabs === undefined || tabs === null) return
1888
+ const ids = conversationViewIdsOf(faces)
1889
+ if (ids === undefined) return
1890
+ const index = ids.indexOf(viewId)
1891
+ const tab = index < 0 ? undefined : tabs[index]
1892
+ if (typeof tab?.click === 'function') attempt(() => tab.click())
1893
+ },
1894
+ // The right-hand pane names the tab in front. The service is the source of truth;
1895
+ // the DOM marker is a fallback for a composition where the service is absent.
1896
+ rightBarSessionId: () => rightBarSessionIdOf(window.document),
1897
+ activePaneTab: () => activePaneTabOf(window.document),
1898
+ activePaneKind: () => {
1899
+ // Both services answer `active`; the controller is authoritative when it does, and
1900
+ // `active` may be exposed as a value or as a method. Neither exposes `getSnapshot`.
1901
+ // `active` can be a string, a tab descriptor or a method returning either, and the
1902
+ // bar's own tab objects carry the kind under a few plausible names.
1903
+ const kindOf = value => {
1904
+ if (typeof value === 'string') return value === '' ? undefined : value
1905
+ if (value === null || typeof value !== 'object') return undefined
1906
+ const nested = value.tab ?? value.entry ?? value.options
1907
+ const candidates = [value.kind, value.id, value.tabId, value.name, nested?.kind, nested?.id]
1908
+ for (const candidate of candidates) {
1909
+ if (typeof candidate === 'string' && candidate !== '' && candidate !== 'pane1') return candidate
1910
+ }
1911
+ return undefined
1912
+ }
1913
+ const read = owner => {
1914
+ if (owner === undefined || owner === null) return undefined
1915
+ const value = typeof owner.active === 'function' ? attemptValue(() => owner.active()) : owner.active
1916
+ const direct = kindOf(value)
1917
+ if (direct !== undefined) return direct
1918
+ const snapshot = attemptValue(() => (typeof owner.getSnapshot === 'function' ? owner.getSnapshot() : undefined))
1919
+ return snapshot === undefined ? undefined : kindOf(snapshot.activeTabId ?? snapshot.activeTab ?? snapshot.active)
1920
+ }
1921
+ const fromStrip = activePaneTabOf(window.document, paneRegistryOf(faces))
1922
+ if (fromStrip !== undefined) return fromStrip
1923
+ const fromController = read(faces.sidebarRight)
1924
+ if (fromController !== undefined) return fromController
1925
+ const fromRegistry = read(faces.sidebarRightTabs)
1926
+ if (fromRegistry !== undefined) return fromRegistry
1927
+ // DOM fallback, deliberately narrow: the active-pane marker is shared with the
1928
+ // main dockkit, so it only counts when it really belongs to the right bar.
1929
+ const active = window.document?.querySelector?.('[data-dockkit-pane-active]')
1930
+ if (active === null || active === undefined) return undefined
1931
+ const own = typeof active.getAttribute === 'function' ? active.getAttribute('data-sidebar-right-tab') : null
1932
+ if (typeof own === 'string' && own !== '') return own
1933
+ const inside = typeof active.querySelector === 'function' ? active.querySelector('[data-sidebar-right-tab]') : null
1934
+ const inner = typeof inside?.getAttribute === 'function' ? inside.getAttribute('data-sidebar-right-tab') : null
1935
+ return typeof inner === 'string' && inner !== '' ? inner : undefined
1936
+ },
1937
+ // The section the overlay shows: the nav item the platform marks aria-current.
1938
+ // One selector at a time (a comma list is beyond a small DOM stub) and the `nav`
1939
+ // ancestor check keeps unrelated aria-current controls out of it.
1940
+ activeSettingsSection: () => {
1941
+ const overlay = window.document?.querySelector?.(`[${SETTINGS_SURFACE_ATTRIBUTE}="${SETTINGS_SURFACE_VALUE}"]`)
1942
+ if (overlay === null || overlay === undefined || typeof overlay.querySelectorAll !== 'function') return undefined
1943
+ for (const candidate of overlay.querySelectorAll('button')) {
1944
+ if (typeof candidate.getAttribute !== 'function') continue
1945
+ if (candidate.getAttribute('aria-current') !== 'true') continue
1946
+ if (typeof candidate.closest === 'function' && candidate.closest('nav') === null) continue
1947
+ const label = (candidate.textContent ?? '').trim()
1948
+ if (label !== '') return label
1949
+ }
1950
+ return undefined
1951
+ },
1952
+ // The tab a settings section is showing: the same ARIA contract one level deeper.
1953
+ activeSettingsTab: () => {
1954
+ const overlay = window.document?.querySelector?.(`[${SETTINGS_SURFACE_ATTRIBUTE}="${SETTINGS_SURFACE_VALUE}"]`)
1955
+ if (overlay === null || overlay === undefined || typeof overlay.querySelectorAll !== 'function') return undefined
1956
+ for (const candidate of overlay.querySelectorAll('[role="tab"]')) {
1957
+ if (typeof candidate.getAttribute !== 'function') continue
1958
+ if (candidate.getAttribute('aria-selected') !== 'true') continue
1959
+ const label = (candidate.textContent ?? '').trim()
1960
+ if (label !== '') return label
1961
+ }
1962
+ return undefined
1963
+ },
1964
+ openSettings: (section, tab) => {
1965
+ if (typeof section === 'string' && section !== '') {
1966
+ // A section is named by the label the user reads, so the request is a label match.
1967
+ const overlay = window.document?.querySelector?.(`[${SETTINGS_SURFACE_ATTRIBUTE}="${SETTINGS_SURFACE_VALUE}"]`)
1968
+ const scope = overlay ?? window.document
1969
+ if (typeof scope?.querySelectorAll === 'function') {
1970
+ const candidates = [...scope.querySelectorAll('button')].filter(candidate => {
1971
+ if (typeof candidate.closest === 'function' && candidate.closest('nav') === null) return false
1972
+ return (candidate.textContent ?? '').trim() !== ''
1973
+ })
1974
+ const wanted = section.trim().toLowerCase()
1975
+ const match = candidates.find(candidate => (candidate.textContent ?? '').trim().toLowerCase() === wanted)
1976
+ if (match !== undefined && typeof match.click === 'function') {
1977
+ attempt(() => match.click())
1978
+ // The section may own tabs: a named one is clicked once the section is up.
1979
+ if (typeof tab === 'string' && tab !== '') {
1980
+ const wantedTab = tab.trim().toLowerCase()
1981
+ const tabs = [...scope.querySelectorAll('[role="tab"]')]
1982
+ const matchTab = tabs.find(candidate => (candidate.textContent ?? '').trim().toLowerCase() === wantedTab)
1983
+ if (matchTab !== undefined && typeof matchTab.click === 'function') attempt(() => matchTab.click())
1984
+ }
1985
+ return
1986
+ }
1987
+ }
1988
+ }
1989
+ const slot = window.document?.querySelector?.(`[data-slot="${SETTINGS_TRIGGER_SLOT}"]`)
1990
+ const button =
1991
+ (typeof slot?.querySelector === 'function' ? slot.querySelector('button') : null) ??
1992
+ (typeof slot?.closest === 'function' ? slot.closest('button') : null)
1993
+ if (typeof button?.click === 'function') {
1994
+ attempt(() => button.click())
1995
+ return
1996
+ }
1997
+ // Fallback for a composition without that slot: the labelled entry.
1998
+ const labelled = typeof window.document?.querySelectorAll === 'function'
1999
+ ? [...window.document.querySelectorAll('button')].find(candidate => /^(settings|设置)$/i.test((candidate.textContent ?? '').trim()))
2000
+ : undefined
2001
+ if (typeof labelled?.click === 'function') attempt(() => labelled.click())
2002
+ },
2003
+ openPane: (kind, track, warn) => {
2004
+ if (typeof kind !== 'string' || kind === '') return
2005
+ // A pane link is a claim like any other, so it is checked before it is made: the
2006
+ // registry names every kind the bar can show, and asking for one it does not know
2007
+ // would leave the link sitting there doing nothing. (Measured live: guide,
2008
+ // subagentchat, changes-review, plan, files, text, terminal.)
2009
+ const known = attemptValue(() => paneRegistryOf(faces))?.map(row => row.kind)
2010
+ if (Array.isArray(known) && !known.includes(kind)) {
2011
+ warn(`${TAG} the right bar has no pane type "${kind}"; it knows: ${known.join(', ')}`)
2012
+ return
2013
+ }
2014
+ // `openTab(kind, options)` belongs to the controller (`sidebarRight`); the registry
2015
+ // only knows the kinds. Asking the registry to open one fails silently.
2016
+ const controller = faces.sidebarRight
2017
+ // Present the right column first: a session's store is adopted only once the bar is
2018
+ // shown, and every open on an unadopted session is a silent no-op.
2019
+ const expanded = attemptValue(() => (typeof controller?.isExpanded === 'function' ? controller.isExpanded() : undefined))
2020
+ if (expanded === false) {
2021
+ const wanted = typeof track === 'string' && track !== '' ? track : (attemptValue(() => faces.mainViewSessionId()) !== undefined ? 'session' : 'panel')
2022
+ attempt(() => faces.openRightbar?.(wanted))
2023
+ }
2024
+ const session = rightBarSessionIdOf(window.document)
2025
+ const expand = () => {
2026
+ const expanded = attemptValue(() => (typeof controller?.isExpanded === 'function' ? controller.isExpanded() : true))
2027
+ if (expanded === false) attempt(() => controller?.toggleExpanded?.())
2028
+ }
2029
+ if (controller !== undefined && controller !== null) {
2030
+ // The controller a root plugin reaches is not "mounted" for a session, and `openTab(kind)`
2031
+ // throws exactly then ("no session surface is mounted") — so the per-session path is the
2032
+ // real one, and it takes the session **first**: `openTabIn(sessionId, kind, options)`.
2033
+ // (Passed the other way round it looks up a session called "files" and does nothing.)
2034
+ const opened = attemptValue(() => {
2035
+ if (typeof controller.openTabIn === 'function' && typeof session === 'string' && session !== '') {
2036
+ controller.openTabIn(session, kind, {})
2037
+ return true
2038
+ }
2039
+ if (typeof controller.openTab === 'function') {
2040
+ controller.openTab(kind, {})
2041
+ return true
2042
+ }
2043
+ return false
2044
+ })
2045
+ if (opened === true) {
2046
+ expand()
2047
+ return
2048
+ }
2049
+
2050
+ }
2051
+ // Last resort: the same action the right bar's empty-state card runs. It is the
2052
+ // bar's own "open this kind" entry, so using it is not a workaround.
2053
+ const guides = attemptValue(() => {
2054
+ const registry = faces.sidebarRightTabs
2055
+ if (registry === undefined || registry === null) return undefined
2056
+ const rows = typeof registry.guideEntries === 'function' ? registry.guideEntries() : registry.guideEntries
2057
+ if (Array.isArray(rows)) return rows
2058
+ const guide = typeof registry.guide === 'function' ? registry.guide() : registry.guide
2059
+ return Array.isArray(guide) ? guide : undefined
2060
+ })
2061
+ if (!Array.isArray(guides)) return
2062
+ for (const entry of guides) {
2063
+ if (entry === null || typeof entry !== 'object') continue
2064
+ const entryKind = entry.kind ?? entry.options?.kind ?? entry.id
2065
+ if (typeof entryKind !== 'string' || entryKind !== kind) continue
2066
+ const run = entry.onPick ?? entry.action ?? entry.select ?? entry.open ?? entry.run
2067
+ if (typeof run === 'function') {
2068
+ attempt(() => run())
2069
+ expand()
2070
+ return
2071
+ }
2072
+ }
2073
+ },
2074
+ // The kinds the bar can show: link targets are checked against this before claiming.
2075
+ paneKinds: () => paneRegistryOf(faces)?.map(row => row.kind),
2076
+ // The rows the registry publishes, for the report and for the drive's check.
2077
+ paneRegistry: () => paneRegistryOf(faces),
2078
+ // The conversation view ids the registry publishes, for the report.
2079
+ conversationViewIds: () => conversationViewIdsOf(faces),
2080
+ beginNavigation: () => {
2081
+ if (faces.beginNavigation === undefined) return
2082
+ attempt(() => faces.beginNavigation())
2083
+ },
2084
+ selectPanel: id => {
2085
+ if (faces.selectPanel === undefined) return
2086
+ attempt(() => faces.selectPanel(id))
2087
+ },
2088
+ readLocation: () =>
2089
+ attempt(() => ({
2090
+ pathname: window.location.pathname,
2091
+ search: window.location.search,
2092
+ hash: window.location.hash,
2093
+ }), { pathname: '', search: '', hash: '' }),
2094
+ replaceUrl: url => {
2095
+ attempt(() => window.history.replaceState(null, '', url))
2096
+ },
2097
+ onPanelChange: handler => (faces.panels === undefined ? () => {} : attempt(() => faces.panels.subscribe(handler), () => {})),
2098
+ onCatalogChange: handler => attempt(() => faces.catalog.subscribe(handler), () => {}),
2099
+ // A panel's inner page is reported by attributes the panel itself publishes, so
2100
+ // DOM changes are a notification source of their own (list <-> detail).
2101
+ onDomChange: handler =>
2102
+ attempt(() => {
2103
+ const root = window.document?.body ?? window.document?.documentElement
2104
+ if (root === undefined || root === null || typeof window.MutationObserver !== 'function') return () => {}
2105
+ const observer = new window.MutationObserver(handler)
2106
+ // Every published page hook must be watched: a filter that names only some of
2107
+ // them silently starves the retry loop of notifications (real bug: the task
2108
+ // board's drive looked flaky because its attributes were not observed).
2109
+ observer.observe(root, {
2110
+ childList: true,
2111
+ subtree: true,
2112
+ attributes: true,
2113
+ attributeFilter: [
2114
+ 'data-plugin-detail',
2115
+ 'data-plugin-item-detail',
2116
+ 'data-plugin-row-detail',
2117
+ TASK_BOARD_OPEN_ATTRIBUTE,
2118
+ TASK_BOARD_NEW_ATTRIBUTE,
2119
+ // Switching a settings section only moves this marker, so it must be
2120
+ // watched or the address bar keeps the section it saw first.
2121
+ 'aria-current',
2122
+ // Switching a settings *tab* moves this one, for the same reason.
2123
+ 'aria-selected',
2124
+ SETTINGS_SURFACE_ATTRIBUTE,
2125
+ ],
2126
+ })
2127
+ return () => observer.disconnect()
2128
+ }, () => {}),
2129
+ onHashChange: handler =>
2130
+ attempt(() => {
2131
+ window.addEventListener('hashchange', handler)
2132
+ window.addEventListener('popstate', handler)
2133
+ return () => {
2134
+ window.removeEventListener('hashchange', handler)
2135
+ window.removeEventListener('popstate', handler)
2136
+ }
2137
+ }, () => {}),
2138
+ now: () => attempt(() => Date.now(), 0),
2139
+ setTimer: (handler, delayMs) =>
2140
+ attempt(() => {
2141
+ const timer = window.setTimeout(handler, delayMs)
2142
+ return () => window.clearTimeout(timer)
2143
+ }, () => {}),
2144
+ warn: message => console.warn(message),
2145
+ }
2146
+ return host
2147
+ }
2148
+
2149
+ /**
2150
+ * Register the URL routing on the running GUI.
2151
+ * @param {object} ctx - client root context.
2152
+ */
2153
+ function apply(ctx) {
2154
+ // Fragment routing: the address bar names the view, and a link opens it.
2155
+ ctx.effect(() => {
2156
+ if (typeof window === 'undefined') return () => {}
2157
+ const probed = probeFaces(ctx)
2158
+ if (probed === null) {
2159
+ console.warn(`${TAG} disabled: the sessions/workspaces/uiWorkspace service shape changed`)
2160
+ return () => {}
2161
+ }
2162
+ if (probed.faces.panels === undefined) {
2163
+ console.warn(`${TAG} the layout service exposes no panel store; only conversation routes are routed`)
2164
+ }
2165
+ return startUrlRouting(browserHost(probed.faces))
2166
+ // The session-scoped seat. A conversation's jump loader (`session.loadThrough(seq)`) lives
2167
+ // on the session controller, and a root plugin only reaches a session by *contributing* to
2168
+ // a session-scoped slot — the same seat the official chat and the family views use
2169
+ // (`inject: (sessionId) => …`). The entry itself draws nothing: it exists so the callback
2170
+ // runs with the session in hand. The entry itself is inert (it renders nothing and adds
2171
+ // nothing to the tail), and it rides a `list` slot so it can never become the chosen view.
2172
+ // (A session-scoped seat was tried here: contributing to a session-scoped slot is how a
2173
+ // root plugin reaches a session controller. It did not fire — measured live — and the
2174
+ // session-scoped slots that *would* fire (`conversation.view`) always add a visible tab,
2175
+ // so the seat was withdrawn. `sessionFor` stays as the seam for when a seam exists.)
2176
+ probed.faces.sessionFor = () => undefined
2177
+ }, 'url-router: fragment routing')
2178
+ // Row links: every conversation row is exposed as a real link to its route, so
2179
+ // the browser's own link menu (open in new tab / copy link) works on it.
2180
+ ctx.effect(() => {
2181
+ if (typeof window === 'undefined' || window.document === undefined) return () => {}
2182
+ const probed = probeFaces(ctx)
2183
+ if (probed === null) return () => {}
2184
+ const openSession = id => {
2185
+ try {
2186
+ probed.faces.openSession(id)
2187
+ } catch {
2188
+ // A refused navigation must not escape into the anchor's click handler.
2189
+ }
2190
+ }
2191
+ const selectPanel = id => {
2192
+ try {
2193
+ probed.faces.selectPanel(id)
2194
+ } catch {
2195
+ // The layout throws for an id nobody registered; a link must not.
2196
+ }
2197
+ }
2198
+ const slots = probed.faces.slots
2199
+ return startRowLinks(window.document, openSession, slots, selectPanel)
2200
+ }, 'url-router: row links')
2201
+ }
2202
+
2203
+ exports.apply = apply
2204
+ exports.inject = inject
2205
+ return module.exports
2206
+ },
2207
+ })