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/AGENTS.md +63 -2
- package/README.md +14 -6
- package/lib/client.js +819 -72
- package/package.json +3 -2
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
|
-
|
|
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
|
-
|
|
371
|
-
if (
|
|
372
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
//
|
|
397
|
-
|
|
398
|
-
if (
|
|
399
|
-
const
|
|
400
|
-
const
|
|
401
|
-
|
|
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
|
-
|
|
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() >=
|
|
439
|
-
const hash = routeHash(
|
|
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
|
-
|
|
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(
|
|
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 (
|
|
850
|
+
if (link.kind === 'settings') {
|
|
458
851
|
if (source !== 'hash' && innerAttempts < INNER_ATTEMPT_LIMIT) {
|
|
459
852
|
innerAttempts += 1
|
|
460
|
-
host.openSettings(
|
|
853
|
+
host.openSettings(link.id, innerPath(link.inner)[0])
|
|
461
854
|
}
|
|
462
855
|
armRetry()
|
|
463
856
|
return
|
|
464
857
|
}
|
|
465
|
-
if (
|
|
466
|
-
|
|
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
|
-
|
|
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 (
|
|
474
|
-
// A turn link is a position,
|
|
475
|
-
// view
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
|
|
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 (
|
|
482
|
-
if (
|
|
483
|
-
if (
|
|
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(
|
|
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 ===
|
|
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(
|
|
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 (
|
|
942
|
+
if (link.inner !== undefined && routeKey(current) !== routeKey(link) && source !== 'hash' && innerAttempts < INNER_ATTEMPT_LIMIT) {
|
|
518
943
|
innerAttempts += 1
|
|
519
|
-
host.openPanelPage(
|
|
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(
|
|
527
|
-
host.warn(`${TAG} ${routeHash(
|
|
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(
|
|
957
|
+
if ((!claimed || moved) && host.isOpenable(link.id)) {
|
|
533
958
|
claimed = true
|
|
534
|
-
host.openSession(
|
|
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
|
|
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
|
-
|
|
619
|
-
|
|
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
|
-
|
|
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 :
|
|
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
|
-
|
|
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
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
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
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
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
|
-
|
|
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.
|