dsh-url-router 0.2.0 → 0.3.1

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
@@ -90,6 +90,22 @@ window.__ModuleLoader__.load({
90
90
  const TASK_BOARD_NEW_EVENT = 'dsh-taskboard-new-task'
91
91
  /** Sidebar row key prefix for a workspace (the value is its id; the row text is its name). */
92
92
  const WORKSPACE_ROW_PREFIX = 'workspace:'
93
+ /** Stable container the conversation header renders its view tabs into. */
94
+ const CONVERSATION_TABS_ATTRIBUTE = 'data-conversation-tabs'
95
+ /** Attribute each trajectory row carries: `<kind>%00<scope>%00<id>`. */
96
+ const TRAJECTORY_ROW_ATTRIBUTE = 'data-trajectory-row-key'
97
+ /** Attribute the family trajectory view puts on a row's turn cell. */
98
+ const TRAJECTORY_TURN_ATTRIBUTE = 'data-dshts-turn'
99
+ /** The right bar's own root: it carries the session the bar belongs to. */
100
+ const RIGHT_BAR_ATTRIBUTE = 'data-sidebar-right-session'
101
+ /** The right bar's tab strip: only *tabs* live here (the empty state has a bare pane). */
102
+ const RIGHT_BAR_STRIP_ATTRIBUTE = 'data-dockkit-strip-tabs'
103
+ /** Attribute the trajectory view puts on the first row of a turn. */
104
+ const TRAJECTORY_TURN_START_ATTRIBUTE = 'data-turn-start'
105
+ /** Inner segment that names one row inside a conversation view. */
106
+ const ROW_SEGMENT = 'row'
107
+ /** Inner segment that names which conversation view is showing. */
108
+ const VIEW_SEGMENT = 'view'
93
109
  /** Inner segment that marks the conversation inside a workspace route. */
94
110
  const WORKSPACE_SESSION_SEGMENT = 'session'
95
111
  /** Attribute the chat view puts on every rendered turn node (the turn number). */
@@ -103,8 +119,51 @@ window.__ModuleLoader__.load({
103
119
  /** Panel id of the official plugin manager, whose list rows and detail pages are routed. */
104
120
  const PLUGINS_PANEL_ID = 'plugins'
105
121
  /** Log prefix for every diagnostic. */
122
+ /**
123
+ * The router's own version, for the report a human files. A test keeps it equal to the
124
+ * package version, so a release cannot forget to bump it.
125
+ */
126
+ const ROUTER_VERSION = '0.3.1'
127
+
106
128
  const TAG = '[url-router]'
107
129
 
130
+ /**
131
+ * The conversation view ids the registry publishes, in registration order — the same order
132
+ * the header renders its tabs in, which is what lets a tab position map back to an id.
133
+ * @param {object} faces - the probed host faces.
134
+ * @returns {string[]|undefined} the ids, or undefined when the registry is not readable.
135
+ */
136
+ /**
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
+
108
167
  /**
109
168
  * The workspace a sidebar row belongs to: the nearest workspace row before it, read from the
110
169
  * row text. Returns undefined when the tree says nothing, so no caller has to guess.
@@ -129,6 +188,166 @@ window.__ModuleLoader__.load({
129
188
  return undefined
130
189
  }
131
190
 
191
+ /**
192
+ * The kind inside a tab id: the bar builds one as `sidebar://<kind>` and appends a uuid for
193
+ * kinds that may be opened more than once, so the kind is right there in the address.
194
+ * @param {string} value - a tab id.
195
+ * @returns {string|undefined} the kind, when the value is one of these addresses.
196
+ */
197
+ /**
198
+ * The right bar's registry, normalised: one row per page type with its kind, the label
199
+ * the tab shows, whether more than one may be open, and its empty-state card (measured
200
+ * live: `{kind, title(): () => string, multiple?: true, guide?: [{id, order}]}`).
201
+ * @param {object} faces - the probed host faces.
202
+ * @returns {{kind: string, label: string|undefined, multiple: boolean}[]|undefined} the rows.
203
+ */
204
+ /**
205
+ * The seq of a turn's start event, from the `turnOutline` projection.
206
+ *
207
+ * The projection is a public read on the session controller (the core reads it the same
208
+ * way: `session.projections.faceOf("turnOutline").getSnapshot()`), and each entry carries
209
+ * the `seq` of that turn's `turn/start` — which is exactly what `loadThrough` pages back
210
+ * to. Reading it is what makes a turn link work for a turn that is not loaded yet.
211
+ * @param {object} faces - the probed host faces.
212
+ * @param {string|undefined} sessionId - the session the turn belongs to.
213
+ * @param {string} turn - the turn number.
214
+ * @returns {number|undefined} the seq, when the projection names that turn.
215
+ */
216
+ function turnSeqOf(faces, sessionId, turn) {
217
+ if (typeof sessionId !== 'string' || sessionId === '') return undefined
218
+ // Reached by retaining the session's scope: `sessions.binding(id)` answers undefined
219
+ // for a session nobody materialised yet (measured), while `retainScope(id, source)`
220
+ // materialises it and returns a reference whose `binding` is the live one. Released here
221
+ // because only the projection is read.
222
+ const reference = attemptValue(() => faces.sessions?.retainScope?.(sessionId, 'url-router'))
223
+ const outline = attemptValue(() => session?.projections?.faceOf?.('turnOutline')?.getSnapshot?.())
224
+ attempt(() => reference?.release?.())
225
+ if (!Array.isArray(outline)) return undefined
226
+ const wanted = String(turn)
227
+ for (const entry of outline) {
228
+ if (entry === null || typeof entry !== 'object') continue
229
+ if (String(entry.turn) !== wanted) continue
230
+ return typeof entry.seq === 'number' ? entry.seq : undefined
231
+ }
232
+ return undefined
233
+ }
234
+
235
+ function paneRegistryOf(faces) {
236
+ const rows = attemptValue(() => faces.sidebarRightTabs?.entries?.())
237
+ if (!Array.isArray(rows)) return undefined
238
+ const out = []
239
+ for (const row of rows) {
240
+ if (row === null || typeof row !== 'object') continue
241
+ const kind = row.kind ?? row.options?.kind ?? row.id
242
+ if (typeof kind !== 'string' || kind === '') continue
243
+ const label = attemptValue(() => (typeof row.title === 'function' ? row.title() : row.title))
244
+ out.push({ kind, label: typeof label === 'string' && label !== '' ? label : undefined, multiple: row.multiple === true })
245
+ }
246
+ return out.length === 0 ? undefined : out
247
+ }
248
+
249
+ /**
250
+ * A tab label compared without the noise a visible label can carry (padding, an underline
251
+ * marker, a shortcut hint), so a registry label can be matched to what the tab shows.
252
+ * @param {string} value - the text to flatten.
253
+ * @returns {string} the flattened text.
254
+ */
255
+ function flatLabel(value) {
256
+ return String(value).replace(/\s+/g, ' ').trim()
257
+ }
258
+
259
+ function kindOfTabId(value) {
260
+ if (typeof value !== 'string' || value === '') return undefined
261
+ const match = /^sidebar:\/\/([^/?#]+)/.exec(value)
262
+ return match === null ? undefined : decodeURIComponent(match[1])
263
+ }
264
+
265
+ /**
266
+ * The session the right bar belongs to: the bar is per session and says so on its own root.
267
+ * @param {object} document - the document to read.
268
+ * @returns {string|undefined} the session id.
269
+ */
270
+ function rightBarSessionIdOf(document) {
271
+ const root = attemptValue(() => document?.querySelector?.(`[${RIGHT_BAR_ATTRIBUTE}]`))
272
+ const id = root === null || root === undefined ? null : attemptValue(() => root.getAttribute?.(RIGHT_BAR_ATTRIBUTE))
273
+ return typeof id === 'string' && id !== '' ? id : undefined
274
+ }
275
+
276
+ /**
277
+ * The right bar's active tab kind, read from its *strip*: the empty state also renders a
278
+ * `data-dockkit-pane`, so any pane-active marker would report a tab that is not there.
279
+ * @param {object} document - the document to read.
280
+ * @returns {string|undefined} the active kind.
281
+ */
282
+ /**
283
+ * Notes already said, across reconciles: a compatibility note is useful the first time, and
284
+ * a plugin that repeats it every reconcile is a log spammer.
285
+ */
286
+ const paneNotes = new Set()
287
+ /**
288
+ * Say a pane diagnostic once.
289
+ * @param {Function} warn - the host's warning sink.
290
+ * @param {string} key - deduplication key.
291
+ * @param {string} message - what to say.
292
+ */
293
+ const notePaneShape = (warn, key, message) => {
294
+ if (paneNotes.has(key)) return
295
+ paneNotes.add(key)
296
+ warn(`${TAG} ${message}`)
297
+ }
298
+
299
+ function activePaneTabOf(document, registry) {
300
+ // Scoped to the right bar's own root: the same dockkit attributes dress the main area
301
+ // too, and reading them unscoped reports the main area's panes, not this bar's tabs.
302
+ const right = attemptValue(() => document?.querySelector?.(`[${RIGHT_BAR_ATTRIBUTE}]`))
303
+ const scope = right === null || right === undefined ? document : right
304
+ const strip = typeof scope?.querySelector === 'function' ? attemptValue(() => scope.querySelector(`[${RIGHT_BAR_STRIP_ATTRIBUTE}]`)) : undefined
305
+ if (strip === null || strip === undefined) return undefined
306
+ // A tab is `role="tab"` with its id in `data-dockkit-tab` — and that id *is* the page
307
+ // address, so the kind sits inside it. Pane markers belong to the content area.
308
+ // Remember what the strip holds, *before* trying to read it: this is what a compatibility
309
+ // note has to name when nothing can be read, so it must be recorded either way.
310
+ const described = attemptValue(() => {
311
+ const child = strip.children?.[0]
312
+ if (child === undefined) return undefined
313
+ // An Attr list and a Map both show up (the DOM makes the former, a stub the latter).
314
+ const pairs = [...child.attributes].map(attribute => Array.isArray(attribute) ? { name: attribute[0], value: attribute[1] } : attribute)
315
+ return pairs.map(attribute => `${attribute.name}="${String(attribute.value).slice(0, 24)}"`).join(' ')
316
+ })
317
+ if (typeof described === 'string') activePaneTabOf.observed = described
318
+ const tabs = typeof strip.querySelectorAll === 'function' ? attemptValue(() => [...strip.querySelectorAll('[role="tab"]')]) : undefined
319
+ if (!Array.isArray(tabs) || tabs.length === 0) return undefined
320
+ const chosen = tabs.find(tab => attemptValue(() => tab.getAttribute?.('aria-selected')) === 'true') ?? tabs[0]
321
+ // The tab's own name is the reliable source: measured live, `data-dockkit-tab` and
322
+ // `data-dockkit-content` both carry the strip's tab id (`tab3`), which says nothing about
323
+ // the kind — while the visible label *is* the kind's name (`Files`, `Start`, `Terminal`).
324
+ const nameOfTab = tab => {
325
+ const labelled = attemptValue(() => tab.getAttribute?.('aria-label')) ?? attemptValue(() => tab.getAttribute?.('title'))
326
+ if (typeof labelled === 'string' && labelled.trim() !== '') return labelled.trim()
327
+ const text = attemptValue(() => (tab.textContent ?? '').trim())
328
+ return typeof text === 'string' && text !== '' && text.length < 40 ? text : undefined
329
+ }
330
+ // 1) the tab's name, resolved to a kind through the registry whenever it can be: the
331
+ // registry publishes the very label each tab shows, so a link can carry the kind
332
+ // (`pane/files`) instead of a sentence in whatever language is on screen.
333
+ const named = nameOfTab(chosen)
334
+ if (named !== undefined) {
335
+ const wanted = flatLabel(named).toLowerCase()
336
+ const match = Array.isArray(registry) ? registry.find(row => typeof row.label === 'string' && flatLabel(row.label).toLowerCase() === wanted) : undefined
337
+ return match === undefined ? named : match.kind
338
+ }
339
+ // 2) an address-shaped id, for a future build that puts one there
340
+ const fromTab = kindOfTabId(attemptValue(() => chosen.getAttribute?.('data-dockkit-tab')))
341
+ if (fromTab !== undefined) return fromTab
342
+ // 3) the tab host section, likewise
343
+ const hosts = typeof scope.querySelectorAll === 'function' ? attemptValue(() => [...scope.querySelectorAll('[data-dockkit-content]')]) : []
344
+ for (const host of hosts) {
345
+ const kind = kindOfTabId(attemptValue(() => host.getAttribute?.('data-dockkit-content')))
346
+ if (kind !== undefined) return kind
347
+ }
348
+ return undefined
349
+ }
350
+
132
351
  /**
133
352
  * Read a route out of a location fragment.
134
353
  * @param {string} hash - the raw `window.location.hash`, including the leading `#`.
@@ -142,6 +361,24 @@ window.__ModuleLoader__.load({
142
361
  }
143
362
  }
144
363
 
364
+ /**
365
+ * The fragment, when it belongs to some other writer.
366
+ *
367
+ * A URL has exactly one fragment, so two plugins that both write it can only take turns:
368
+ * whichever wrote last owns it, and the other one's link is gone. This router never
369
+ * pretends otherwise — it names the fragment it did not write and leaves it alone.
370
+ * @param {string} hash - the current fragment.
371
+ * @returns {string|undefined} the foreign fragment, or undefined when it is ours (or empty).
372
+ */
373
+ function foreignFragmentOf(hash) {
374
+ if (typeof hash !== 'string') return undefined
375
+ const trimmed = hash.trim()
376
+ if (trimmed === '' || trimmed === '#') return undefined
377
+ const match = ROUTE_PATTERN.exec(trimmed)
378
+ if (match !== null && ROUTE_ALIASES[match[1]] !== undefined) return undefined
379
+ return trimmed
380
+ }
381
+
145
382
  function parseRoute(hash) {
146
383
  const match = ROUTE_PATTERN.exec(hash)
147
384
  if (match === null) return undefined
@@ -159,7 +396,19 @@ window.__ModuleLoader__.load({
159
396
  if (rest.length < 2 || rest[0] !== WORKSPACE_SESSION_SEGMENT) return { unknown: `workspace/${match[2]}` }
160
397
  const sessionId = rest[1].trim()
161
398
  if (sessionId === '') return { unknown: `workspace/${match[2]}` }
162
- return { kind: 'workspace', id: name, inner: innerPath([WORKSPACE_SESSION_SEGMENT, sessionId]) }
399
+ // The view a conversation is showing is part of the conversation, so it may follow.
400
+ const tail = rest.slice(2)
401
+ // `turn` is a position like `row` and `pane`, and it composes with `row`
402
+ // (`turn/<n>/row/…`): without this branch a workspace-shaped turn link lands in
403
+ // `unknown` and is never claimed at all.
404
+ const turnAt = tail.indexOf(TURN_SEGMENT)
405
+ if (turnAt >= 0 && tail.length > turnAt + 1 && tail[turnAt + 1].trim() !== '') return { kind: 'workspace', id: name, inner: innerPath([WORKSPACE_SESSION_SEGMENT, sessionId, ...tail]) }
406
+ if (tail.length === 0) return { kind: 'workspace', id: name, inner: innerPath([WORKSPACE_SESSION_SEGMENT, sessionId]) }
407
+ if (tail.length < 2 || tail[1].trim() === '') return { unknown: `workspace/${match[2]}` }
408
+ if (tail[0] === VIEW_SEGMENT && tail.length === 2) return { kind: 'workspace', id: name, inner: innerPath([WORKSPACE_SESSION_SEGMENT, sessionId, VIEW_SEGMENT, tail[1]]) }
409
+ if (tail[0] === ROW_SEGMENT && tail.length >= 3) return { kind: 'workspace', id: name, inner: innerPath([WORKSPACE_SESSION_SEGMENT, sessionId, ...tail]) }
410
+ if (tail[0] === PANE_SEGMENT && tail.length === 2) return { kind: 'workspace', id: name, inner: innerPath([WORKSPACE_SESSION_SEGMENT, sessionId, PANE_SEGMENT, tail[1]]) }
411
+ return { unknown: `workspace/${match[2]}` }
163
412
  }
164
413
  const segments = match[2].replace(/\/+$/, '').split('/')
165
414
  const head = segments.shift() ?? ''
@@ -307,6 +556,12 @@ window.__ModuleLoader__.load({
307
556
  /** The package segment of the deepest panel page the address bar carried. */
308
557
  let panelScope
309
558
  /** The turn the user last clicked inside a conversation, with its session. */
559
+ /** What the report can say about this run: fragments it refused, kinds it left alone. */
560
+ const observed = { foreignFragment: undefined, ignored: [], warnings: [] }
561
+ let rowAnchor
562
+ /** The pane kind this claim already opened (one drive per claim, see the pane branch). */
563
+ let paneOpenedFor
564
+ /** A position the previous step asked to bring into view, driven on the next reconcile. */
310
565
  let turnAnchor
311
566
  /** How many times the linked panel was selected (bounded, see below). */
312
567
  let panelAttempts = 0
@@ -328,6 +583,7 @@ window.__ModuleLoader__.load({
328
583
  claimed = false
329
584
  panelAttempts = 0
330
585
  innerAttempts = 0
586
+ paneOpenedFor = undefined
331
587
  dropTimer()
332
588
  }
333
589
  /**
@@ -363,17 +619,37 @@ window.__ModuleLoader__.load({
363
619
  // The workspace is part of *where* the conversation lives, so it leads the route.
364
620
  // Reading it from the sidebar keeps this honest: no sidebar (or no matching row)
365
621
  // means the plain conversation route, never a guess.
622
+ // Which conversation view is showing, when it is not the default one: a plain
623
+ // conversation keeps its short route, any other view says which one it is.
624
+ // A pointed-at row is the most specific position there is, then the view, then the
625
+ // turn: they are all "where inside this conversation", so one of them is written.
626
+ const view = host.activeConversationView()
627
+ // A pointed-at row is a position, then the view. A *turn* deliberately is not: while a
628
+ // turn link is in flight the address bar belongs to it, and once it lands the link hands
629
+ // the address bar back — naming the turn here as well is what made a link look "landed"
630
+ // before it had ever driven (a real bug, live-measured).
631
+ // A pointed-at row is a position, then the view, then the turn the user pointed at —
632
+ // but never while a turn link is in flight: that link owns the address bar until it lands,
633
+ // and naming the turn here too is what made it look "landed" before it ever drove.
634
+ const anchoredTurn = linked === undefined && turnAnchor !== undefined && turnAnchor.session === id && turnAnchor.turn !== undefined && turnAnchor.turn !== '' ? [TURN_SEGMENT, turnAnchor.turn] : []
635
+ const position = [
636
+ ...(rowAnchor !== undefined && rowAnchor.session === id ? (rowAnchor.turn === undefined || rowAnchor.turn === '' ? [ROW_SEGMENT, ...rowAnchor.parts] : [TURN_SEGMENT, rowAnchor.turn, ROW_SEGMENT, ...rowAnchor.parts]) : []),
637
+ ...(anchoredTurn.length > 0 && rowAnchor !== undefined && rowAnchor.session === id ? [] : anchoredTurn),
638
+ ...(view !== undefined && view.index !== 0 ? [VIEW_SEGMENT, view.id] : []),
639
+ ]
640
+ // The right-hand pane is its own layer, not a position inside the conversation:
641
+ // it is named next to whatever the main column shows, never instead of it — so it
642
+ // is read *before* the workspace branch, or the workspace would swallow it.
643
+ const pane = host.activePaneKind()
644
+ const tail = pane === undefined ? [] : [PANE_SEGMENT, pane]
366
645
  const workspaceName = host.workspaceNameOf(id)
367
646
  if (workspaceName !== undefined) {
368
- return { kind: 'workspace', id: workspaceName, inner: innerPath([WORKSPACE_SESSION_SEGMENT, id]) }
647
+ return { kind: 'workspace', id: workspaceName, inner: innerPath([WORKSPACE_SESSION_SEGMENT, id, ...position, ...tail]) }
369
648
  }
370
- const pane = host.activePaneKind()
371
- if (pane !== undefined) return { kind: 'session', id, inner: innerPath([PANE_SEGMENT, pane]) }
372
- // The turn the user last pointed at is a position inside this conversation;
649
+ if (tail.length === 0 && position.length > 0) return { kind: 'session', id, inner: innerPath(position) }
650
+ if (tail.length > 0) return { kind: 'session', id, inner: innerPath([...position, ...tail]) }
651
+ // The turn the user last pointed at is a position inside this conversation;
373
652
  // it is only named while the same conversation is the one on screen.
374
- if (turnAnchor !== undefined && turnAnchor.session === id) {
375
- return { kind: 'session', id, inner: innerPath([TURN_SEGMENT, turnAnchor.turn]) }
376
- }
377
653
  return { kind: 'session', id }
378
654
  }
379
655
  return undefined
@@ -383,22 +659,78 @@ window.__ModuleLoader__.load({
383
659
  // surface, so the link lands at once instead of sitting there until its window expires.
384
660
  // A *named* section against a different named section is a real difference, so a section
385
661
  // link still drives the overlay to the section it asks for.
386
- const sameRoute = (left, right) => {
387
- if (routeKey(left) === routeKey(right)) return true
388
- // A workspace route is about the conversation it names; the workspace name is what the
389
- // link *shows*, so a stale name must not keep the link from landing.
390
- if (left?.kind === 'workspace' || right?.kind === 'workspace') {
391
- if (left?.kind !== right?.kind) return false
392
- return innerPath(left.inner)[1] === innerPath(right.inner)[1]
662
+ // Whether the view on screen already *is* what a link asked for.
663
+ //
664
+ // The rule is directional on purpose: a link may be less specific than the view (a bare
665
+ // `#/settings` is satisfied by any section, `#/session/<id>` by any view of it), but a view
666
+ // that is less specific than the link never satisfies it — otherwise a link naming a view
667
+ // would look "already there" and never be driven.
668
+ const sessionIdOfRoute = route => {
669
+ if (route?.kind === 'workspace') return innerPath(route.inner)[1]
670
+ if (route?.kind === 'session') return route.id
671
+ return undefined
672
+ }
673
+ const viewIdOfRoute = route => {
674
+ const parts = innerPath(route?.inner)
675
+ const at = parts.indexOf(VIEW_SEGMENT)
676
+ return at < 0 ? undefined : parts[at + 1]
677
+ }
678
+
679
+ const paneOf = route => {
680
+ const parts = innerPath(route?.inner)
681
+ const at = parts.indexOf(PANE_SEGMENT)
682
+ return at < 0 ? undefined : parts[at + 1]
393
683
  }
394
- if (left?.kind !== 'settings' || right?.kind !== 'settings') return false
395
- if (left.id === '' || right.id === '') return true
396
- // One level deeper: the same section is satisfied while a link names no tab, but a
397
- // link that names a tab has to see that tab.
398
- if (left.id !== right.id) return false
399
- const leftTab = innerPath(left.inner)[0] ?? ''
400
- const rightTab = innerPath(right.inner)[0] ?? ''
401
- return leftTab === '' || rightTab === ''
684
+ const sameRoute = (current, linked) => {
685
+ // A drive re-enters this machine, and re-entry can find the link already dropped: a link
686
+ // that is no longer in flight is never "the same route" as anything.
687
+ if (linked === undefined || linked === null) return false
688
+ if (routeKey(current) === routeKey(linked)) return true
689
+ const currentSession = sessionIdOfRoute(current)
690
+ const linkedSession = sessionIdOfRoute(linked)
691
+ if (currentSession !== undefined && linkedSession !== undefined) {
692
+ if (currentSession !== linkedSession) return false
693
+ const linkedView = viewIdOfRoute(linked)
694
+ if (linkedView === undefined) {
695
+ // A row or turn link is a position inside the conversation, so it stays exact; any other
696
+ // route that names the same conversation is satisfied by it being on screen —
697
+ // the workspace name is decoration, not a condition.
698
+ const parts = innerPath(linked.inner)
699
+ if (parts.includes(TURN_SEGMENT)) {
700
+ // A turn link lands when that turn is really in view. Comparing routes here called it
701
+ // landed while the seat was still outside the viewport (live-measured): the address bar
702
+ // is what the *link* already guarantees, so the route comparison proved nothing.
703
+ // Unknown geometry counts as "not in view" — the safe side: it keeps the drive alive
704
+ // rather than dropping a link that has not moved anything yet.
705
+ const turnWanted = parts[parts.indexOf(TURN_SEGMENT) + 1]
706
+ const seat = attemptValue(() => document?.querySelector?.(`[${TURN_ATTRIBUTE}="${turnWanted}"]`))
707
+ if (seat === null || seat === undefined) return false
708
+ // The official viewport reads exactly this: a seat that is hidden, or inside anything
709
+ // hidden, is not on screen — and the virtual list does render such rows (measured: the
710
+ // seat for the wanted turn existed while the turn was plainly not loaded).
711
+ if (seat.hidden === true) return false
712
+ if (attemptValue(() => seat.closest?.('[hidden]')) !== null && attemptValue(() => seat.closest?.('[hidden]')) !== undefined) return false
713
+ const rect = attemptValue(() => seat.getBoundingClientRect?.())
714
+ if (rect === null || rect === undefined) return false
715
+ const viewHeight = attemptValue(() => window.innerHeight)
716
+ if (typeof viewHeight !== 'number' || !(viewHeight > 0)) return false
717
+ return rect.height > 0 && rect.bottom > 0 && rect.top < viewHeight
718
+ }
719
+ if (parts.includes(ROW_SEGMENT)) return routeKey(current) === routeKey(linked)
720
+ // A pane is its own layer, named next to the conversation: the route *shape* (with or
721
+ // without the workspace) is decoration, exactly like the workspace name — what has to
722
+ // match is the pane kind itself.
723
+ if (parts.includes(PANE_SEGMENT)) return paneOf(current) === paneOf(linked)
724
+ return true
725
+ }
726
+ // A link that names a view has to see that view.
727
+ return viewIdOfRoute(current) === linkedView
728
+ }
729
+ if (current?.kind !== 'settings' || linked?.kind !== 'settings') return false
730
+ if (linked.id === '') return true
731
+ if (current.id !== linked.id) return false
732
+ const linkedTab = innerPath(linked.inner)[0] ?? ''
733
+ return linkedTab === '' || innerPath(current.inner)[0] === linkedTab
402
734
  }
403
735
  /** Write the fragment for the current view, leaving path and query untouched. */
404
736
  const reflect = route => {
@@ -407,6 +739,18 @@ window.__ModuleLoader__.load({
407
739
  // path or the launch query, and comparing them made every call look like a change.
408
740
  const wantedHash = route === undefined ? '' : routeHash(route.kind, route.id, route.inner)
409
741
  if (hash === wantedHash) return
742
+ // Somebody else's fragment is on screen: taking it over would erase their link, so
743
+ // this router stays quiet and says why instead.
744
+ const foreign = foreignFragmentOf(hash)
745
+ if (foreign !== undefined) {
746
+ observed.foreignFragment = foreign
747
+ const known = observed.warnings.includes(`refused to write over: ${foreign}`)
748
+ if (!known) {
749
+ observed.warnings.push(`refused to write over: ${foreign}`)
750
+ host.warn(`${TAG} not writing a route while ${foreign} is in the address bar: that fragment belongs to another plugin`)
751
+ }
752
+ return
753
+ }
410
754
  // Nothing to name and nothing named: leave the address bar exactly as it is
411
755
  // (this also leaves a foreign fragment alone, which the caller never reaches).
412
756
  if (route === undefined && hash === '') return
@@ -418,15 +762,30 @@ window.__ModuleLoader__.load({
418
762
  */
419
763
  const reconcile = (source = 'boot') => {
420
764
  if (disposed) return
765
+
421
766
  const current = canonical()
422
767
  const currentKey = routeKey(current)
423
768
  const moved = currentKey !== seen
424
769
  seen = currentKey
770
+ // The right bar holds a tab this build cannot name: say it once, with the tab's own
771
+ // attributes. A silent pane layer is the worst of both worlds — this is how the shape
772
+ // reaches a bug report instead of a guess.
773
+ const paneTabs = attemptValue(() => window.document?.querySelector?.(`[${RIGHT_BAR_STRIP_ATTRIBUTE}]`)?.querySelectorAll?.('[role="tab"]')?.length) ?? 0
774
+ if (paneTabs > 0 && host.activePaneKind() === undefined) {
775
+ 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'}`)
776
+ }
777
+ // A position the previous step asked for: it is driven here, one step later, so the step
778
+ // that claimed it never drives while it is still running.
425
779
  if (linked === undefined) {
426
780
  reflect(current)
427
781
  return
428
782
  }
429
- if (sameRoute(current, linked)) {
783
+ // Snapshot the link for the rest of this step. Driving a position re-enters this machine
784
+ // (and re-entry can drop the link), so every read below must use the snapshot: reading the
785
+ // live binding again is how a drive turned into "Cannot read properties of undefined".
786
+ const link = linked
787
+
788
+ if (sameRoute(current, link)) {
430
789
  dropRetry()
431
790
  // The link has landed: it stops being authoritative right here, so the user
432
791
  // keeps owning the address bar afterwards (walking deeper inside a panel
@@ -435,17 +794,51 @@ window.__ModuleLoader__.load({
435
794
  reflect(current)
436
795
  return
437
796
  }
438
- if (host.now() >= linked.deadline) {
439
- const hash = routeHash(linked.kind, linked.id, linked.inner)
797
+ if (host.now() >= link.deadline) {
798
+ const hash = routeHash(link.kind, link.id, link.inner)
440
799
  host.warn(`${TAG} ${hash} did not become the shown view within ${LINK_ENFORCE_WINDOW_MS} ms; the link was ignored`)
441
800
  dropLink()
442
801
  reflect(canonical())
443
802
  return
444
803
  }
445
- if (linked.kind === 'workspace') {
804
+ // A conversation link may name which view it wants, on either route shape; the view is
805
+ // driven before the plain conversation/workspace branches, or they would swallow it.
806
+ const rowParts = innerPath(link.inner)
807
+ const rowAt = rowParts.indexOf(ROW_SEGMENT)
808
+ const wantedTurn = rowParts.indexOf(TURN_SEGMENT)
809
+ const rowSession = link.kind === 'workspace' ? rowParts[1] : link.kind === 'session' ? link.id : undefined
810
+ if (rowSession !== undefined && rowAt >= 0 && rowParts.length > rowAt + 2) {
811
+ const shown = host.mainViewSessionId()
812
+ if (shown !== rowSession && panelAttempts < INNER_ATTEMPT_LIMIT) {
813
+ panelAttempts += 1
814
+ host.openSession(rowSession)
815
+ } else if (shown === rowSession && innerAttempts < INNER_ATTEMPT_LIMIT) {
816
+ innerAttempts += 1
817
+ if (!host.revealPosition(rowParts.slice(rowAt)) && wantedTurn >= 0) host.revealPosition(rowParts.slice(wantedTurn, wantedTurn + 2), rowSession)
818
+ }
819
+ armRetry()
820
+ return
821
+ }
822
+ const wantedView = link.kind === 'session' || link.kind === 'workspace' ? viewIdOfRoute(link) : undefined
823
+ const wantedSession = link.kind === 'workspace' ? innerPath(link.inner)[1] : link.kind === 'session' ? link.id : undefined
824
+ if (wantedView !== undefined && wantedSession !== undefined) {
825
+ // Unlike the boot restore, a named view is driven whatever brought the route in —
826
+ // a pasted view link exists precisely to switch the view, so it must not be skipped.
827
+ const shown = host.mainViewSessionId()
828
+ if (shown !== wantedSession && panelAttempts < INNER_ATTEMPT_LIMIT) {
829
+ panelAttempts += 1
830
+ host.openSession(wantedSession)
831
+ } else if (shown === wantedSession && innerAttempts < INNER_ATTEMPT_LIMIT) {
832
+ innerAttempts += 1
833
+ host.openConversationView(wantedView)
834
+ }
835
+ armRetry()
836
+ return
837
+ }
838
+ if (link.kind === 'workspace') {
446
839
  // The workspace is where the conversation lives, not a separate surface: the link
447
840
  // is satisfied by that conversation being on screen, whatever the name says.
448
- const target = innerPath(linked.inner)[1]
841
+ const target = innerPath(link.inner)[1]
449
842
  const shown = host.mainViewSessionId()
450
843
  if (shown !== target && source !== 'hash' && panelAttempts < INNER_ATTEMPT_LIMIT) {
451
844
  panelAttempts += 1
@@ -454,35 +847,67 @@ window.__ModuleLoader__.load({
454
847
  armRetry()
455
848
  return
456
849
  }
457
- if (linked.kind === 'settings') {
850
+ if (link.kind === 'settings') {
458
851
  if (source !== 'hash' && innerAttempts < INNER_ATTEMPT_LIMIT) {
459
852
  innerAttempts += 1
460
- host.openSettings(linked.id, innerPath(linked.inner)[0])
853
+ host.openSettings(link.id, innerPath(link.inner)[0])
461
854
  }
462
855
  armRetry()
463
856
  return
464
857
  }
465
- if (linked.kind === 'session' && linked.inner !== undefined && innerPath(linked.inner)[0] === PANE_SEGMENT) {
466
- if (source !== 'hash' && innerAttempts < INNER_ATTEMPT_LIMIT) {
858
+ if (link.kind === 'session' && link.inner !== undefined && innerPath(link.inner)[0] === PANE_SEGMENT) {
859
+ // A pasted pane link is exactly what this drive is for, so the source is not a filter
860
+ // here (the same lesson as the view branch); the attempt bound still limits it.
861
+ const wantedPane = innerPath(link.inner)[1]
862
+ // A page type that may be opened more than once (measured: `multiple: true`, the
863
+ // terminal) makes *every* attempt open another tab, so one claim drives once and the
864
+ // retry then only waits for the bar to report the tab it opened.
865
+ if (innerAttempts < INNER_ATTEMPT_LIMIT && paneOpenedFor !== wantedPane) {
467
866
  innerAttempts += 1
468
- host.openPane(innerPath(linked.inner)[1])
867
+ paneOpenedFor = wantedPane
868
+ host.openPane(wantedPane, link.kind === 'panel' ? 'panel' : 'session', message => host.warn(message))
469
869
  }
470
870
  armRetry()
471
871
  return
472
872
  }
473
- if (linked.kind === 'session' && linked.inner !== undefined && innerPath(linked.inner)[0] === TURN_SEGMENT) {
474
- // A turn link is a position, not a surface: the conversation is opened, and the
475
- // view is told once that this build does not scroll to the turn (see README).
476
- // Record the position so the canonical form keeps it, then fall through to the
477
- // ordinary conversation branch: a turn link opens the conversation, and saying so
478
- // in the console would only be noise.
479
- turnAnchor = { session: linked.id, turn: innerPath(linked.inner)[1] }
873
+ if (link.kind === 'session' && link.inner !== undefined && innerPath(link.inner)[0] === TURN_SEGMENT) {
874
+ // A turn link is a position, and a drivable one: the conversation is opened and the turn
875
+ // is brought into view in the same step, because the link lands as soon as the address
876
+ // bar names it. A turn that is not loaded yet is paged back through its seq.
877
+ //
878
+ // The position is recorded only *after* the drive: recording it first would make the
879
+ // canonical form equal the link already, so a link that has not moved anything would
880
+ // look "landed" and never be driven (a real bug, live-measured).
881
+ const wantedTurn = innerPath(link.inner)[1]
882
+ const wantedSession = link.id
883
+ // Drive first, record second. Recording first would make the canonical form equal the
884
+ // link on the *next* reconcile — before the drive ever ran, so the link would look
885
+ // "landed" and the position would never be brought into view (live-measured). Driving
886
+ // first means the work is done by the time the form can match.
887
+ if (innerAttempts < INNER_ATTEMPT_LIMIT) {
888
+ innerAttempts += 1
889
+ // The conversation has to be on screen before anything inside it can be brought into
890
+ // view: driving a turn against a conversation that is not shown is a silent no-op, which
891
+ // is what a cold-opened turn link used to do. So navigate first, and drive only once the
892
+ // conversation is really the main view.
893
+ // Bring the conversation up *and* drive the position, the way a row link does: the drive is
894
+ // idempotent on a conversation that is not shown yet (no seats, nothing to scroll to, and
895
+ // the paging path is a no-op too), and waiting for the main-view counter to catch up is
896
+ // what left a cold-opened turn link doing nothing at all.
897
+ host.openSession(wantedSession)
898
+ host.revealPosition(innerPath([TURN_SEGMENT, wantedTurn]), wantedSession)
899
+ // A row may follow the turn (`turn/<n>/row/…`): reveal it after the turn.
900
+ const rowAt = innerPath(link.inner).indexOf(ROW_SEGMENT)
901
+ if (rowAt >= 0) host.revealPosition(innerPath(link.inner).slice(rowAt), wantedSession)
902
+ }
903
+ turnAnchor = { session: wantedSession, turn: wantedTurn }
904
+ return
480
905
  }
481
- if (linked.kind === 'panel') {
482
- if (linked.inner !== undefined && innerPath(linked.inner)[0] === PANE_SEGMENT) {
483
- if (source !== 'hash' && innerAttempts < INNER_ATTEMPT_LIMIT) {
906
+ if (link.kind === 'panel') {
907
+ if (link.inner !== undefined && innerPath(link.inner)[0] === PANE_SEGMENT) {
908
+ if (innerAttempts < INNER_ATTEMPT_LIMIT) {
484
909
  innerAttempts += 1
485
- host.openPane(innerPath(linked.inner)[1])
910
+ host.openPane(innerPath(link.inner)[1], link.kind === 'panel' ? 'panel' : 'session', message => host.warn(message))
486
911
  }
487
912
  armRetry()
488
913
  return
@@ -492,7 +917,7 @@ window.__ModuleLoader__.load({
492
917
  // panel store moves (a late-loading plugin), not on every catalog tick.
493
918
  // `current` may be undefined when nothing is on screen yet; reading through it
494
919
  // unconditionally would abort the whole step inside the guard.
495
- const panelShown = current !== undefined && current.kind === 'panel' && current.id === linked.id
920
+ const panelShown = current !== undefined && current.kind === 'panel' && current.id === link.id
496
921
  if (!panelShown) {
497
922
  // Selecting a panel can be refused or swallowed while the shell is still
498
923
  // settling, so the request is retried (on later notifications and on a
@@ -503,7 +928,7 @@ window.__ModuleLoader__.load({
503
928
  // in flight first (the layout exposes exactly that pair), otherwise the
504
929
  // selection can be swallowed by the boot restore.
505
930
  host.beginNavigation()
506
- host.selectPanel(linked.id)
931
+ host.selectPanel(link.id)
507
932
  }
508
933
  armRetry()
509
934
  return
@@ -514,30 +939,44 @@ window.__ModuleLoader__.load({
514
939
  // once that panel has rendered: the request is retried on every later
515
940
  // notification (not on the hash read itself, to avoid a double request),
516
941
  // with a bound so an unopenable page cannot turn into a click loop.
517
- if (linked.inner !== undefined && routeKey(current) !== routeKey(linked) && source !== 'hash' && innerAttempts < INNER_ATTEMPT_LIMIT) {
942
+ if (link.inner !== undefined && routeKey(current) !== routeKey(link) && source !== 'hash' && innerAttempts < INNER_ATTEMPT_LIMIT) {
518
943
  innerAttempts += 1
519
- host.openPanelPage(linked.id, linked.inner)
944
+ host.openPanelPage(link.id, link.inner)
520
945
  }
521
946
  armRetry()
522
947
  return
523
948
  }
524
949
  // A session link: refuse immediately when the conversation cannot be opened at
525
950
  // all, otherwise hand the navigation over once and wait for the view to land.
526
- if (host.isArchived(linked.id)) {
527
- host.warn(`${TAG} ${routeHash(linked.kind, linked.id)} is archived; the link was ignored`)
951
+ if (host.isArchived(link.id)) {
952
+ host.warn(`${TAG} ${routeHash(link.kind, link.id)} is archived; the link was ignored`)
528
953
  dropLink()
529
954
  reflect(canonical())
530
955
  return
531
956
  }
532
- if ((!claimed || moved) && host.isOpenable(linked.id)) {
957
+ if ((!claimed || moved) && host.isOpenable(link.id)) {
533
958
  claimed = true
534
- host.openSession(linked.id)
959
+ host.openSession(link.id)
535
960
  }
536
961
  }
537
962
  /** Adopt the surface the address bar names, if it names one. */
538
963
  const readLink = () => {
539
964
  if (disposed) return
540
- const parsed = parseRoute(host.readLocation().hash)
965
+ const hash = host.readLocation().hash
966
+ // Another writer owns the fragment: name it once and step aside. Overwriting it here
967
+ // is how two routers silently erase each other's links.
968
+ const foreign = foreignFragmentOf(hash)
969
+ if (foreign !== undefined) {
970
+ const known = observed.foreignFragment === foreign
971
+ observed.foreignFragment = foreign
972
+ dropLink()
973
+ if (!known) {
974
+ 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`)
975
+ observed.warnings.push(`foreign fragment: ${foreign}`)
976
+ }
977
+ return
978
+ }
979
+ const parsed = parseRoute(hash)
541
980
  if (parsed === undefined) {
542
981
  dropLink()
543
982
  reconcile('hash')
@@ -546,6 +985,7 @@ window.__ModuleLoader__.load({
546
985
  if (parsed.unknown !== undefined) {
547
986
  // Another router's route (or a typo): leave the address bar alone and say so
548
987
  // once, so a router owning more kinds can take over later.
988
+ if (!observed.ignored.includes(parsed.unknown)) observed.ignored.push(parsed.unknown)
549
989
  if (linked === undefined) host.warn(`${TAG} ignoring unknown route kind "${parsed.unknown}"`)
550
990
  return
551
991
  }
@@ -615,27 +1055,91 @@ window.__ModuleLoader__.load({
615
1055
  if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
616
1056
  const target = event.target
617
1057
  if (typeof target?.closest !== 'function') return
618
- const node = target.closest(`[${TURN_ATTRIBUTE}]`)
619
- if (node === null) return
1058
+ // A trajectory row names itself first: it carries its own identity (and is often a
1059
+ // button), while a chat turn carries only a turn number. Both paths are pure observers
1060
+ // — nothing is prevented, so clicks keep behaving exactly as the views expect.
1061
+ const session = host.mainViewSessionId()
1062
+ const rowOwner = attemptValue(() => host.rowIdentityOf(target))
1063
+ if (rowOwner !== undefined) {
1064
+ if (session !== undefined) {
1065
+ if (rowOwner[0] === TURN_SEGMENT && rowOwner.length === 2) {
1066
+ turnAnchor = { session, turn: rowOwner[1] }
1067
+ rowAnchor = undefined
1068
+ } else {
1069
+ // The view's own key carries the turn for assistant nodes but not for tool
1070
+ // calls; when the row sits between two rendered turn boundaries the turn is
1071
+ // provable, so the link may say it. Otherwise it stays out of the route.
1072
+ const turn = rowOwner.includes(TURN_SEGMENT) ? undefined : attemptValue(() => host.turnOfRow(target))
1073
+ rowAnchor = turn === undefined || turn === '' ? { session, parts: rowOwner } : { session, turn, parts: rowOwner }
1074
+ turnAnchor = undefined
1075
+ }
1076
+ safeReconcile('row')
1077
+ }
1078
+ return
1079
+ }
620
1080
  // One selector at a time: a comma list is the kind of thing a small DOM stub (and a
621
1081
  // future minimal engine) may not implement, and the answer must be the same.
622
1082
  for (const interactive of ['button', 'a', 'input', 'textarea', 'select', '[role="button"]', '[contenteditable="true"]']) {
623
1083
  if (target.closest(interactive) !== null) return
624
1084
  }
1085
+ const node = target.closest(`[${TURN_ATTRIBUTE}]`)
1086
+ if (node === null) return
1087
+ const id = session
1088
+ if (id === undefined) return
625
1089
  const raw = node.getAttribute?.(TURN_ATTRIBUTE)
626
1090
  const turn = typeof raw === 'string' ? raw.trim() : ''
627
1091
  if (turn === '') return
628
- const id = host.mainViewSessionId()
629
- if (id === undefined) return
630
1092
  turnAnchor = { session: id, turn }
631
- safeReconcile('turn')
632
- }
1093
+ rowAnchor = undefined
1094
+ safeReconcile('turn') }
633
1095
  try {
634
1096
  window.document?.addEventListener?.('click', onDocumentClick, true)
635
1097
  } catch {
636
1098
  // A composition without the DOM listener is still a working router.
637
1099
  }
638
1100
 
1101
+ /**
1102
+ * The route health report: every layer this router knows about, the shape it is in right
1103
+ * now, and everything it refused to touch. Reachable as `window.__dshUrlRouter.report()`
1104
+ * — a debug seam, so it never throws and never writes anything.
1105
+ * @returns {object} the report.
1106
+ */
1107
+ const report = () => {
1108
+ const current = attemptValue(() => canonical())
1109
+ const document = window.document
1110
+ const count = selector => attemptValue(() => document?.querySelectorAll?.(selector)?.length) ?? '\u2014'
1111
+ return {
1112
+ version: ROUTER_VERSION,
1113
+ hash: attemptValue(() => host.readLocation().hash) ?? '',
1114
+ canonical: current === undefined ? null : { kind: current.kind, id: current.id, inner: innerPath(current.inner) },
1115
+ link: linked === undefined ? null : { hash: routeHash(linked.kind, linked.id, linked.inner), attempts: { panel: panelAttempts, inner: innerAttempts } },
1116
+ surfaces: {
1117
+ conversationRows: count(`[data-row-key^="${ROW_KEY_PREFIX}"]`),
1118
+ workspaceRows: count(`[data-row-key^="${WORKSPACE_ROW_PREFIX}"]`),
1119
+ viewTabs: count(`[${CONVERSATION_TABS_ATTRIBUTE}]`),
1120
+ viewIds: attemptValue(() => host.conversationViewIds?.()) ?? [],
1121
+ turns: count(`[${TURN_ATTRIBUTE}]`),
1122
+ trajectoryRows: count(`[${TRAJECTORY_ROW_ATTRIBUTE}]`),
1123
+ turnBoundaries: count(`[${TRAJECTORY_TURN_START_ATTRIBUTE}]`),
1124
+ pluginPackages: count('[data-plugin-package]'),
1125
+ officialItems: count('[data-plugin-item]'),
1126
+ settingsOverlay: count(`[${SETTINGS_SURFACE_ATTRIBUTE}="${SETTINGS_SURFACE_VALUE}"]`),
1127
+ settingsSection: attemptValue(() => host.activeSettingsSection()) ?? null,
1128
+ taskBoardOpen: count(`[${TASK_BOARD_OPEN_ATTRIBUTE}]`),
1129
+ taskBoardNew: count(`[${TASK_BOARD_NEW_ATTRIBUTE}]`),
1130
+ paneActive: attemptValue(() => host.activePaneKind()) ?? null,
1131
+ paneKinds: attemptValue(() => host.paneKinds?.()) ?? null,
1132
+ },
1133
+ compat: observed.compat,
1134
+ foreignFragment: observed.foreignFragment,
1135
+ ignoredKinds: observed.ignored,
1136
+ warnings: observed.warnings,
1137
+ }
1138
+ }
1139
+ guard(() => {
1140
+ window.__dshUrlRouter = { version: ROUTER_VERSION, report, when: host.now() }
1141
+ })
1142
+
639
1143
  return () => {
640
1144
  disposed = true
641
1145
  try {
@@ -974,7 +1478,7 @@ window.__ModuleLoader__.load({
974
1478
  // browser wiring: probe the official faces, then drive the machine.
975
1479
  // ---------------------------------------------------------------------
976
1480
  /** Services this plugin needs before apply runs; `layout` is optional and read through `ctx.get`. */
977
- const inject = ['sessions', 'workspaces', 'uiWorkspace', 'slots', 'sidebarRightTabs']
1481
+ const inject = ['sessions', 'workspaces', 'uiWorkspace', 'slots', 'sidebarRightTabs', 'uiConversation']
978
1482
 
979
1483
  /**
980
1484
  * Probe the faces the router needs.
@@ -987,6 +1491,11 @@ window.__ModuleLoader__.load({
987
1491
  */
988
1492
  function probeFaces(scope) {
989
1493
  const list = scope.sessions?.list
1494
+ // The session controller itself (not just its catalog): the turn drive needs
1495
+ // `retainScope(id, source)` to reach a session that is not loaded yet. It lives in `inject`
1496
+ // for the same reason `uiConversation` does — cordis only hands a service to a plugin that
1497
+ // declared it — and reading it here is what makes the paging path reachable at all.
1498
+ const sessions = scope.sessions
990
1499
  const registry = scope.workspaces?.list
991
1500
  const navigation = scope.uiWorkspace
992
1501
  if (typeof list !== 'object' || list === null) return null
@@ -1012,6 +1521,9 @@ window.__ModuleLoader__.load({
1012
1521
  const openRightbar = typeof layout?.openRightbar === 'function' ? layout.openRightbar.bind(layout) : undefined
1013
1522
  const sidebarRightTabs = typeof scope.get === 'function' ? scope.get('sidebarRightTabs') : undefined
1014
1523
  const sidebarRight = typeof scope.get === 'function' ? scope.get('sidebarRight') : undefined
1524
+ // `uiConversation` is a root service, but cordis only hands it to a plugin that
1525
+ // declared it in `inject` — declaring it is what makes the view registry readable.
1526
+ const uiConversation = typeof scope.get === 'function' ? scope.get('uiConversation') : undefined
1015
1527
  return {
1016
1528
  faces: {
1017
1529
  catalog: list,
@@ -1019,9 +1531,11 @@ window.__ModuleLoader__.load({
1019
1531
  openSession: id => openSession.call(navigation, id),
1020
1532
  selectPanel: selectPanel === undefined ? undefined : id => selectPanel(id),
1021
1533
  beginNavigation: beginNavigation === undefined ? undefined : () => beginNavigation(),
1022
- openRightbar: openRightbar === undefined ? undefined : () => openRightbar(),
1534
+ openRightbar: openRightbar === undefined ? undefined : track => openRightbar(track),
1023
1535
  sidebarRightTabs,
1024
1536
  sidebarRight,
1537
+ uiConversation,
1538
+ sessions,
1025
1539
  panels,
1026
1540
  slots,
1027
1541
  },
@@ -1049,7 +1563,7 @@ window.__ModuleLoader__.load({
1049
1563
  const ids = attempt(() => faces.archivedSessionIds(), [])
1050
1564
  return Array.isArray(ids) ? ids : []
1051
1565
  }
1052
- return {
1566
+ const host = {
1053
1567
  mainViewSessionId: () => attempt(() => mainViewSessionId(faces.catalog.getSnapshot().byId), undefined),
1054
1568
  // The sidebar lists workspaces and their sessions in one tree, in order, so the
1055
1569
  // workspace a conversation belongs to is the nearest workspace row before it.
@@ -1151,15 +1665,168 @@ window.__ModuleLoader__.load({
1151
1665
  openSession: id => {
1152
1666
  attempt(() => faces.openSession(id))
1153
1667
  },
1668
+ // A row's own identity, in the shape the trajectory views publish it. The value is a
1669
+ // percent-encoded NUL-separated triple, so the route can read as a plain path.
1670
+ rowIdentityOf: element => {
1671
+ let node = element
1672
+ for (let depth = 0; node !== null && node !== undefined && depth < 12; depth += 1, node = node.parentElement) {
1673
+ if (typeof node.getAttribute !== 'function') break
1674
+ const key = node.getAttribute(TRAJECTORY_ROW_ATTRIBUTE)
1675
+ if (typeof key === 'string' && key !== '') {
1676
+ const parts = key.split('%00').map(part => part.trim()).filter(part => part !== '')
1677
+ if (parts.length >= 2) return parts
1678
+ }
1679
+ const turn = node.getAttribute(TRAJECTORY_TURN_ATTRIBUTE)
1680
+ if (typeof turn === 'string' && turn.trim() !== '') return [TURN_SEGMENT, turn.trim()]
1681
+ }
1682
+ return undefined
1683
+ },
1684
+ // The turn a row belongs to, but only when it is *provable*: the trajectory view
1685
+ // virtualizes, so the evidence is a turn boundary rendered before the row, and that is
1686
+ // trustworthy exactly when the next boundary is also rendered after it. Otherwise the
1687
+ // answer is undefined — never a guess.
1688
+ turnOfRow: element => {
1689
+ if (typeof element?.compareDocumentPosition !== 'function') return undefined
1690
+ const boundaries = window.document?.querySelectorAll?.(`[${TRAJECTORY_TURN_START_ATTRIBUTE}]`)
1691
+ if (boundaries === undefined || boundaries === null || boundaries.length === 0) return undefined
1692
+ const following = window.Node?.DOCUMENT_POSITION_FOLLOWING ?? 4
1693
+ const preceding = window.Node?.DOCUMENT_POSITION_PRECEDING ?? 2
1694
+ let before
1695
+ let after
1696
+ for (const boundary of boundaries) {
1697
+ const position = attemptValue(() => element.compareDocumentPosition(boundary)) ?? 0
1698
+ const turn = (boundary.getAttribute?.(TRAJECTORY_TURN_START_ATTRIBUTE) ?? '').trim()
1699
+ if (turn === '') continue
1700
+ if ((position & following) !== 0) after ??= turn
1701
+ if ((position & preceding) !== 0) before = turn
1702
+ }
1703
+ if (before === undefined || after === undefined || before === after) return undefined
1704
+ return before
1705
+ },
1706
+ // Best effort: scroll to the named row when it is rendered.
1707
+ /**
1708
+ * Bring the named position into view.
1709
+ *
1710
+ * A trajectory row is scrolled to when it is rendered. A *turn* needs history paged
1711
+ * back first, and that part is a public slot contract rather than a guess:
1712
+ * `session.loadThrough(seq)` pages backwards until the window covers the seq of that
1713
+ * turn's start event (measured in the core, and the same path the chat view uses). The
1714
+ * seq comes from the `turnOutline` projection, which is readable through the session
1715
+ * controller's projections face.
1716
+ * @param {Array} parts - the inner path segments.
1717
+ * @returns {boolean} whether the position was brought into view.
1718
+ */
1719
+ revealPosition: (parts, wantedSession) => {
1720
+ const wanted = innerPath(parts)
1721
+ const document = window.document
1722
+ if (wanted[0] === ROW_SEGMENT && wanted.length >= 3) {
1723
+ const key = wanted.slice(1).join('%00')
1724
+ for (const candidate of document?.querySelectorAll?.(`[${TRAJECTORY_ROW_ATTRIBUTE}]`) ?? []) {
1725
+ if (candidate.getAttribute?.(TRAJECTORY_ROW_ATTRIBUTE) !== key) continue
1726
+ attempt(() => candidate.scrollIntoView?.({ block: 'center' }))
1727
+ return true
1728
+ }
1729
+ return false
1730
+ }
1731
+ if (wanted[0] !== TURN_SEGMENT) return false
1732
+ const turn = wanted[1]
1733
+ // 1) Already rendered: the chat seats carry `data-chat-turn` (what the official
1734
+ // ChatNodeSeat prints), so scroll to it and be done.
1735
+ // Official viewport selector: a seat that is hidden, or inside anything hidden, is not on
1736
+ // screen — and the virtual list renders such seats for turns that are plainly not loaded.
1737
+ for (const candidate of document?.querySelectorAll?.(`[${TURN_ATTRIBUTE}]:not([hidden])`) ?? []) {
1738
+ if (candidate.getAttribute?.(TURN_ATTRIBUTE) !== turn) continue
1739
+ attempt(() => candidate.scrollIntoView?.({ block: 'center' }))
1740
+ return true
1741
+ }
1742
+ // 2) Not loaded yet: page history back through that turn's seq, then its seat renders.
1743
+ //
1744
+ // The session controller is reached by *retaining* its scope: `sessions.binding(id)`
1745
+ // answers undefined for a session nobody materialised yet (measured), while
1746
+ // `retainScope(id, source)` materialises it and returns a reference whose `binding`
1747
+ // is the live one. The reference is released as soon as the page-back is asked for, so
1748
+ // retaining costs nothing when the turn turns out to be unloadable.
1749
+ // The session the position belongs to is passed in by the caller, because the turn that
1750
+ // drove it may not be the main view *yet* (a link that just opened it): reading it back
1751
+ // from the catalog here is how this step used to look up "no session".
1752
+ const reference = attemptValue(() => faces.sessions?.retainScope?.(wantedSession, 'url-router'))
1753
+ const session = attemptValue(() => reference?.binding?.session)
1754
+ const loadThrough = attemptValue(() => (session === undefined || session === null ? undefined : session.loadThrough))
1755
+ const outline = attemptValue(() => session?.projections?.faceOf?.('turnOutline')?.getSnapshot?.())
1756
+ const seq = Array.isArray(outline) ? outline.find(entry => entry && String(entry.turn) === String(turn))?.seq : undefined
1757
+ const usable = typeof loadThrough === 'function' && typeof reference?.release === 'function'
1758
+ if (typeof seq !== 'number' || !usable) {
1759
+ attemptValue(() => reference?.release?.())
1760
+ return false
1761
+ }
1762
+ attemptValue(() => loadThrough(seq))
1763
+ attemptValue(() => reference.release())
1764
+ return true
1765
+ },
1766
+ // Which conversation view is showing. The registry is the source of truth for ids
1767
+ // (`ctx.uiConversation.views`), the header renders its tabs in registration order,
1768
+ // and the active tab is the aria-selected one — so the position maps to an id.
1769
+ activeConversationView: () => {
1770
+ // One selector at a time: the container first, then its tabs inside it.
1771
+ const row = window.document?.querySelector?.(`[${CONVERSATION_TABS_ATTRIBUTE}]`)
1772
+ const tabs = typeof row?.querySelectorAll === 'function' ? row.querySelectorAll('[role="tab"]') : undefined
1773
+ if (tabs === undefined || tabs === null || tabs.length === 0) return undefined
1774
+ let index = -1
1775
+ for (let position = 0; position < tabs.length; position += 1) {
1776
+ const tab = tabs[position]
1777
+ if (typeof tab.getAttribute !== 'function') continue
1778
+ if (tab.getAttribute('aria-selected') !== 'true') continue
1779
+ index = position
1780
+ break
1781
+ }
1782
+ if (index < 0) return undefined
1783
+ const ids = conversationViewIdsOf(faces)
1784
+ if (ids === undefined || ids.length !== tabs.length) return undefined
1785
+ return { id: ids[index], index, total: ids.length }
1786
+ },
1787
+ openConversationView: viewId => {
1788
+ const row = window.document?.querySelector?.(`[${CONVERSATION_TABS_ATTRIBUTE}]`)
1789
+ const tabs = typeof row?.querySelectorAll === 'function' ? row.querySelectorAll('[role="tab"]') : undefined
1790
+ if (tabs === undefined || tabs === null) return
1791
+ const ids = conversationViewIdsOf(faces)
1792
+ if (ids === undefined) return
1793
+ const index = ids.indexOf(viewId)
1794
+ const tab = index < 0 ? undefined : tabs[index]
1795
+ if (typeof tab?.click === 'function') attempt(() => tab.click())
1796
+ },
1154
1797
  // The right-hand pane names the tab in front. The service is the source of truth;
1155
1798
  // the DOM marker is a fallback for a composition where the service is absent.
1799
+ rightBarSessionId: () => rightBarSessionIdOf(window.document),
1800
+ activePaneTab: () => activePaneTabOf(window.document),
1156
1801
  activePaneKind: () => {
1157
- const face = faces.sidebarRightTabs
1158
- if (face !== undefined) {
1159
- const snapshot = attempt(() => (typeof face.getSnapshot === 'function' ? face.getSnapshot() : undefined))
1160
- const active = snapshot === undefined ? undefined : snapshot.activeTabId ?? snapshot.activeTab ?? snapshot.active
1161
- if (typeof active === 'string' && active !== '') return active
1802
+ // Both services answer `active`; the controller is authoritative when it does, and
1803
+ // `active` may be exposed as a value or as a method. Neither exposes `getSnapshot`.
1804
+ // `active` can be a string, a tab descriptor or a method returning either, and the
1805
+ // bar's own tab objects carry the kind under a few plausible names.
1806
+ const kindOf = value => {
1807
+ if (typeof value === 'string') return value === '' ? undefined : value
1808
+ if (value === null || typeof value !== 'object') return undefined
1809
+ const nested = value.tab ?? value.entry ?? value.options
1810
+ const candidates = [value.kind, value.id, value.tabId, value.name, nested?.kind, nested?.id]
1811
+ for (const candidate of candidates) {
1812
+ if (typeof candidate === 'string' && candidate !== '' && candidate !== 'pane1') return candidate
1813
+ }
1814
+ return undefined
1162
1815
  }
1816
+ const read = owner => {
1817
+ if (owner === undefined || owner === null) return undefined
1818
+ const value = typeof owner.active === 'function' ? attemptValue(() => owner.active()) : owner.active
1819
+ const direct = kindOf(value)
1820
+ if (direct !== undefined) return direct
1821
+ const snapshot = attemptValue(() => (typeof owner.getSnapshot === 'function' ? owner.getSnapshot() : undefined))
1822
+ return snapshot === undefined ? undefined : kindOf(snapshot.activeTabId ?? snapshot.activeTab ?? snapshot.active)
1823
+ }
1824
+ const fromStrip = activePaneTabOf(window.document, paneRegistryOf(faces))
1825
+ if (fromStrip !== undefined) return fromStrip
1826
+ const fromController = read(faces.sidebarRight)
1827
+ if (fromController !== undefined) return fromController
1828
+ const fromRegistry = read(faces.sidebarRightTabs)
1829
+ if (fromRegistry !== undefined) return fromRegistry
1163
1830
  // DOM fallback, deliberately narrow: the active-pane marker is shared with the
1164
1831
  // main dockkit, so it only counts when it really belongs to the right bar.
1165
1832
  const active = window.document?.querySelector?.('[data-dockkit-pane-active]')
@@ -1236,15 +1903,83 @@ window.__ModuleLoader__.load({
1236
1903
  : undefined
1237
1904
  if (typeof labelled?.click === 'function') attempt(() => labelled.click())
1238
1905
  },
1239
- openPane: kind => {
1906
+ openPane: (kind, track, warn) => {
1240
1907
  if (typeof kind !== 'string' || kind === '') return
1241
- const face = faces.sidebarRightTabs ?? faces.sidebarRight
1242
- if (face !== undefined && typeof face.openTab === 'function') {
1243
- attempt(() => face.openTab(kind))
1908
+ // A pane link is a claim like any other, so it is checked before it is made: the
1909
+ // registry names every kind the bar can show, and asking for one it does not know
1910
+ // would leave the link sitting there doing nothing. (Measured live: guide,
1911
+ // subagentchat, changes-review, plan, files, text, terminal.)
1912
+ const known = attemptValue(() => paneRegistryOf(faces))?.map(row => row.kind)
1913
+ if (Array.isArray(known) && !known.includes(kind)) {
1914
+ warn(`${TAG} the right bar has no pane type "${kind}"; it knows: ${known.join(', ')}`)
1244
1915
  return
1245
1916
  }
1246
- attempt(() => faces.openRightbar?.())
1917
+ // `openTab(kind, options)` belongs to the controller (`sidebarRight`); the registry
1918
+ // only knows the kinds. Asking the registry to open one fails silently.
1919
+ const controller = faces.sidebarRight
1920
+ // Present the right column first: a session's store is adopted only once the bar is
1921
+ // shown, and every open on an unadopted session is a silent no-op.
1922
+ const expanded = attemptValue(() => (typeof controller?.isExpanded === 'function' ? controller.isExpanded() : undefined))
1923
+ if (expanded === false) {
1924
+ const wanted = typeof track === 'string' && track !== '' ? track : (attemptValue(() => faces.mainViewSessionId()) !== undefined ? 'session' : 'panel')
1925
+ attempt(() => faces.openRightbar?.(wanted))
1926
+ }
1927
+ const session = rightBarSessionIdOf(window.document)
1928
+ const expand = () => {
1929
+ const expanded = attemptValue(() => (typeof controller?.isExpanded === 'function' ? controller.isExpanded() : true))
1930
+ if (expanded === false) attempt(() => controller?.toggleExpanded?.())
1931
+ }
1932
+ if (controller !== undefined && controller !== null) {
1933
+ // The controller a root plugin reaches is not "mounted" for a session, and `openTab(kind)`
1934
+ // throws exactly then ("no session surface is mounted") — so the per-session path is the
1935
+ // real one, and it takes the session **first**: `openTabIn(sessionId, kind, options)`.
1936
+ // (Passed the other way round it looks up a session called "files" and does nothing.)
1937
+ const opened = attemptValue(() => {
1938
+ if (typeof controller.openTabIn === 'function' && typeof session === 'string' && session !== '') {
1939
+ controller.openTabIn(session, kind, {})
1940
+ return true
1941
+ }
1942
+ if (typeof controller.openTab === 'function') {
1943
+ controller.openTab(kind, {})
1944
+ return true
1945
+ }
1946
+ return false
1947
+ })
1948
+ if (opened === true) {
1949
+ expand()
1950
+ return
1951
+ }
1952
+
1953
+ }
1954
+ // Last resort: the same action the right bar's empty-state card runs. It is the
1955
+ // bar's own "open this kind" entry, so using it is not a workaround.
1956
+ const guides = attemptValue(() => {
1957
+ const registry = faces.sidebarRightTabs
1958
+ if (registry === undefined || registry === null) return undefined
1959
+ const rows = typeof registry.guideEntries === 'function' ? registry.guideEntries() : registry.guideEntries
1960
+ if (Array.isArray(rows)) return rows
1961
+ const guide = typeof registry.guide === 'function' ? registry.guide() : registry.guide
1962
+ return Array.isArray(guide) ? guide : undefined
1963
+ })
1964
+ if (!Array.isArray(guides)) return
1965
+ for (const entry of guides) {
1966
+ if (entry === null || typeof entry !== 'object') continue
1967
+ const entryKind = entry.kind ?? entry.options?.kind ?? entry.id
1968
+ if (typeof entryKind !== 'string' || entryKind !== kind) continue
1969
+ const run = entry.onPick ?? entry.action ?? entry.select ?? entry.open ?? entry.run
1970
+ if (typeof run === 'function') {
1971
+ attempt(() => run())
1972
+ expand()
1973
+ return
1974
+ }
1975
+ }
1247
1976
  },
1977
+ // The kinds the bar can show: link targets are checked against this before claiming.
1978
+ paneKinds: () => paneRegistryOf(faces)?.map(row => row.kind),
1979
+ // The rows the registry publishes, for the report and for the drive's check.
1980
+ paneRegistry: () => paneRegistryOf(faces),
1981
+ // The conversation view ids the registry publishes, for the report.
1982
+ conversationViewIds: () => conversationViewIdsOf(faces),
1248
1983
  beginNavigation: () => {
1249
1984
  if (faces.beginNavigation === undefined) return
1250
1985
  attempt(() => faces.beginNavigation())
@@ -1311,6 +2046,7 @@ window.__ModuleLoader__.load({
1311
2046
  }, () => {}),
1312
2047
  warn: message => console.warn(message),
1313
2048
  }
2049
+ return host
1314
2050
  }
1315
2051
 
1316
2052
  /**
@@ -1330,6 +2066,17 @@ window.__ModuleLoader__.load({
1330
2066
  console.warn(`${TAG} the layout service exposes no panel store; only conversation routes are routed`)
1331
2067
  }
1332
2068
  return startUrlRouting(browserHost(probed.faces))
2069
+ // The session-scoped seat. A conversation's jump loader (`session.loadThrough(seq)`) lives
2070
+ // on the session controller, and a root plugin only reaches a session by *contributing* to
2071
+ // a session-scoped slot — the same seat the official chat and the family views use
2072
+ // (`inject: (sessionId) => …`). The entry itself draws nothing: it exists so the callback
2073
+ // runs with the session in hand. The entry itself is inert (it renders nothing and adds
2074
+ // nothing to the tail), and it rides a `list` slot so it can never become the chosen view.
2075
+ // (A session-scoped seat was tried here: contributing to a session-scoped slot is how a
2076
+ // root plugin reaches a session controller. It did not fire — measured live — and the
2077
+ // session-scoped slots that *would* fire (`conversation.view`) always add a visible tab,
2078
+ // so the seat was withdrawn. `sessionFor` stays as the seam for when a seam exists.)
2079
+ probed.faces.sessionFor = () => undefined
1333
2080
  }, 'url-router: fragment routing')
1334
2081
  // Row links: every conversation row is exposed as a real link to its route, so
1335
2082
  // the browser's own link menu (open in new tab / copy link) works on it.