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