dsh-url-router 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -35,11 +35,26 @@
35
35
 
36
36
  window.__ModuleLoader__.load({
37
37
  id: 'dsh-url-router',
38
- factory: () => {
38
+ factory: require => {
39
39
  const module = { exports: {} }
40
40
  const exports = module.exports
41
41
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
42
42
 
43
+ // ---------------------------------------------------------------------
44
+ // React, when the module loader hands one over.
45
+ //
46
+ // Routing, the address bar and the row links are plain DOM, so a host without
47
+ // React still gets every route. Only the plugin manager's settings card is a
48
+ // React component; without the runtime the card is not registered at all, and
49
+ // nothing else changes.
50
+ // ---------------------------------------------------------------------
51
+ let react
52
+ try {
53
+ react = typeof require === 'function' ? require('react') : undefined
54
+ } catch {
55
+ react = undefined
56
+ }
57
+
43
58
  // ---------------------------------------------------------------------
44
59
  // main-view session: resolve the Session the main view currently shows.
45
60
  // Reads the catalog's rows rather than a per-id retain-info source, so this
@@ -126,19 +141,39 @@ window.__ModuleLoader__.load({
126
141
  const SIDEBAR_RIGHT_SERVICE = 'sidebarRightTabs'
127
142
  /** Panel id of the official plugin manager, whose list rows and detail pages are routed. */
128
143
  const PLUGINS_PANEL_ID = 'plugins'
144
+ /** Panel id of the automation-task manager, whose status filter and open task are routed. */
145
+ const SCHEDULES_PANEL_ID = 'schedules'
146
+ /** Inner segment that names the automation task a list has open. */
147
+ const TASK_SEGMENT = 'task'
148
+ /** Inner segment that names the model-provider card an open editor belongs to. */
149
+ const PROVIDER_SEGMENT = 'provider'
150
+ /** Inner segment that names the automation list's status filter. */
151
+ const FILTER_SEGMENT = 'filter'
152
+ /**
153
+ * The status filters, in the fixed order the manager renders them.
154
+ *
155
+ * Its own source maps `["all", "active", "inactive"]` over the three buttons, and the
156
+ * buttons carry no value of their own (only `aria-pressed`), so the position is the
157
+ * identity — the same positional contract the conversation view tabs already use.
158
+ */
159
+ const SCHEDULE_FILTERS = ['all', 'active', 'inactive']
160
+ /** Attribute the manager puts on a task-detail tab, whose value is `rule` or `records`. */
161
+ const SCHEDULE_TAB_ATTRIBUTE = 'data-detail-tab'
162
+ /** The right bar's own "collapsed" marker: its tabs stay mounted while the bar is shut. */
163
+ const RIGHT_BAR_COLLAPSED_ATTRIBUTE = 'data-rightbar-collapsed'
129
164
  /** Log prefix for every diagnostic. */
130
165
  /**
131
166
  * The router's own version, for the report a human files. A test keeps it equal to the
132
167
  * package version, so a release cannot forget to bump it.
133
168
  */
134
- const ROUTER_VERSION = '0.4.1'
169
+ const ROUTER_VERSION = '0.4.2'
135
170
  /**
136
171
  * Hand-maintained build marker, reported alongside the version. A working copy that is
137
172
  * linked into a profile serves this file straight from disk, so the package version
138
173
  * cannot say *which* copy a live window loaded — this can, and `report().build` is how a
139
174
  * real-machine check answers that. Bump it whenever a live window must be told apart.
140
175
  */
141
- const ROUTER_BUILD = 'desktop-bar+settings-marker+modal-dismiss+mac-header-bar.2'
176
+ const ROUTER_BUILD = 'desktop-bar+settings-marker+modal-dismiss+mac-header-bar+bar-select-all+recent-links+copy-link+automation-links+page-over-pane+band-yield+provider-card+perf+compat+turn-jump.12'
142
177
 
143
178
  const TAG = '[url-router]'
144
179
 
@@ -153,6 +188,11 @@ window.__ModuleLoader__.load({
153
188
  /** The bar's structural hook, the way the row anchors carry theirs. */
154
189
  const TITLEBAR_PART = 'url-router-address-bar'
155
190
  const TITLEBAR_STYLE_ID = 'dsh-url-router-titlebar-style'
191
+ /** Structural hooks the bar's recent list publishes (tests and the observer read them). */
192
+ const RECENT_LIST_ATTRIBUTE = 'data-dsh-url-router-recent'
193
+ const RECENT_ITEM_ATTRIBUTE = 'data-dsh-url-router-recent-item'
194
+ /** The bar's copy-confirmation chip. */
195
+ const TOAST_ATTRIBUTE = 'data-dsh-url-router-toast'
156
196
  /** Width the window's caption buttons take at the right end of the strip. */
157
197
  const TITLEBAR_CAPTION_WIDTH = 138
158
198
  /** Gap between the shell's menu and the bar. */
@@ -236,6 +276,59 @@ window.__ModuleLoader__.load({
236
276
  }
237
277
  return leaves
238
278
  }
279
+ /**
280
+ * The things whose presence on a line means "do not draw over this".
281
+ *
282
+ * Controls only: a page heading or an icon is decoration the bar may sit on, while a tab, a
283
+ * button or a field is something to click or type into (measured: the automation task's detail
284
+ * tabs were exactly what the bar covered).
285
+ */
286
+ const BAND_SELECTORS = ['button', '[role="tab"]', '[role="button"]', 'a', 'input', 'textarea', 'select', '[contenteditable="true"]', 'h1', 'h2', 'h3']
287
+ /**
288
+ * The page's own contents that stand on one line of the window, as merged spans.
289
+ *
290
+ * A screen with no header row to measure (a panel, the settings overlay) still draws things
291
+ * at the bar's line: the automation task's detail tabs, a page heading, a toolbar. Reusing a
292
+ * stretch measured on a conversation put the bar straight across those tabs (measured on the
293
+ * automation panel). Only controls, headings and icons count — a background or a plain
294
+ * container is not something to give way to.
295
+ * @param {object} document - the page.
296
+ * @param {object} own - the bar itself, whose box is never an obstacle.
297
+ * @param {number} top - the line's top edge.
298
+ * @param {number} height - the line's height.
299
+ * @returns {Array<{left: number, right: number}>} merged spans, left to right.
300
+ */
301
+ function bandSpans(document, own, top, height) {
302
+ const bottom = top + height
303
+ const seen = new Set()
304
+ const spans = []
305
+ // One selector at a time: a comma list is beyond a small DOM stub.
306
+ for (const selector of BAND_SELECTORS) {
307
+ for (const node of attemptValue(() => [...(document?.querySelectorAll?.(selector) ?? [])]) ?? []) {
308
+ if (node === null || node === undefined || seen.has(node)) continue
309
+ seen.add(node)
310
+ const mine = node === own || (typeof own?.contains === 'function' && own.contains(node) === true)
311
+ if (mine) continue
312
+ const rect = attemptValue(() => node.getBoundingClientRect?.())
313
+ if (rect === undefined || rect === null) continue
314
+ if (!(rect.height > 0) || rect.top >= bottom || rect.top + rect.height <= top) continue
315
+ const left = Math.round(rect.left)
316
+ const right = Math.round(rect.left + rect.width)
317
+ if (right > left) spans.push({ left, right })
318
+ }
319
+ }
320
+ spans.sort((first, second) => first.left - second.left)
321
+ const merged = []
322
+ for (const span of spans) {
323
+ const last = merged[merged.length - 1]
324
+ if (last !== undefined && span.left <= last.right) {
325
+ if (span.right > last.right) last.right = span.right
326
+ continue
327
+ }
328
+ merged.push({ left: span.left, right: span.right })
329
+ }
330
+ return merged
331
+ }
239
332
  /**
240
333
  * The header row the bar has to live in: the row that carries the
241
334
  * conversation's title on the left and the header's own buttons on the right.
@@ -248,8 +341,9 @@ window.__ModuleLoader__.load({
248
341
  * @returns {object|undefined} the row element, or undefined when there is none.
249
342
  */
250
343
  function macHeaderRow(trigger) {
251
- const corner = document.querySelector?.('[data-conversation-header-corner]')
252
- const named = document.querySelector?.('[class*="titleRow"]')
344
+ const page = attemptValue(() => window.document)
345
+ const corner = attemptValue(() => page?.querySelector?.('[data-conversation-header-corner]'))
346
+ const named = attemptValue(() => page?.querySelector?.('[class*="titleRow"]'))
253
347
  if (named !== undefined && named !== null) {
254
348
  // Only the row that actually holds the button is the row to measure.
255
349
  if (trigger === undefined || trigger === null) return named
@@ -331,6 +425,24 @@ window.__ModuleLoader__.load({
331
425
  `#${TITLEBAR_BAR_ID} input:focus{border-color:var(--dsw-alias-brand-primary,rgba(79,124,255,.9))}`,
332
426
  `#${TITLEBAR_BAR_ID}[data-dsh-url-router-state="refused"] input{border-color:rgba(214,69,69,.85);color:rgba(214,69,69,1)}`,
333
427
  ].join('')
428
+ /**
429
+ * The recent list's own stylesheet: a browser-style drop-down under the bar.
430
+ *
431
+ * Both platforms share it — the bar is the same fixed-position element in both — so
432
+ * the picker can never drift into two different-looking lists.
433
+ */
434
+ const RECENT_STYLE_TEXT = [
435
+ `#${TITLEBAR_BAR_ID} [${RECENT_LIST_ATTRIBUTE}]{position:absolute;top:calc(100% + 4px);left:0;right:0;z-index:5;display:none;box-sizing:border-box;max-height:min(52vh,420px);overflow:auto;padding:4px;border:1px solid rgba(127,127,127,.34);border-radius:10px;background:var(--dsw-alias-bg-layer-1,#fff);box-shadow:0 16px 40px rgba(0,0,0,.16);-webkit-app-region:no-drag}`,
436
+ `#${TITLEBAR_BAR_ID} [${RECENT_LIST_ATTRIBUTE}][data-open]{display:block}`,
437
+ `#${TITLEBAR_BAR_ID} [${RECENT_ITEM_ATTRIBUTE}]{display:flex;align-items:center;gap:8px;width:100%;box-sizing:border-box;padding:6px 8px;border:none;border-radius:7px;background:transparent;color:inherit;font:inherit;font-size:12px;line-height:18px;text-align:left;cursor:pointer}`,
438
+ `#${TITLEBAR_BAR_ID} [${RECENT_ITEM_ATTRIBUTE}]:hover,#${TITLEBAR_BAR_ID} [${RECENT_ITEM_ATTRIBUTE}]:focus-visible{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.16));outline:none}`,
439
+ `#${TITLEBAR_BAR_ID} .dshur-kind{flex:none;padding:0 6px;border-radius:5px;background:rgba(127,127,127,.16);color:var(--dsw-alias-label-secondary,#4b5872);font-size:11px;line-height:18px}`,
440
+ `#${TITLEBAR_BAR_ID} .dshur-title{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-primary,#1b1b20)}`,
441
+ `#${TITLEBAR_BAR_ID} .dshur-link{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary,#81858c);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px}`,
442
+ `#${TITLEBAR_BAR_ID} [${RECENT_ITEM_ATTRIBUTE}][data-copied] .dshur-link{color:var(--dsw-alias-brand-primary,#4d6bfe);font-weight:600}`,
443
+ `#${TITLEBAR_BAR_ID} [${TOAST_ATTRIBUTE}]{position:absolute;right:6px;top:50%;transform:translateY(-50%);z-index:6;padding:0 8px;line-height:20px;border-radius:999px;border:1px solid rgba(127,127,127,.34);background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-secondary,#4b5872);font-size:11px;pointer-events:none;opacity:0;transition:opacity .12s}`,
444
+ `#${TITLEBAR_BAR_ID} [${TOAST_ATTRIBUTE}][data-show]{opacity:1}`,
445
+ ].join('')
334
446
  /** Listeners told the fragment this router just wrote; the desktop bar reads it. */
335
447
  const routeFeed = new Set()
336
448
  /**
@@ -338,6 +450,9 @@ window.__ModuleLoader__.load({
338
450
  * @param {string} hash - the fragment just written.
339
451
  */
340
452
  function publishRoute(hash) {
453
+ // A fragment this router just wrote names the screen the reader moved to, so it is
454
+ // remembered (the bar's recent list). An empty write names nothing and is skipped.
455
+ rememberRecent(hash)
341
456
  for (const listener of [...routeFeed]) {
342
457
  try {
343
458
  listener(hash)
@@ -363,6 +478,282 @@ window.__ModuleLoader__.load({
363
478
  */
364
479
  let driveTypedFragment = () => {}
365
480
 
481
+ // ---------------------------------------------------------------------
482
+ // preferences: how the address bar behaves, remembered on this machine
483
+ //
484
+ // Two small preferences make the bar act like a browser's: the first click
485
+ // selects all of the old link, and the bar remembers the screens it has named
486
+ // plus the links pasted into it. They belong to this window rather than to the
487
+ // profile, so they live in localStorage; the plugin manager page writes them
488
+ // (`#/panel/plugins/dsh-url-router/dsh-url-router`).
489
+ // ---------------------------------------------------------------------
490
+ /** Preference record: the two switches the settings page owns. */
491
+ const SETTINGS_KEY = 'dsh-url-router-settings'
492
+ /** Remembered links, newest first. */
493
+ const RECENT_KEY = 'dsh-url-router-recent'
494
+ /** What the bar does before the settings page has ever been opened. */
495
+ const DEFAULT_SETTINGS = { selectAllOnFocus: true, recentLimit: 8 }
496
+ /**
497
+ * The key the plugin manager dispatches a row's configuration under:
498
+ * `<package name>#<row id>`. This package declares one row whose package name and
499
+ * row id are the same string, so the key names it twice — and the page it opens is
500
+ * exactly `#/panel/plugins/dsh-url-router/dsh-url-router`.
501
+ */
502
+ const ROW_CONFIG_KEY = 'dsh-url-router#dsh-url-router'
503
+
504
+ /**
505
+ * How much work the two observers have caused, for the report.
506
+ *
507
+ * A streaming answer mutates the page dozens of times a second; these counters are what a
508
+ * real-machine check reads to tell "coalesced" from "reconciled on every batch".
509
+ */
510
+ const workCounts = { reconciles: 0, placements: 0 }
511
+ /** Listeners told the preferences changed; the settings card redraws from them. */
512
+ const settingsListeners = new Set()
513
+ /** The parsed record, read once and kept in step with every write. */
514
+ let settingsCache
515
+
516
+ /**
517
+ * The settings record, with anything unusable replaced by its default.
518
+ * @param {unknown} raw - the parsed record, or anything that was on disk.
519
+ * @returns {{selectAllOnFocus: boolean, recentLimit: number}} the record.
520
+ */
521
+ function normaliseSettings(raw) {
522
+ const source = raw !== null && typeof raw === 'object' ? raw : {}
523
+ const limit = Number(source.recentLimit)
524
+ return {
525
+ selectAllOnFocus: typeof source.selectAllOnFocus === 'boolean' ? source.selectAllOnFocus : DEFAULT_SETTINGS.selectAllOnFocus,
526
+ // A count of remembered links: whole, never negative, and with no upper bound
527
+ // the reader did not ask for.
528
+ recentLimit: Number.isFinite(limit) && limit >= 0 ? Math.floor(limit) : DEFAULT_SETTINGS.recentLimit,
529
+ }
530
+ }
531
+
532
+ /**
533
+ * Read the settings, once.
534
+ * @returns {{selectAllOnFocus: boolean, recentLimit: number}} the record in force.
535
+ */
536
+ function readSettings() {
537
+ if (settingsCache !== undefined) return settingsCache
538
+ settingsCache = normaliseSettings(attemptValue(() => {
539
+ const raw = globalThis.localStorage?.getItem?.(SETTINGS_KEY)
540
+ return raw === null || raw === undefined || raw === '' ? undefined : JSON.parse(raw)
541
+ }))
542
+ return settingsCache
543
+ }
544
+
545
+ /**
546
+ * Change the settings, keep them, and tell the card.
547
+ * @param {object} patch - the fields to change.
548
+ * @returns {object} the record now in force.
549
+ */
550
+ function updateSettings(patch) {
551
+ const next = normaliseSettings({ ...readSettings(), ...(patch !== null && typeof patch === 'object' ? patch : {}) })
552
+ settingsCache = next
553
+ attemptValue(() => globalThis.localStorage?.setItem?.(SETTINGS_KEY, JSON.stringify(next)))
554
+ // A lower count takes effect now, not at the next remembered link.
555
+ trimRecent()
556
+ for (const listener of [...settingsListeners]) {
557
+ try {
558
+ listener(next)
559
+ } catch {
560
+ // A card that throws must not stop the bar from reading the new record.
561
+ }
562
+ }
563
+ return next
564
+ }
565
+
566
+ /**
567
+ * Follow the settings from something that draws them.
568
+ * @param {Function} listener - called with the record after every change.
569
+ * @returns {() => void} the unsubscribe.
570
+ */
571
+ function subscribeSettings(listener) {
572
+ settingsListeners.add(listener)
573
+ return () => settingsListeners.delete(listener)
574
+ }
575
+
576
+ /**
577
+ * One remembered link, exactly as it was stored.
578
+ * @param {unknown} raw - one entry off disk.
579
+ * @returns {{hash: string, at: number, source: string}|undefined} the entry, or undefined when unusable.
580
+ */
581
+ function recentEntryOf(raw) {
582
+ if (raw === null || typeof raw !== 'object') return undefined
583
+ const hash = typeof raw.hash === 'string' ? raw.hash.trim() : ''
584
+ if (!hash.startsWith('#') || hash === '#') return undefined
585
+ const at = Number(raw.at)
586
+ return { hash, at: Number.isFinite(at) ? at : 0, source: raw.source === 'paste' ? 'paste' : 'visit' }
587
+ }
588
+
589
+ /**
590
+ * The remembered links, newest first.
591
+ * @returns {Array<object>} the entries, in the order they are shown.
592
+ */
593
+ function readRecent() {
594
+ const raw = attemptValue(() => {
595
+ const text = globalThis.localStorage?.getItem?.(RECENT_KEY)
596
+ return text === null || text === undefined || text === '' ? [] : JSON.parse(text)
597
+ })
598
+ if (!Array.isArray(raw)) return []
599
+ return raw.map(recentEntryOf).filter(entry => entry !== undefined)
600
+ }
601
+
602
+ /**
603
+ * Put the remembered links on disk.
604
+ * @param {Array<object>} entries - the entries to keep, newest first.
605
+ */
606
+ function writeRecent(entries) {
607
+ attemptValue(() => globalThis.localStorage?.setItem?.(RECENT_KEY, JSON.stringify(entries)))
608
+ }
609
+
610
+ /**
611
+ * The screen a remembered link stands for.
612
+ *
613
+ * A link may name a position inside a screen (`turn/…`, `row/…`, a panel's inner
614
+ * page); the memory is of *screens*, so the identity stops at the surface. Without
615
+ * this every click inside a conversation would push another entry of the same
616
+ * conversation and crowd the list out.
617
+ * @param {string} hash - the fragment.
618
+ * @returns {string} the identity two links of the same screen share.
619
+ */
620
+ function recentIdentity(hash) {
621
+ const route = parseRoute(String(hash ?? ''))
622
+ if (route === undefined || route.unknown !== undefined) return String(hash ?? '')
623
+ if (route.kind === 'workspace') {
624
+ const inner = innerPath(route.inner)
625
+ return inner.length >= 2 ? `${ROW_KEY_PREFIX}${inner[1]}` : `${route.kind}:${route.id}`
626
+ }
627
+ return `${route.kind}:${route.id}`
628
+ }
629
+
630
+ /**
631
+ * The screen the address bar names right now.
632
+ * @returns {string} the current fragment, or an empty string off a page.
633
+ */
634
+ function currentScreenHash() {
635
+ return typeof window?.location?.hash === 'string' ? window.location.hash : ''
636
+ }
637
+
638
+ /**
639
+ * How many entries are kept on disk.
640
+ *
641
+ * The count the reader sets is the number of links the drop-down offers — the screen in
642
+ * the address bar is not one of them, because that is where the reader already is. That
643
+ * screen is still remembered (leave it and it comes back in the list), so it may not eat
644
+ * one of the slots: one spare slot covers it, since a screen has exactly one entry.
645
+ * @returns {number} the entries to keep.
646
+ */
647
+ function recentKeep() {
648
+ const limit = readSettings().recentLimit
649
+ return limit <= 0 ? 0 : limit + 1
650
+ }
651
+
652
+ /**
653
+ * Keep the remembered links at the count in force, newest first.
654
+ * @returns {Array<object>} the entries that are kept.
655
+ */
656
+ function trimRecent() {
657
+ const keep = recentKeep()
658
+ const stored = readRecent()
659
+ const kept = keep <= 0 ? [] : stored.slice(0, keep)
660
+ if (kept.length !== stored.length) writeRecent(kept)
661
+ return kept
662
+ }
663
+
664
+ /**
665
+ * The remembered links the drop-down offers: the count in force, without the screen the
666
+ * address bar already names, newest first.
667
+ * @returns {Array<object>} the entries, in the order they are shown.
668
+ */
669
+ function recentEntries() {
670
+ const limit = readSettings().recentLimit
671
+ // The memory is off: nothing is kept and nothing is shown.
672
+ if (limit <= 0) return []
673
+ const keep = recentKeep()
674
+ const stored = readRecent()
675
+ const kept = stored.length > keep ? stored.slice(0, keep) : stored
676
+ // Reading never writes: `report()` calls this and it must stay side-effect free.
677
+ // The disc is trimmed when a link is written and when the count changes.
678
+ const current = currentScreenHash()
679
+ return kept.filter(entry => entry.hash !== current).slice(0, limit)
680
+ }
681
+
682
+ /**
683
+ * Remember a link: a screen the view just moved to, or a link pasted into the bar.
684
+ * @param {string} hash - the fragment.
685
+ * @param {string} [source] - `paste` for a pasted link, `visit` (the default) otherwise.
686
+ */
687
+ function rememberRecent(hash, source) {
688
+ const text = typeof hash === 'string' ? hash.trim() : ''
689
+ if (!text.startsWith('#') || text === '#') return
690
+ // Nothing is remembered while the memory is off, so turning it on later starts
691
+ // from an empty list instead of resurrecting links from before.
692
+ const limit = readSettings().recentLimit
693
+ if (limit <= 0) return
694
+ const identity = recentIdentity(text)
695
+ const entries = readRecent().filter(entry => recentIdentity(entry.hash) !== identity)
696
+ entries.unshift({ hash: text, at: attemptValue(() => Date.now(), 0), source: source === 'paste' ? 'paste' : 'visit' })
697
+ writeRecent(entries.slice(0, recentKeep()))
698
+ }
699
+
700
+ /** Forget every remembered link. */
701
+ function clearRecent() {
702
+ writeRecent([])
703
+ }
704
+
705
+ /**
706
+ * The conversation title the sidebar shows for an id, when it is rendered.
707
+ * @param {string} sessionId - the conversation id.
708
+ * @returns {string} the title, or an empty string when the row is not on screen.
709
+ */
710
+ function sessionTitles() {
711
+ const titles = new Map()
712
+ const rows = attemptValue(() => window.document?.querySelectorAll?.('[data-row-key]')) ?? []
713
+ for (const row of rows) {
714
+ const key = typeof row?.getAttribute === 'function' ? row.getAttribute('data-row-key') : null
715
+ if (typeof key !== 'string' || !key.startsWith(ROW_KEY_PREFIX)) continue
716
+ const text = String(row.textContent ?? '').trim().replace(/\s+/gu, ' ').slice(0, 80)
717
+ if (text !== '') titles.set(key, text)
718
+ }
719
+ return titles
720
+ }
721
+
722
+ /**
723
+ * The title of one conversation, read from a map made once for the whole list.
724
+ * @param {string} sessionId - the conversation id.
725
+ * @param {Map<string, string>} [titles] - the row titles, when the caller already has them.
726
+ * @returns {string} the title, or an empty string when the row is not on screen.
727
+ */
728
+ function sessionTitleOf(sessionId, titles) {
729
+ const key = `${ROW_KEY_PREFIX}${sessionId}`
730
+ if (titles !== undefined) return titles.get(key) ?? ''
731
+ for (const row of attemptValue(() => window.document?.querySelectorAll?.('[data-row-key]')) ?? []) {
732
+ if (typeof row?.getAttribute !== 'function' || row.getAttribute('data-row-key') !== key) continue
733
+ return String(row.textContent ?? '').trim().replace(/\s+/gu, ' ').slice(0, 80)
734
+ }
735
+ return ''
736
+ }
737
+
738
+ /**
739
+ * The words a remembered link reads as: the conversation's own title when the
740
+ * sidebar has it, a settings section's label, or nothing (the fragment then says it).
741
+ * @param {string} hash - the fragment.
742
+ * @returns {string} the label, or an empty string.
743
+ */
744
+ function recentLabel(hash, titles) {
745
+ const route = parseRoute(String(hash ?? ''))
746
+ if (route === undefined || route.unknown !== undefined) return ''
747
+ if (route.kind === 'session') return sessionTitleOf(route.id, titles)
748
+ if (route.kind === 'workspace') {
749
+ const inner = innerPath(route.inner)
750
+ return inner.length >= 2 ? sessionTitleOf(inner[1], titles) : route.id
751
+ }
752
+ // A settings section's identity is its own display name.
753
+ if (route.kind === 'settings') return route.id
754
+ return ''
755
+ }
756
+
366
757
  /**
367
758
  * The conversation view ids the registry publishes, in registration order — the same order
368
759
  * the header renders its tabs in, which is what lets a tab position map back to an id.
@@ -519,6 +910,72 @@ window.__ModuleLoader__.load({
519
910
  warn(`${TAG} ${message}`)
520
911
  }
521
912
 
913
+ /** Notes already given, per check: a compatibility problem is worth saying once. */
914
+ const compatNotes = new Set()
915
+ /** At most one compatibility sweep per this interval: each check reads the page. */
916
+ const COMPAT_EVERY_MS = 1000
917
+ /** The phrase every compatibility note carries, so a log can be searched for it. */
918
+ const COMPAT_MESSAGE = '与当前 DSH 版本不兼容'
919
+
920
+ /**
921
+ * The screens whose hooks this router reads, and what it expects to find on them.
922
+ *
923
+ * Each check pairs a *weak* signal that the screen is up (something this build still
924
+ * recognises) with a *strong* hook the router actually needs. A screen that is up while
925
+ * its strong hook is gone is exactly the silent breakage this plugin cannot afford: links
926
+ * stop naming that surface and nothing says so. The checks only report — they never change
927
+ * what the router does.
928
+ * @param {object} document - the page.
929
+ * @param {object} context - what the router knows: the active panel, conversation count and registered views.
930
+ * @returns {Array<{name: string, expected: string, found: string}>} the problems, empty when healthy.
931
+ */
932
+ function compatibilityProblems(document, context) {
933
+ const problems = []
934
+ if (document === undefined || document === null || typeof document.querySelectorAll !== 'function') return problems
935
+ const count = selector => attemptValue(() => document.querySelectorAll?.(selector)?.length) ?? 0
936
+ // 1) The settings overlay. A modal carrying a nav of several buttons is this app's
937
+ // settings page; when neither official marker is on it, the router is reading a page it
938
+ // cannot name (this is the marker that already moved once).
939
+ for (const dialog of attemptValue(() => [...(document.querySelectorAll('[role="dialog"]') ?? [])]) ?? []) {
940
+ if (attemptValue(() => dialog.getAttribute?.('aria-modal')) !== 'true') continue
941
+ let navButtons = 0
942
+ for (const nav of attemptValue(() => [...(dialog.querySelectorAll('nav') ?? [])]) ?? []) {
943
+ navButtons += attemptValue(() => nav.querySelectorAll?.('button')?.length) ?? 0
944
+ }
945
+ if (navButtons < 3) continue
946
+ if (settingsOverlayOf(document) !== undefined) continue
947
+ problems.push({ name: 'settings-overlay', expected: 'data-shortcut-modal="settings"', found: `a modal with a ${navButtons}-button nav and no settings marker` })
948
+ break
949
+ }
950
+ // 2) The plugin manager. Its panel root is stable; its row/detail hooks are what links
951
+ // are built from.
952
+ if (count('[data-plugin-panel]') > 0) {
953
+ const hooks = count('[data-plugin-package]') + count('[data-plugin-item]') + count('[data-plugin-row]')
954
+ + count('[data-plugin-detail]') + count('[data-plugin-item-detail]') + count('[data-plugin-row-detail]')
955
+ if (hooks === 0) {
956
+ problems.push({ name: 'plugin-manager', expected: 'data-plugin-package / data-plugin-item / data-plugin-row / data-plugin-detail', found: 'a plugin panel with none of them' })
957
+ }
958
+ }
959
+ // 3) The automation list's status filter: three aria-pressed buttons in a role=group.
960
+ if (context?.activePanelId === 'schedules' || count('[data-plugin-panel="schedules"]') > 0) {
961
+ const groups = attemptValue(() => [...(document.querySelectorAll('[role="group"]') ?? [])]) ?? []
962
+ if (groups.length > 0 && scheduleFilterOf(document) === undefined) {
963
+ problems.push({ name: 'schedules-filter', expected: 'a [role="group"] holding three aria-pressed buttons', found: `${groups.length} group(s), none matching` })
964
+ }
965
+ }
966
+ // 4) The conversation header's view tabs. Only when the registry really publishes more
967
+ // than one view: a composition with a single view has no tabs to find.
968
+ if ((context?.views ?? 0) >= 2 && count('[data-chat-turn]') > 0 && count('[data-conversation-tabs]') === 0) {
969
+ problems.push({ name: 'conversation-tabs', expected: `[${CONVERSATION_TABS_ATTRIBUTE}] in the header`, found: 'chat turns with no header container' })
970
+ }
971
+ // 5) The sidebar's conversation rows. The settings entry is the "a real sidebar is up"
972
+ // signal, and the catalog says there is something to list.
973
+ if (count(`[data-slot="${SETTINGS_TRIGGER_SLOT}"]`) > 0 && (context?.sessions ?? 0) > 0 && count('[data-row-key^="session:"]') === 0) {
974
+ problems.push({ name: 'sidebar-rows', expected: '[data-row-key="session:<id>"] rows', found: `a sidebar with ${context.sessions} conversation(s) and no rows` })
975
+ }
976
+ return problems
977
+ }
978
+
522
979
  function activePaneTabOf(document, registry) {
523
980
  // Scoped to the right bar's own root: the same dockkit attributes dress the main area
524
981
  // too, and reading them unscoped reports the main area's panes, not this bar's tabs.
@@ -557,7 +1014,11 @@ window.__ModuleLoader__.load({
557
1014
  if (named !== undefined) {
558
1015
  const wanted = flatLabel(named).toLowerCase()
559
1016
  const match = Array.isArray(registry) ? registry.find(row => typeof row.label === 'string' && flatLabel(row.label).toLowerCase() === wanted) : undefined
560
- return match === undefined ? named : match.kind
1017
+ if (match !== undefined) return match.kind
1018
+ // With a registry to check against, a label that matches nothing is a *title* — the
1019
+ // open file's name, a terminal's own name — never a kind. Without a registry the
1020
+ // label is the only name there is, so it is taken as the kind.
1021
+ return Array.isArray(registry) && registry.length > 0 ? undefined : named
561
1022
  }
562
1023
  // 2) an address-shaped id, for a future build that puts one there
563
1024
  const fromTab = kindOfTabId(attemptValue(() => chosen.getAttribute?.('data-dockkit-tab')))
@@ -571,6 +1032,206 @@ window.__ModuleLoader__.load({
571
1032
  return undefined
572
1033
  }
573
1034
 
1035
+ /**
1036
+ * Whether the right-hand bar is collapsed.
1037
+ *
1038
+ * A collapsed bar keeps its tab strip mounted, so "a tab is selected in the strip" is not
1039
+ * the same question as "that pane is on screen" — measured: after closing the bar the
1040
+ * address bar kept naming its last pane, which was no longer anywhere.
1041
+ * @param {object} document - the page.
1042
+ * @returns {boolean} whether the bar is collapsed.
1043
+ */
1044
+ function rightBarCollapsedOf(document) {
1045
+ const marker = attemptValue(() => document?.querySelector?.(`[${RIGHT_BAR_COLLAPSED_ATTRIBUTE}]`))
1046
+ if (marker === null || marker === undefined) return false
1047
+ const value = attemptValue(() => marker.getAttribute?.(RIGHT_BAR_COLLAPSED_ATTRIBUTE))
1048
+ return value !== 'false'
1049
+ }
1050
+
1051
+ /**
1052
+ * The status filter the automation-task list is showing.
1053
+ *
1054
+ * Read from the group's own buttons: the pressed one's *position* among the three is the
1055
+ * filter (see `SCHEDULE_FILTERS`). A group is the manager's when it holds exactly three
1056
+ * buttons, all of them pressed-state buttons, and it is not the right bar's own.
1057
+ * @param {object} document - the page.
1058
+ * @returns {string|undefined} `all`, `active` or `inactive`.
1059
+ */
1060
+ function scheduleFilterOf(document) {
1061
+ const groups = attemptValue(() => [...(document?.querySelectorAll?.('[role="group"]') ?? [])]) ?? []
1062
+ for (const group of groups) {
1063
+ const inside = attemptValue(() => group?.closest?.(`[${RIGHT_BAR_ATTRIBUTE}]`))
1064
+ if (inside !== null && inside !== undefined) continue
1065
+ const buttons = attemptValue(() => [...(group?.querySelectorAll?.('button') ?? [])]) ?? []
1066
+ if (buttons.length !== SCHEDULE_FILTERS.length) continue
1067
+ if (!buttons.every(button => attemptValue(() => button.getAttribute?.('aria-pressed')) !== null)) continue
1068
+ const pressed = buttons.findIndex(button => attemptValue(() => button.getAttribute?.('aria-pressed')) === 'true')
1069
+ if (pressed < 0) continue
1070
+ return SCHEDULE_FILTERS[pressed]
1071
+ }
1072
+ return undefined
1073
+ }
1074
+
1075
+ /**
1076
+ * The automation task the manager has open, and the detail tab it shows.
1077
+ *
1078
+ * The row states both facts: `aria-expanded` marks the open one, and its
1079
+ * `aria-describedby` ends with the task id (`<useId>-metadata-<taskId>`). The detail's
1080
+ * tabs carry their own value (`data-detail-tab="rule" | "records"`).
1081
+ * @param {object} document - the page.
1082
+ * @returns {{id: string, tab: string}|undefined} the open task, or undefined when none is.
1083
+ */
1084
+ function scheduleTaskOf(document) {
1085
+ const buttons = attemptValue(() => [...(document?.querySelectorAll?.('button') ?? [])]) ?? []
1086
+ for (const button of buttons) {
1087
+ const described = attemptValue(() => button.getAttribute?.('aria-describedby'))
1088
+ if (typeof described !== 'string' || described === '') continue
1089
+ const match = /-metadata-(.+)$/u.exec(described)
1090
+ if (match === null) continue
1091
+ if (attemptValue(() => button.getAttribute?.('aria-expanded')) !== 'true') continue
1092
+ const tabs = attemptValue(() => [...(document?.querySelectorAll?.(`[${SCHEDULE_TAB_ATTRIBUTE}]`) ?? [])]) ?? []
1093
+ const chosen = tabs.find(tab => attemptValue(() => tab.getAttribute?.('aria-selected')) === 'true')
1094
+ const tab = attemptValue(() => chosen?.getAttribute?.(SCHEDULE_TAB_ATTRIBUTE))
1095
+ return { id: match[1], tab: typeof tab === 'string' && tab !== '' ? tab : 'rule' }
1096
+ }
1097
+ return undefined
1098
+ }
1099
+
1100
+ /**
1101
+ * The parts of a settings inner path.
1102
+ *
1103
+ * A settings route may name a tab (`[<tab>]`) and, one level deeper, the model-provider
1104
+ * card whose editor is open (`[<tab>?/provider/<id>]`). Keeping one reader for both shapes
1105
+ * is what lets the link's landing rule and the drive agree.
1106
+ * @param {string|Array<string>} inner - the inner path.
1107
+ * @returns {{tab: string|undefined, provider: string|undefined}} the parts.
1108
+ */
1109
+ function settingsPartsOf(inner) {
1110
+ const parts = innerPath(inner)
1111
+ const at = parts.indexOf(PROVIDER_SEGMENT)
1112
+ if (at < 0) return { tab: parts[0], provider: undefined }
1113
+ return { tab: at > 0 ? parts[0] : undefined, provider: parts[at + 1] }
1114
+ }
1115
+
1116
+ /**
1117
+ * The model-provider card that is open in the settings overlay, when one is.
1118
+ *
1119
+ * The models section renders a card's editor inline and publishes no data hook for it: the
1120
+ * card is an `li` that suddenly holds a text field, and its identity lives in an action's
1121
+ * accessible name, which the official copy builds as `<label> <displayName>
1122
+ * (<provider>)`. Only the parenthesised provider id is language-independent, so that is
1123
+ * what a link carries.
1124
+ * @param {object} document - the page.
1125
+ * @returns {string|undefined} the open provider id.
1126
+ */
1127
+ function openSettingsProviderId(document) {
1128
+ const overlay = settingsOverlayOf(document)
1129
+ if (overlay === null || overlay === undefined || typeof overlay.querySelectorAll !== 'function') return undefined
1130
+ for (const card of overlay.querySelectorAll('li')) {
1131
+ if (typeof card?.querySelector !== 'function') continue
1132
+ if (card.querySelector('input') === null && card.querySelector('textarea') === null) continue
1133
+ for (const button of card.querySelectorAll('button')) {
1134
+ const label = attemptValue(() => button.getAttribute?.('aria-label'))
1135
+ if (typeof label !== 'string' || label === '') continue
1136
+ const match = /\(([^()]*)\)\s*$/u.exec(label)
1137
+ if (match === null || match[1].trim() === '') continue
1138
+ return match[1].trim()
1139
+ }
1140
+ }
1141
+ return undefined
1142
+ }
1143
+
1144
+ /**
1145
+ * Open one model-provider card's editor in the settings overlay.
1146
+ * @param {string} id - the provider id the link named.
1147
+ */
1148
+ function clickSettingsProvider(id) {
1149
+ const wanted = String(id ?? '').trim().toLowerCase()
1150
+ if (wanted === '') return
1151
+ const overlay = settingsOverlayOf(window.document)
1152
+ const scope = overlay ?? window.document
1153
+ const buttons = attemptValue(() => [...(scope?.querySelectorAll?.('button') ?? [])]) ?? []
1154
+ for (const button of buttons) {
1155
+ const label = attemptValue(() => button.getAttribute?.('aria-label'))
1156
+ if (typeof label !== 'string') continue
1157
+ const match = /\(([^()]*)\)\s*$/u.exec(label)
1158
+ if (match === null || match[1].trim().toLowerCase() !== wanted) continue
1159
+ // Already open: the next press would close it, and the retry loop would then flip the
1160
+ // card open and shut until the link expired.
1161
+ const card = attemptValue(() => button.closest?.('li'))
1162
+ if (card !== null && card !== undefined) {
1163
+ const open = attemptValue(() => card.querySelector?.('input') !== null || card.querySelector?.('textarea') !== null)
1164
+ if (open === true) return
1165
+ }
1166
+ // The edit control comes before the delete control in a card's actions.
1167
+ if (typeof button.click === 'function') attemptValue(() => button.click())
1168
+ return
1169
+ }
1170
+ }
1171
+
1172
+ /**
1173
+ * Press one status filter of the automation list.
1174
+ * @param {string} token - `all`, `active` or `inactive`.
1175
+ */
1176
+ function clickScheduleFilter(token) {
1177
+ const order = SCHEDULE_FILTERS.indexOf(String(token ?? ''))
1178
+ if (order < 0) return
1179
+ const document = window.document
1180
+ const groups = attemptValue(() => [...(document?.querySelectorAll?.('[role="group"]') ?? [])]) ?? []
1181
+ for (const group of groups) {
1182
+ const inside = attemptValue(() => group?.closest?.(`[${RIGHT_BAR_ATTRIBUTE}]`))
1183
+ if (inside !== null && inside !== undefined) continue
1184
+ const buttons = attemptValue(() => [...(group?.querySelectorAll?.('button') ?? [])]) ?? []
1185
+ if (buttons.length !== SCHEDULE_FILTERS.length) continue
1186
+ if (!buttons.every(button => attemptValue(() => button.getAttribute?.('aria-pressed')) !== null)) continue
1187
+ const button = buttons[order]
1188
+ if (typeof button?.click === 'function') attemptValue(() => button.click())
1189
+ return
1190
+ }
1191
+ }
1192
+
1193
+ /**
1194
+ * Open one automation task's detail, and the detail tab it names.
1195
+ * @param {string} id - the task id.
1196
+ * @param {string} [tab] - `rule` or `records`.
1197
+ */
1198
+ function clickScheduleTask(id, tab) {
1199
+ const wanted = String(id ?? '')
1200
+ if (wanted === '') return
1201
+ const document = window.document
1202
+ const buttons = attemptValue(() => [...(document?.querySelectorAll?.('button') ?? [])]) ?? []
1203
+ let found = false
1204
+ for (const button of buttons) {
1205
+ const described = attemptValue(() => button.getAttribute?.('aria-describedby'))
1206
+ if (typeof described !== 'string' || !described.endsWith(`-metadata-${wanted}`)) continue
1207
+ found = true
1208
+ if (typeof button.click === 'function') attemptValue(() => button.click())
1209
+ break
1210
+ }
1211
+ // A filter hides the rows it does not match, so a link to a task outside it has nothing
1212
+ // to open. The list is widened to "all" and the next retry finds the row.
1213
+ if (!found) clickScheduleFilter(SCHEDULE_FILTERS[0])
1214
+ if (typeof tab !== 'string' || tab === '' || tab === 'rule') return
1215
+ const tabs = attemptValue(() => [...(document?.querySelectorAll?.(`[${SCHEDULE_TAB_ATTRIBUTE}]`) ?? [])]) ?? []
1216
+ const chosen = tabs.find(candidate => attemptValue(() => candidate.getAttribute?.(SCHEDULE_TAB_ATTRIBUTE)) === tab)
1217
+ if (typeof chosen?.click === 'function') attemptValue(() => chosen.click())
1218
+ }
1219
+
1220
+ /**
1221
+ * `filter/all` names the state a bare automation list is already in, so a link carrying it
1222
+ * is read as the bare panel: the router never writes it, and dropping it here keeps the
1223
+ * link comparable (it lands instead of waiting for a page that will never say `all`).
1224
+ * @param {string} kind - the route kind.
1225
+ * @param {string} id - the route identity.
1226
+ * @param {Array<string>} parts - the inner path segments.
1227
+ * @returns {Array<string>} the segments to use.
1228
+ */
1229
+ function normalisePanelInner(kind, id, parts) {
1230
+ if (kind !== 'panel' || id !== SCHEDULES_PANEL_ID) return parts
1231
+ if (parts.length >= 2 && parts[0] === FILTER_SEGMENT && parts[1] === SCHEDULE_FILTERS[0]) return parts.slice(2)
1232
+ return parts
1233
+ }
1234
+
574
1235
  /**
575
1236
  * Read a route out of a location fragment.
576
1237
  * @param {string} hash - the raw `window.location.hash`, including the leading `#`.
@@ -650,7 +1311,7 @@ window.__ModuleLoader__.load({
650
1311
  }
651
1312
  // A panel may have an inner page (a plugin's detail view, for instance): the
652
1313
  // rest of the path names it, per panel.
653
- const inner = segments.map(decodePart).filter(segment => segment.trim() !== '')
1314
+ const inner = normalisePanelInner(kind, id, segments.map(decodePart).filter(segment => segment.trim() !== ''))
654
1315
  return { kind, id, inner: inner.length === 0 ? undefined : inner }
655
1316
  }
656
1317
 
@@ -711,6 +1372,27 @@ window.__ModuleLoader__.load({
711
1372
  return rowId === '' ? undefined : { packageName: undefined, rowId }
712
1373
  }
713
1374
 
1375
+ /**
1376
+ * The control that opens the page a plugin row describes.
1377
+ *
1378
+ * The manager puts the row's own link first and its enable switch beside it, and both
1379
+ * are buttons. Clicking the switch on behalf of a pasted link would silently turn a
1380
+ * plugin off (measured: a component page link did exactly that while the row had no
1381
+ * configuration), so the switch is never picked. A row whose only button is the switch
1382
+ * has no page to open, and its link is left to expire with the usual diagnostic.
1383
+ * @param {object} element - the row, card or list item.
1384
+ * @returns {object|undefined} the control to click.
1385
+ */
1386
+ function openControlOf(element) {
1387
+ const buttons = attemptValue(() => [...(element?.querySelectorAll?.('button') ?? [])]) ?? []
1388
+ for (const button of buttons) {
1389
+ const role = typeof button?.getAttribute === 'function' ? button.getAttribute('role') : null
1390
+ if (role === 'switch') continue
1391
+ if (typeof button?.click === 'function') return button
1392
+ }
1393
+ return undefined
1394
+ }
1395
+
714
1396
  function innerPath(value) {
715
1397
  const parts = Array.isArray(value) ? value : String(value ?? '').split('/')
716
1398
  return parts.map(part => String(part).trim()).filter(part => part !== '')
@@ -780,7 +1462,7 @@ window.__ModuleLoader__.load({
780
1462
  let panelScope
781
1463
  /** The turn the user last clicked inside a conversation, with its session. */
782
1464
  /** What the report can say about this run: fragments it refused, kinds it left alone. */
783
- const observed = { foreignFragment: undefined, ignored: [], warnings: [] }
1465
+ const observed = { foreignFragment: undefined, ignored: [], warnings: [], compat: [] }
784
1466
  let rowAnchor
785
1467
  /** The pane kind this claim already opened (one drive per claim, see the pane branch). */
786
1468
  let paneOpenedFor
@@ -792,6 +1474,8 @@ window.__ModuleLoader__.load({
792
1474
  let innerAttempts = 0
793
1475
  /** Whether the current link already asked for a modal standing in front of it to go away. */
794
1476
  let overlayDismissed = false
1477
+ /** When the compatibility checks last read the page (`-Infinity`: never swept). */
1478
+ let lastCompatSweep = -Infinity
795
1479
  /** The view state observed on the previous notification. */
796
1480
  let seen = 'none'
797
1481
  let cancelTimer
@@ -821,22 +1505,32 @@ window.__ModuleLoader__.load({
821
1505
  if (settingsOverlayOf(window.document) !== undefined) {
822
1506
  const section = host.activeSettingsSection() ?? ''
823
1507
  const tab = section === '' ? undefined : host.activeSettingsTab()
824
- return tab === undefined ? { kind: 'settings', id: section } : { kind: 'settings', id: section, inner: [tab] }
1508
+ // A model-provider card whose editor is open is a page of its own: its id rides
1509
+ // after the section (and after the tab, when the section has one).
1510
+ const provider = section === '' ? undefined : host.activeSettingsProvider()
1511
+ const inner = [...(tab === undefined ? [] : [tab]), ...(provider === undefined ? [] : [PROVIDER_SEGMENT, provider])]
1512
+ return inner.length === 0 ? { kind: 'settings', id: section } : { kind: 'settings', id: section, inner }
825
1513
  }
826
1514
  const panelId = host.activePanelId()
827
1515
  if (panelId !== undefined && panelId !== null) {
828
1516
  const id = String(panelId)
829
- // A right-hand pane in front is part of what is on screen, so it is named first:
830
- // it is true whether or not the panel itself reports an inner page.
831
- const paneInPanel = host.activePaneKind()
832
- if (paneInPanel !== undefined) return { kind: 'panel', id, inner: innerPath([PANE_SEGMENT, paneInPanel]) }
1517
+ // The panel's own inner page comes first: a panel that reports a page has a page on
1518
+ // screen that the reader can name, and the right-hand pane is a layer *beside* it.
1519
+ // Naming the pane first is what used to hide the automation task's own link behind
1520
+ // `pane/<kind>` (measured live).
833
1521
  const page = host.panelPage(id, panelScope)
834
- if (page === undefined) return { kind: 'panel', id, inner: undefined }
835
1522
  const segments = innerPath(page)
836
1523
  // Remember the package a page was opened under, so a bare component page (which
837
1524
  // reports only its row) can still be named fully.
838
1525
  if (id === PLUGINS_PANEL_ID && segments.length === 1) panelScope = segments[0]
839
- return { kind: 'panel', id, inner: segments }
1526
+ // A panel's own page names what the main column shows, and the right-hand pane is
1527
+ // secondary to it: the reader asked for the task (or the plugin row), not for the
1528
+ // pane standing beside it, so the pane gives way. It is named only on a panel that
1529
+ // has no page of its own. A collapsed bar keeps its tabs mounted, so it never
1530
+ // counts at all (measured: the pane segment outlived its own bar).
1531
+ const pane = segments.length > 0 || rightBarCollapsedOf(window.document) ? undefined : host.activePaneKind()
1532
+ const inner = pane === undefined ? segments : [PANE_SEGMENT, pane]
1533
+ return inner.length === 0 ? { kind: 'panel', id, inner: undefined } : { kind: 'panel', id, inner }
840
1534
  }
841
1535
  const session = host.mainViewSessionId()
842
1536
  if (session !== undefined && session !== null) {
@@ -865,7 +1559,8 @@ window.__ModuleLoader__.load({
865
1559
  // The right-hand pane is its own layer, not a position inside the conversation:
866
1560
  // it is named next to whatever the main column shows, never instead of it — so it
867
1561
  // is read *before* the workspace branch, or the workspace would swallow it.
868
- const pane = host.activePaneKind()
1562
+ // A collapsed bar keeps its tabs mounted, so it only counts while it is on screen.
1563
+ const pane = rightBarCollapsedOf(window.document) ? undefined : host.activePaneKind()
869
1564
  const tail = pane === undefined ? [] : [PANE_SEGMENT, pane]
870
1565
  const workspaceName = host.workspaceNameOf(id)
871
1566
  if (workspaceName !== undefined) {
@@ -911,6 +1606,27 @@ window.__ModuleLoader__.load({
911
1606
  // that is no longer in flight is never "the same route" as anything.
912
1607
  if (linked === undefined || linked === null) return false
913
1608
  if (routeKey(current) === routeKey(linked)) return true
1609
+ // A panel's page may be *more* specific than the link (`plugins/<pkg>/<row>` for a
1610
+ // `plugins/<pkg>` link), and the right-hand pane is a layer beside that page rather
1611
+ // than part of it — so it is compared only when the link names one. Exact comparison
1612
+ // is what left a link waiting on a page that was already open.
1613
+ if (current?.kind === 'panel' || linked?.kind === 'panel') {
1614
+ if (current?.kind !== linked.kind || current.id !== linked.id) return false
1615
+ const pageOf = route => {
1616
+ const parts = innerPath(route?.inner)
1617
+ const at = parts.indexOf(PANE_SEGMENT)
1618
+ return at < 0 ? parts : parts.slice(0, at)
1619
+ }
1620
+ // A link that names a pane asks for that pane, whatever else it names.
1621
+ const wantedPane = paneOf(linked)
1622
+ if (wantedPane !== undefined && paneOf(current) !== wantedPane) return false
1623
+ const wanted = pageOf(linked)
1624
+ if (wanted.length === 0) return true
1625
+ // A page link is satisfied once that page is on screen; a link may be wider than the
1626
+ // view (an open component satisfies a package link) but never narrower.
1627
+ const shown = pageOf(current)
1628
+ return wanted.length <= shown.length && wanted.every((part, index) => shown[index] === part)
1629
+ }
914
1630
  const currentSession = sessionIdOfRoute(current)
915
1631
  const linkedSession = sessionIdOfRoute(linked)
916
1632
  if (currentSession !== undefined && linkedSession !== undefined) {
@@ -954,8 +1670,12 @@ window.__ModuleLoader__.load({
954
1670
  if (current?.kind !== 'settings' || linked?.kind !== 'settings') return false
955
1671
  if (linked.id === '') return true
956
1672
  if (current.id !== linked.id) return false
957
- const linkedTab = innerPath(linked.inner)[0] ?? ''
958
- return linkedTab === '' || innerPath(current.inner)[0] === linkedTab
1673
+ const wanted = settingsPartsOf(linked.inner)
1674
+ const shown = settingsPartsOf(current.inner)
1675
+ if (wanted.tab !== undefined && wanted.tab !== shown.tab) return false
1676
+ // A provider card is a page one level deeper than its section: a link that names one
1677
+ // has to see that one open (the section alone is satisfied by any of its cards).
1678
+ return wanted.provider === undefined || wanted.provider === shown.provider
959
1679
  }
960
1680
  /** Write the fragment for the current view, leaving path and query untouched. */
961
1681
  const reflect = route => {
@@ -988,6 +1708,7 @@ window.__ModuleLoader__.load({
988
1708
  */
989
1709
  const reconcile = (source = 'boot') => {
990
1710
  if (disposed) return
1711
+ workCounts.reconciles += 1
991
1712
 
992
1713
  const current = canonical()
993
1714
  const currentKey = routeKey(current)
@@ -1000,6 +1721,22 @@ window.__ModuleLoader__.load({
1000
1721
  if (paneTabs > 0 && host.activePaneKind() === undefined) {
1001
1722
  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'}`)
1002
1723
  }
1724
+ // Say each compatibility problem once, before anything else in this step: a screen whose
1725
+ // hooks moved does not throw, it just stops being nameable.
1726
+ const sweepAt = host.now()
1727
+ if (sweepAt - lastCompatSweep >= COMPAT_EVERY_MS) {
1728
+ lastCompatSweep = sweepAt
1729
+ const problems = compatibilityProblems(window.document, {
1730
+ activePanelId: host.activePanelId(),
1731
+ sessions: attemptValue(() => host.listedSessions?.()) ?? 0,
1732
+ views: attemptValue(() => host.conversationViewIds?.())?.length ?? 0,
1733
+ })
1734
+ for (const problem of problems) {
1735
+ if (observed.compat.includes(problem.name)) continue
1736
+ observed.compat.push(problem.name)
1737
+ host.warn(`${TAG} ${COMPAT_MESSAGE}:${problem.name} —— 期望 ${problem.expected},实际 ${problem.found}。这一层的链接会静默失效,请升级 DSH 或本插件`)
1738
+ }
1739
+ }
1003
1740
  // A position the previous step asked for: it is driven here, one step later, so the step
1004
1741
  // that claimed it never drives while it is still running.
1005
1742
  if (linked === undefined) {
@@ -1016,8 +1753,18 @@ window.__ModuleLoader__.load({
1016
1753
  // The link has landed: it stops being authoritative right here, so the user
1017
1754
  // keeps owning the address bar afterwards (walking deeper inside a panel
1018
1755
  // updates the URL instead of being pulled back to the link).
1756
+ //
1757
+ // A turn link that was already satisfied (its seat is on screen) still names where
1758
+ // the reader is, so it becomes the anchor: writing the plain view instead is how a
1759
+ // pasted `turn/<n>` link used to lose its turn the instant it landed.
1760
+ const parts = innerPath(link.inner)
1761
+ const at = parts.indexOf(TURN_SEGMENT)
1762
+ if (at >= 0 && parts[at + 1] !== undefined && parts[at + 1] !== '') {
1763
+ const session = link.kind === 'workspace' ? parts[1] : link.id
1764
+ turnAnchor = { session, turn: parts[at + 1] }
1765
+ }
1019
1766
  dropLink()
1020
- reflect(current)
1767
+ reflect(canonical())
1021
1768
  return
1022
1769
  }
1023
1770
  if (host.now() >= link.deadline) {
@@ -1099,7 +1846,8 @@ window.__ModuleLoader__.load({
1099
1846
  if (link.kind === 'settings') {
1100
1847
  if (source !== 'hash' && innerAttempts < INNER_ATTEMPT_LIMIT) {
1101
1848
  innerAttempts += 1
1102
- host.openSettings(link.id, innerPath(link.inner)[0])
1849
+ const parts = settingsPartsOf(link.inner)
1850
+ host.openSettings(link.id, parts.tab, parts.provider)
1103
1851
  }
1104
1852
  armRetry()
1105
1853
  return
@@ -1190,7 +1938,16 @@ window.__ModuleLoader__.load({
1190
1938
  // with a bound so an unopenable page cannot turn into a click loop.
1191
1939
  if (link.inner !== undefined && routeKey(current) !== routeKey(link) && source !== 'hash' && innerAttempts < INNER_ATTEMPT_LIMIT) {
1192
1940
  innerAttempts += 1
1193
- host.openPanelPage(link.id, link.inner)
1941
+ // A pane tail names the right bar, not the panel's page: handing it to the
1942
+ // panel would ask that panel to open a page called `files` (measured on a
1943
+ // link the router itself had written while both were open).
1944
+ const parts = innerPath(link.inner)
1945
+ const paneAt = parts.indexOf(PANE_SEGMENT)
1946
+ const wantedPane = paneAt < 0 ? undefined : parts[paneAt + 1]
1947
+ host.openPanelPage(link.id, paneAt < 0 ? parts : parts.slice(0, paneAt))
1948
+ if (wantedPane !== undefined && host.activePaneKind() !== wantedPane) {
1949
+ host.openPane(wantedPane, 'panel', message => host.warn(message))
1950
+ }
1194
1951
  }
1195
1952
  armRetry()
1196
1953
  return
@@ -1393,6 +2150,14 @@ window.__ModuleLoader__.load({
1393
2150
  paneActive: attemptValue(() => host.activePaneKind()) ?? null,
1394
2151
  paneKinds: attemptValue(() => host.paneKinds?.()) ?? null,
1395
2152
  },
2153
+ // The address bar's own preferences, so a bug report can say what the bar
2154
+ // was told to do without opening the settings page.
2155
+ preferences: {
2156
+ selectAllOnFocus: readSettings().selectAllOnFocus,
2157
+ recentLimit: readSettings().recentLimit,
2158
+ remembered: recentEntries().length,
2159
+ },
2160
+ work: { reconciles: workCounts.reconciles, placements: workCounts.placements },
1396
2161
  compat: observed.compat,
1397
2162
  foreignFragment: observed.foreignFragment,
1398
2163
  ignoredKinds: observed.ignored,
@@ -1902,6 +2667,13 @@ window.__ModuleLoader__.load({
1902
2667
  }
1903
2668
  const host = {
1904
2669
  mainViewSessionId: () => attempt(() => mainViewSessionId(faces.catalog.getSnapshot().byId), undefined),
2670
+ // How many conversations the catalog lists: the compatibility check uses it to tell
2671
+ // "an empty sidebar" from "a sidebar whose rows are no longer where they were".
2672
+ listedSessions: () => attemptValue(() => {
2673
+ const snapshot = faces.catalog.getSnapshot()
2674
+ if (Array.isArray(snapshot?.ids)) return snapshot.ids.length
2675
+ return Object.keys(snapshot?.byId ?? {}).length
2676
+ }, 0),
1905
2677
  // The sidebar lists workspaces and their sessions in one tree, in order, so the
1906
2678
  // workspace a conversation belongs to is the nearest workspace row before it.
1907
2679
  workspaceNameOf: sessionId => {
@@ -1926,6 +2698,19 @@ window.__ModuleLoader__.load({
1926
2698
  // The inner page a panel is showing, in that panel's own vocabulary. Each panel
1927
2699
  // publishes it with a data attribute; reading it is the whole reflect direction.
1928
2700
  panelPage: (panelId, scope) => {
2701
+ if (panelId === SCHEDULES_PANEL_ID) {
2702
+ // Two dimensions, in one path: the status filter the list shows (the default
2703
+ // `all` is not written) and the task whose detail is open (with its tab).
2704
+ const segments = []
2705
+ const filter = scheduleFilterOf(window.document)
2706
+ if (filter !== undefined && filter !== SCHEDULE_FILTERS[0]) segments.push(FILTER_SEGMENT, filter)
2707
+ const task = scheduleTaskOf(window.document)
2708
+ if (task !== undefined) {
2709
+ segments.push(TASK_SEGMENT, task.id)
2710
+ if (task.tab !== 'rule') segments.push(task.tab)
2711
+ }
2712
+ return segments.length === 0 ? undefined : segments
2713
+ }
1929
2714
  if (panelId === TASK_BOARD_PANEL_ID) {
1930
2715
  // The form and a task are mutually exclusive, and the form wins when both
1931
2716
  // would be reported (it is the page the user is looking at).
@@ -1949,6 +2734,15 @@ window.__ModuleLoader__.load({
1949
2734
  },
1950
2735
  openPanelPage: (panelId, inner) => {
1951
2736
  const asked = innerPath(inner)
2737
+ if (panelId === SCHEDULES_PANEL_ID) {
2738
+ // Oldest first: the filter decides which rows the list holds, so it is pressed
2739
+ // before a task in that list is asked for.
2740
+ const filterAt = asked.indexOf(FILTER_SEGMENT)
2741
+ if (filterAt >= 0) clickScheduleFilter(asked[filterAt + 1])
2742
+ const taskAt = asked.indexOf(TASK_SEGMENT)
2743
+ if (taskAt >= 0 && asked[taskAt + 1] !== undefined) clickScheduleTask(asked[taskAt + 1], asked[taskAt + 2])
2744
+ return
2745
+ }
1952
2746
  if (panelId === TASK_BOARD_PANEL_ID) {
1953
2747
  // The task board owns how its overlays open: we hand over the request on its
1954
2748
  // own contract (window events) and nothing else crosses the seam.
@@ -1974,8 +2768,9 @@ window.__ModuleLoader__.load({
1974
2768
  const pkg =
1975
2769
  window.document?.querySelector?.(`li[data-plugin-package="${segments[0]}"]`) ??
1976
2770
  window.document?.querySelector?.(`li[data-plugin-item="${segments[0]}"]`)
1977
- const button = pkg?.querySelector?.('button')
1978
- if (typeof button?.click === 'function') button.click()
2771
+ // Never the enable switch: a link opens a page, it never changes a plugin.
2772
+ const button = openControlOf(pkg)
2773
+ if (button !== undefined) button.click()
1979
2774
  if (segments.length === 1) return
1980
2775
  }
1981
2776
  const rowId = segments[segments.length - 1]
@@ -1983,8 +2778,8 @@ window.__ModuleLoader__.load({
1983
2778
  for (const row of rows) {
1984
2779
  const parsed = splitRowHook(typeof row.getAttribute === 'function' ? row.getAttribute('data-plugin-row') : null)
1985
2780
  if (parsed === undefined || parsed.rowId !== rowId) continue
1986
- const button = row.querySelector?.('button')
1987
- if (typeof button?.click === 'function') button.click()
2781
+ const button = openControlOf(row)
2782
+ if (button !== undefined) button.click()
1988
2783
  return
1989
2784
  }
1990
2785
  },
@@ -2056,6 +2851,7 @@ window.__ModuleLoader__.load({
2056
2851
  revealPosition: (parts, wantedSession) => {
2057
2852
  const wanted = innerPath(parts)
2058
2853
  const document = window.document
2854
+
2059
2855
  if (wanted[0] === ROW_SEGMENT && wanted.length >= 3) {
2060
2856
  const key = wanted.slice(1).join('%00')
2061
2857
  for (const candidate of document?.querySelectorAll?.(`[${TRAJECTORY_ROW_ATTRIBUTE}]`) ?? []) {
@@ -2088,16 +2884,46 @@ window.__ModuleLoader__.load({
2088
2884
  // from the catalog here is how this step used to look up "no session".
2089
2885
  const reference = attemptValue(() => faces.sessions?.retainScope?.(wantedSession, 'url-router'))
2090
2886
  const session = attemptValue(() => reference?.binding?.session)
2091
- const loadThrough = attemptValue(() => (session === undefined || session === null ? undefined : session.loadThrough))
2092
2887
  const outline = attemptValue(() => session?.projections?.faceOf?.('turnOutline')?.getSnapshot?.())
2093
2888
  const seq = Array.isArray(outline) ? outline.find(entry => entry && String(entry.turn) === String(turn))?.seq : undefined
2094
- const usable = typeof loadThrough === 'function' && typeof reference?.release === 'function'
2889
+ // The loader is called **on the session**, never through a detached reference:
2890
+ // `loadThrough` is a prototype method in this build and reads `this.openState`, so
2891
+ // destructuring it (`const load = session.loadThrough`) throws on every call.
2892
+ const usable = typeof session?.loadThrough === 'function' && typeof reference?.release === 'function'
2095
2893
  if (typeof seq !== 'number' || !usable) {
2096
2894
  attemptValue(() => reference?.release?.())
2097
2895
  return false
2098
2896
  }
2099
- attemptValue(() => loadThrough(seq))
2897
+ // The loader resolves when the pages are in, but the chat renders them on its own
2898
+ // commit — so the seat shows up a frame or two later, and until something scrolls
2899
+ // to it the virtual list simply trims the freshly loaded turns again. The official
2900
+ // chat does the same two steps (`loadThrough(seq)` then `scrollToTurn(turn)`); this
2901
+ // is the scroll half, without a handle on its navigation object.
2902
+ const scrollToSeat = () => {
2903
+ const seat = attemptValue(() => document?.querySelector?.(`[${TURN_ATTRIBUTE}="${turn}"]`))
2904
+ if (seat === null || seat === undefined) return false
2905
+ attempt(() => seat.scrollIntoView?.({ block: 'center' }))
2906
+ return true
2907
+ }
2908
+ const pending = attemptValue(() => session.loadThrough(seq))
2100
2909
  attemptValue(() => reference.release())
2910
+ if (pending !== undefined && pending !== null && typeof pending.then === 'function') {
2911
+ pending.then(
2912
+ () => {
2913
+ // A few frames of patience: the seat is rendered by the next commit.
2914
+ let tries = 0
2915
+ const tick = () => {
2916
+ tries += 1
2917
+ if (tries > 25 || scrollToSeat()) return
2918
+ attemptValue(() => window.setTimeout?.(tick, 50))
2919
+ }
2920
+ tick()
2921
+ },
2922
+ () => {},
2923
+ )
2924
+ } else {
2925
+ scrollToSeat()
2926
+ }
2101
2927
  return true
2102
2928
  },
2103
2929
  // Which conversation view is showing. The registry is the source of truth for ids
@@ -2158,10 +2984,13 @@ window.__ModuleLoader__.load({
2158
2984
  const snapshot = attemptValue(() => (typeof owner.getSnapshot === 'function' ? owner.getSnapshot() : undefined))
2159
2985
  return snapshot === undefined ? undefined : kindOf(snapshot.activeTabId ?? snapshot.activeTab ?? snapshot.active)
2160
2986
  }
2161
- const fromStrip = activePaneTabOf(window.document, paneRegistryOf(faces))
2162
- if (fromStrip !== undefined) return fromStrip
2987
+ // The controller first: it holds the live tab descriptor (`{ id, kind, title }`), so
2988
+ // its kind survives a title that is no longer the kind — a Files tab showing a file
2989
+ // is titled with the file name (`client.js`), and the DOM only carries that title.
2163
2990
  const fromController = read(faces.sidebarRight)
2164
2991
  if (fromController !== undefined) return fromController
2992
+ const fromStrip = activePaneTabOf(window.document, paneRegistryOf(faces))
2993
+ if (fromStrip !== undefined) return fromStrip
2165
2994
  const fromRegistry = read(faces.sidebarRightTabs)
2166
2995
  if (fromRegistry !== undefined) return fromRegistry
2167
2996
  // DOM fallback, deliberately narrow: the active-pane marker is shared with the
@@ -2189,6 +3018,8 @@ window.__ModuleLoader__.load({
2189
3018
  }
2190
3019
  return undefined
2191
3020
  },
3021
+ // The model-provider card whose editor is open, when one is (see the helper).
3022
+ activeSettingsProvider: () => openSettingsProviderId(window.document),
2192
3023
  // The tab a settings section is showing: the same ARIA contract one level deeper.
2193
3024
  activeSettingsTab: () => {
2194
3025
  const overlay = settingsOverlayOf(window.document)
@@ -2214,7 +3045,7 @@ window.__ModuleLoader__.load({
2214
3045
  window.document?.dispatchEvent?.(event)
2215
3046
  })
2216
3047
  },
2217
- openSettings: (section, tab) => {
3048
+ openSettings: (section, tab, provider) => {
2218
3049
  if (typeof section === 'string' && section !== '') {
2219
3050
  // A section is named by the label the user reads, so the request is a label match.
2220
3051
  const overlay = settingsOverlayOf(window.document)
@@ -2227,7 +3058,11 @@ window.__ModuleLoader__.load({
2227
3058
  const wanted = section.trim().toLowerCase()
2228
3059
  const match = candidates.find(candidate => (candidate.textContent ?? '').trim().toLowerCase() === wanted)
2229
3060
  if (match !== undefined && typeof match.click === 'function') {
2230
- attempt(() => match.click())
3061
+ // The section that is already showing is not pressed again: a section
3062
+ // re-render can close a card editor the link asked for (its drive is
3063
+ // retried every half second, so pressing twice would flap).
3064
+ const current = candidates.find(candidate => attemptValue(() => candidate.getAttribute?.('aria-current')) === 'true')
3065
+ if (current !== match) attempt(() => match.click())
2231
3066
  // The section may own tabs: a named one is clicked once the section is up.
2232
3067
  if (typeof tab === 'string' && tab !== '') {
2233
3068
  const wantedTab = tab.trim().toLowerCase()
@@ -2235,6 +3070,8 @@ window.__ModuleLoader__.load({
2235
3070
  const matchTab = tabs.find(candidate => (candidate.textContent ?? '').trim().toLowerCase() === wantedTab)
2236
3071
  if (matchTab !== undefined && typeof matchTab.click === 'function') attempt(() => matchTab.click())
2237
3072
  }
3073
+ // And one of its cards may be the page a link named.
3074
+ if (typeof provider === 'string' && provider !== '') clickSettingsProvider(provider)
2238
3075
  return
2239
3076
  }
2240
3077
  }
@@ -2355,7 +3192,22 @@ window.__ModuleLoader__.load({
2355
3192
  attempt(() => {
2356
3193
  const root = window.document?.body ?? window.document?.documentElement
2357
3194
  if (root === undefined || root === null || typeof window.MutationObserver !== 'function') return () => {}
2358
- const observer = new window.MutationObserver(handler)
3195
+ // One reconcile per frame. A streaming answer mutates the page dozens of times a
3196
+ // second, and every batch re-reads the whole view state (the sidebar's rows
3197
+ // included). A host without frames (tests, a minimal DOM) runs the handler
3198
+ // straight away, which is what the fake DOM relies on.
3199
+ let queued = false
3200
+ const flush = () => {
3201
+ queued = false
3202
+ handler()
3203
+ }
3204
+ const schedule = () => {
3205
+ if (queued) return
3206
+ queued = true
3207
+ const frame = attemptValue(() => window.requestAnimationFrame?.(flush))
3208
+ if (frame === undefined) flush()
3209
+ }
3210
+ const observer = new window.MutationObserver(schedule)
2359
3211
  // Every published page hook must be watched: a filter that names only some of
2360
3212
  // them silently starves the retry loop of notifications (real bug: the task
2361
3213
  // board's drive looked flaky because its attributes were not observed).
@@ -2374,6 +3226,10 @@ window.__ModuleLoader__.load({
2374
3226
  'aria-current',
2375
3227
  // Switching a settings *tab* moves this one, for the same reason.
2376
3228
  'aria-selected',
3229
+ // The automation list's status filter is three buttons whose only state is
3230
+ // this attribute, and pressing one touches nothing else: without it here the
3231
+ // address bar kept the filter it saw first.
3232
+ 'aria-pressed',
2377
3233
  SETTINGS_SURFACE_ATTRIBUTE,
2378
3234
  ],
2379
3235
  })
@@ -2541,10 +3397,11 @@ window.__ModuleLoader__.load({
2541
3397
  * @returns {object|undefined} the trigger element, or undefined when absent.
2542
3398
  */
2543
3399
  function powerTrigger() {
2544
- if (typeof document?.querySelector !== 'function') return undefined
2545
- return document.querySelector('[data-dshpm-mount] .dshpm-trigger')
2546
- ?? document.querySelector('.dshpm-trigger')
2547
- ?? document.querySelector('[aria-label="电源菜单"]')
3400
+ const page = attemptValue(() => window.document)
3401
+ if (typeof page?.querySelector !== 'function') return undefined
3402
+ return page.querySelector('[data-dshpm-mount] .dshpm-trigger')
3403
+ ?? page.querySelector('.dshpm-trigger')
3404
+ ?? page.querySelector('[aria-label="电源菜单"]')
2548
3405
  ?? undefined
2549
3406
  }
2550
3407
 
@@ -2594,7 +3451,7 @@ window.__ModuleLoader__.load({
2594
3451
  try {
2595
3452
  style = document.createElement('style')
2596
3453
  style.id = TITLEBAR_STYLE_ID
2597
- style.textContent = macInHeader ? `${TITLEBAR_STYLE_TEXT}\n${MAC_STYLE_TEXT}` : TITLEBAR_STYLE_TEXT
3454
+ style.textContent = `${macInHeader ? `${TITLEBAR_STYLE_TEXT}\n${MAC_STYLE_TEXT}` : TITLEBAR_STYLE_TEXT}\n${RECENT_STYLE_TEXT}`
2598
3455
  const parent = document.head ?? document.body
2599
3456
  if (parent !== undefined && parent !== null && typeof parent.appendChild === 'function') parent.appendChild(style)
2600
3457
  } catch {
@@ -2612,6 +3469,16 @@ window.__ModuleLoader__.load({
2612
3469
  input.placeholder = TITLEBAR_PLACEHOLDER
2613
3470
  if (typeof input.setAttribute === 'function') input.setAttribute('aria-label', '地址栏 / address bar')
2614
3471
  bar.appendChild(input)
3472
+ // The recent list: the drop-down a browser shows when its address bar is focused.
3473
+ // It is a child of the bar so it moves with it and can never end up somewhere
3474
+ // else on a screen where the bar is repositioned.
3475
+ const recentList = document.createElement('div')
3476
+ if (typeof recentList.setAttribute === 'function') {
3477
+ recentList.setAttribute(RECENT_LIST_ATTRIBUTE, '')
3478
+ recentList.setAttribute('role', 'listbox')
3479
+ recentList.setAttribute('aria-label', '最近使用的链接 / recent links')
3480
+ }
3481
+ bar.appendChild(recentList)
2615
3482
  /**
2616
3483
  * The free stretch the bar was last fitted into.
2617
3484
  *
@@ -2672,6 +3539,8 @@ window.__ModuleLoader__.load({
2672
3539
  * macOS: sit in the free stretch of the header row, on the power button's
2673
3540
  * own line.
2674
3541
  */
3542
+ /** The shape the last placement was measured from, so an unchanged header is not re-walked. */
3543
+ let lastShape
2675
3544
  const place = () => {
2676
3545
  if (macInHeader) {
2677
3546
  // The bar lives on the header row, on the same line as the power
@@ -2684,6 +3553,20 @@ window.__ModuleLoader__.load({
2684
3553
  const row = macHeaderRow(trigger)
2685
3554
  const rowRect = row?.getBoundingClientRect?.()
2686
3555
  const anchored = rect !== undefined && rect !== null && rect.width > 0
3556
+ // The observer fires for every mutation on the page, and a streaming answer produces
3557
+ // them constantly: walking the row again when nothing that shaped the last placement
3558
+ // moved is pure waste (the walk reads a box per leaf, i.e. forces layout). A screen
3559
+ // with no row to measure is not cached this way — its band is the page's own content,
3560
+ // which changes without moving the row.
3561
+ const shape = [
3562
+ rowRect?.top, rowRect?.left, rowRect?.width, rowRect?.height,
3563
+ rect?.top, rect?.left, rect?.width,
3564
+ Math.round(globalThis.innerWidth ?? 0),
3565
+ (row?.textContent ?? '').length,
3566
+ ].join('|')
3567
+ if (anchored && shape === lastShape && bar.style.display !== 'none') return
3568
+ if (anchored) lastShape = shape
3569
+ workCounts.placements += 1
2687
3570
  // The row is only walked when there is a button to measure from.
2688
3571
  const gap = anchored ? macFreeGap(macLeaves(row, 0), rect, rowRect) : null
2689
3572
  if (gap === null) {
@@ -2703,11 +3586,30 @@ window.__ModuleLoader__.load({
2703
3586
  bar.style.display = 'none'
2704
3587
  return
2705
3588
  }
2706
- const box = fitBar(lastPlacement.gap, lastPlacement.top)
2707
- const left = Math.max(MAC_BAR_GAP, Math.min(box.left, viewport - box.width - MAC_BAR_GAP))
3589
+ const remembered = fitBar(lastPlacement.gap, lastPlacement.top)
3590
+ // Yield to whatever this screen draws on that line: the bar moves into a free run
3591
+ // of its own instead of being drawn across a control (measured: it sat straight
3592
+ // across the automation task's detail tabs).
3593
+ const runs = []
3594
+ let cursor = 0
3595
+ for (const span of bandSpans(document, bar, remembered.top, MAC_BAR_HEIGHT)) {
3596
+ if (span.left > cursor) runs.push({ left: cursor, right: span.left })
3597
+ cursor = Math.max(cursor, span.right)
3598
+ }
3599
+ if (viewport > cursor) runs.push({ left: cursor, right: viewport })
3600
+ let best = null
3601
+ for (const run of runs) {
3602
+ if (run.right - run.left < MAC_BAR_MIN_WIDTH) continue
3603
+ if (best === null || run.right - run.left > best.right - best.left) best = run
3604
+ }
3605
+ if (best === null) {
3606
+ bar.style.display = 'none'
3607
+ return
3608
+ }
3609
+ const box = fitBar(best, remembered.top)
2708
3610
  bar.style.display = ''
2709
3611
  bar.style.top = `${box.top}px`
2710
- bar.style.left = `${left}px`
3612
+ bar.style.left = `${box.left}px`
2711
3613
  bar.style.right = 'auto'
2712
3614
  bar.style.width = `${box.width}px`
2713
3615
  return
@@ -2744,7 +3646,7 @@ window.__ModuleLoader__.load({
2744
3646
  let placeFrame
2745
3647
  const schedulePlace = () => {
2746
3648
  if (placeFrame !== undefined) return
2747
- placeFrame = attemptValue(() => globalThis.requestAnimationFrame?.(() => {
3649
+ placeFrame = attemptValue(() => window.requestAnimationFrame?.(() => {
2748
3650
  placeFrame = undefined
2749
3651
  place()
2750
3652
  }))
@@ -2810,14 +3712,291 @@ window.__ModuleLoader__.load({
2810
3712
  input.value = why
2811
3713
  }, TITLEBAR_SETTLE_MS))
2812
3714
  }
3715
+ /**
3716
+ * The chip a remembered link is shown with: what kind of screen it names.
3717
+ * @param {object|undefined} route - the decoded route.
3718
+ * @returns {string} the chip's text.
3719
+ */
3720
+ const recentKind = route => {
3721
+ if (route === undefined || route.unknown !== undefined) return '链接'
3722
+ if (route.kind === 'session') return '会话'
3723
+ if (route.kind === 'workspace') return '工作区'
3724
+ if (route.kind === 'panel') return '面板'
3725
+ if (route.kind === 'settings') return '设置'
3726
+ return '链接'
3727
+ }
3728
+ /** Hide the remembered links. */
3729
+ const hideRecent = () => {
3730
+ if (typeof recentList.removeAttribute === 'function') recentList.removeAttribute('data-open')
3731
+ }
3732
+ /**
3733
+ * Whether the reader has edited the text since the bar was focused.
3734
+ *
3735
+ * The list is narrowed only by text the reader typed. The field always holds the
3736
+ * current link when it is merely focused, and filtering by that would hide every
3737
+ * remembered screen but the one on display.
3738
+ */
3739
+ let narrowed = false
3740
+ /** Empty the list before it is drawn again. */
3741
+ const emptyRecent = () => {
3742
+ while (recentList.firstChild !== null && recentList.firstChild !== undefined) {
3743
+ if (typeof recentList.removeChild !== 'function') break
3744
+ recentList.removeChild(recentList.firstChild)
3745
+ }
3746
+ }
3747
+ /**
3748
+ * The absolute URL a remembered link stands for.
3749
+ *
3750
+ * A copied link has to survive the trip out of this window, so it carries the path
3751
+ * and the launch query (`?token=…`) exactly the way a row anchor's own "copy link
3752
+ * address" does — the bare fragment only works inside an already-open page.
3753
+ * @param {string} hash - the fragment.
3754
+ * @returns {string} the URL to copy.
3755
+ */
3756
+ const absoluteLink = hash => {
3757
+ const location = window.location ?? {}
3758
+ return `${location.origin ?? ''}${location.pathname ?? ''}${location.search ?? ''}${hash}`
3759
+ }
3760
+ /** Set while the copy's throw-away field borrows focus, so the bar ignores the swap. */
3761
+ let quietFocus = false
3762
+ /**
3763
+ * Run something without the bar reading it as the reader leaving or entering the
3764
+ * field: the copy must neither close the list nor re-select the link.
3765
+ * @param {Function} run - what to run.
3766
+ * @returns {*} whatever it returned.
3767
+ */
3768
+ const quietly = run => {
3769
+ quietFocus = true
3770
+ try {
3771
+ return run()
3772
+ } finally {
3773
+ quietFocus = false
3774
+ }
3775
+ }
3776
+ /**
3777
+ * Put text on the clipboard the old way: select it in a throw-away field and let the
3778
+ * command copy it. Used when the window has no clipboard service, or refuses the
3779
+ * async call — and the reader is put back in the field afterwards, because the field
3780
+ * was only there to be copied from.
3781
+ * @param {string} text - what to copy.
3782
+ * @returns {boolean} whether the copy was accepted.
3783
+ */
3784
+ const copyBySelection = text => quietly(() => attemptValue(() => {
3785
+ const wasFocused = attemptValue(() => document.activeElement)
3786
+ const area = document.createElement('textarea')
3787
+ area.value = text
3788
+ area.setAttribute?.('readonly', '')
3789
+ if (area.style !== undefined) {
3790
+ area.style.position = 'fixed'
3791
+ area.style.top = '-1000px'
3792
+ }
3793
+ document.body?.appendChild?.(area)
3794
+ area.focus?.()
3795
+ area.select?.()
3796
+ const done = typeof document.execCommand === 'function' && document.execCommand('copy') === true
3797
+ area.remove?.()
3798
+ if (wasFocused === input) attemptValue(() => input.focus?.())
3799
+ return done
3800
+ }, false) ?? false)
3801
+ /**
3802
+ * Copy a remembered link.
3803
+ * @param {string} hash - the fragment to copy.
3804
+ * @returns {Promise<boolean>|boolean} whether the copy was accepted.
3805
+ */
3806
+ const copyLink = hash => {
3807
+ const text = absoluteLink(hash)
3808
+ const clipboard = attemptValue(() => globalThis.navigator?.clipboard)
3809
+ if (clipboard !== undefined && clipboard !== null && typeof clipboard.writeText === 'function') {
3810
+ let pending
3811
+ try {
3812
+ pending = Promise.resolve(clipboard.writeText(text))
3813
+ } catch {
3814
+ return copyBySelection(text)
3815
+ }
3816
+ // A window that refuses the async call still has the old path. (The fallback
3817
+ // is a function call inside the handler on purpose: evaluating it up front
3818
+ // would run — and focus a throw-away field — on every single copy.)
3819
+ return pending.then(() => true, () => copyBySelection(text))
3820
+ }
3821
+ return copyBySelection(text)
3822
+ }
3823
+ /** Timers that put a "copied" row back, so disposal can clear them. */
3824
+ const copiedTimers = new Set()
3825
+ /** The bar's own confirmation chip: it stays visible even if the list had to close. */
3826
+ const toast = document.createElement('span')
3827
+ toast.className = 'dshur-toast'
3828
+ if (typeof toast.setAttribute === 'function') {
3829
+ toast.setAttribute(TOAST_ATTRIBUTE, '')
3830
+ toast.setAttribute('aria-live', 'polite')
3831
+ }
3832
+ bar.appendChild(toast)
3833
+ let toastTimer
3834
+ /**
3835
+ * Show one short confirmation in the bar.
3836
+ * @param {string} text - what to say.
3837
+ */
3838
+ const flashToast = text => {
3839
+ toast.textContent = text
3840
+ toast.setAttribute?.('data-show', '')
3841
+ if (toastTimer !== undefined) attemptValue(() => window.clearTimeout(toastTimer))
3842
+ toastTimer = attemptValue(() => window.setTimeout(() => {
3843
+ toastTimer = undefined
3844
+ toast.removeAttribute?.('data-show')
3845
+ toast.textContent = ''
3846
+ }, 1200))
3847
+ }
3848
+ /**
3849
+ * Say so on the row that was copied, then put the link back.
3850
+ * @param {object} item - the remembered-link row.
3851
+ * @param {object} link - that row's link text.
3852
+ */
3853
+ const flashCopied = (item, link) => {
3854
+ const text = readableFragment(attemptValue(() => item.getAttribute(RECENT_ITEM_ATTRIBUTE)) ?? '')
3855
+ link.textContent = '已复制 ✓'
3856
+ item.setAttribute?.('data-copied', '')
3857
+ const timer = attemptValue(() => window.setTimeout(() => {
3858
+ copiedTimers.delete(timer)
3859
+ // A redraw may have replaced the row already; only a live one is restored.
3860
+ link.textContent = text
3861
+ item.removeAttribute?.('data-copied')
3862
+ }, 1200))
3863
+ if (timer !== undefined) copiedTimers.add(timer)
3864
+ }
3865
+ /**
3866
+ * Draw the remembered links: the screens this bar named and the links pasted into
3867
+ * it, newest first, narrowed by whatever is typed. Nothing to show means no list.
3868
+ */
3869
+ const drawRecent = () => {
3870
+ const typed = narrowed ? String(input.value ?? '').trim().toLowerCase() : ''
3871
+ // One scan of the sidebar for the whole list: asking per entry re-walked every row
3872
+ // once per item (and twice per item, since the filter asks too).
3873
+ const titles = sessionTitles()
3874
+ // `recentEntries()` already leaves out the screen on display, so a remembered
3875
+ // link is only narrowed here by what the reader typed.
3876
+ const shown = recentEntries().filter(entry => {
3877
+ if (typed === '') return true
3878
+ return `${entry.hash}\n${recentLabel(entry.hash, titles)}`.toLowerCase().includes(typed)
3879
+ })
3880
+ emptyRecent()
3881
+ for (const entry of shown) {
3882
+ const label = recentLabel(entry.hash, titles)
3883
+ const item = document.createElement('button')
3884
+ item.type = 'button'
3885
+ if (typeof item.setAttribute === 'function') {
3886
+ item.setAttribute(RECENT_ITEM_ATTRIBUTE, entry.hash)
3887
+ item.setAttribute('role', 'option')
3888
+ item.setAttribute('title', `${entry.source === 'paste' ? '粘贴的链接' : '用过的界面'}:${readableFragment(entry.hash)}(右键复制链接)`)
3889
+ }
3890
+ const kind = document.createElement('span')
3891
+ kind.className = 'dshur-kind'
3892
+ kind.textContent = recentKind(parseRoute(entry.hash))
3893
+ item.appendChild(kind)
3894
+ if (label !== '') {
3895
+ const title = document.createElement('span')
3896
+ title.className = 'dshur-title'
3897
+ title.textContent = label
3898
+ item.appendChild(title)
3899
+ }
3900
+ const link = document.createElement('span')
3901
+ link.className = 'dshur-link'
3902
+ link.textContent = readableFragment(entry.hash)
3903
+ item.appendChild(link)
3904
+ item.addEventListener('mousedown', event => {
3905
+ // The click belongs to this list: the input has to keep focus, or the
3906
+ // press would blur it first and the list would be gone before the click.
3907
+ attemptValue(() => event?.preventDefault?.())
3908
+ })
3909
+ item.addEventListener('click', () => {
3910
+ narrowed = false
3911
+ hideRecent()
3912
+ mark('pending', undefined)
3913
+ // The box says where it is going at once; the settle below corrects it to
3914
+ // what actually landed (or says why nothing did).
3915
+ input.value = readableFragment(entry.hash)
3916
+ driveTypedFragment(entry.hash)
3917
+ settle(entry.hash, false)
3918
+ })
3919
+ item.addEventListener('contextmenu', event => {
3920
+ // Right-click copies instead of opening a menu: this list is the one place
3921
+ // where "give me this link" is the action a reader wants, and the browser's
3922
+ // own menu would only offer the same thing one level deeper.
3923
+ attemptValue(() => event?.preventDefault?.())
3924
+ const confirm = accepted => {
3925
+ if (accepted !== true) return
3926
+ flashCopied(item, link)
3927
+ flashToast('已复制链接')
3928
+ }
3929
+ const copied = copyLink(entry.hash)
3930
+ if (typeof copied?.then === 'function') copied.then(confirm)
3931
+ else confirm(copied)
3932
+ })
3933
+ recentList.appendChild(item)
3934
+ }
3935
+ if (shown.length === 0) {
3936
+ hideRecent()
3937
+ return
3938
+ }
3939
+ if (typeof recentList.setAttribute === 'function') recentList.setAttribute('data-open', '')
3940
+ }
3941
+ /**
3942
+ * Select everything in the bar, the way a browser's address bar does on its first
3943
+ * click. Off means the reader keeps the caret an ordinary text field gives them.
3944
+ * @returns {boolean} whether the text was selected.
3945
+ */
3946
+ const selectAll = () => {
3947
+ if (!readSettings().selectAllOnFocus) return false
3948
+ return attemptValue(() => {
3949
+ if (typeof input.select !== 'function') return false
3950
+ input.select()
3951
+ return true
3952
+ }, false)
3953
+ }
3954
+ const onFocus = () => {
3955
+ // The copy's own field came and went: this is not the reader focusing the bar.
3956
+ if (quietFocus) return
3957
+ narrowed = false
3958
+ selectAll()
3959
+ drawRecent()
3960
+ }
3961
+ const onMouseDown = event => {
3962
+ // Already focused: this press is placing a caret inside the link.
3963
+ if (document.activeElement === input) return
3964
+ if (!readSettings().selectAllOnFocus) return
3965
+ narrowed = false
3966
+ // The default of this very press would drop the selection made below.
3967
+ attemptValue(() => event?.preventDefault?.())
3968
+ attemptValue(() => input.focus?.())
3969
+ selectAll()
3970
+ drawRecent()
3971
+ }
3972
+ const onPaste = event => {
3973
+ const pasted = attemptValue(() => event?.clipboardData?.getData?.('text'))
3974
+ const fragment = attemptValue(() => titlebarFragmentOf(pasted))
3975
+ // Remembered as soon as it is a link this bar can open: a link that is already
3976
+ // on screen writes no route later, and would otherwise be forgotten entirely.
3977
+ if (typeof fragment === 'string' && fragment !== '') rememberRecent(fragment, 'paste')
3978
+ }
3979
+ const onInput = () => {
3980
+ narrowed = true
3981
+ drawRecent()
3982
+ }
3983
+ const onBlur = () => {
3984
+ if (quietFocus) return
3985
+ narrowed = false
3986
+ hideRecent()
3987
+ }
2813
3988
  const onKeyDown = event => {
2814
3989
  if (event?.key === 'Escape') {
2815
3990
  stopSettle()
3991
+ narrowed = false
3992
+ hideRecent()
2816
3993
  mark('idle', undefined)
2817
3994
  input.value = readableFragment(window.location?.hash)
2818
3995
  return
2819
3996
  }
2820
3997
  if (event?.key !== 'Enter') return
3998
+ narrowed = false
3999
+ hideRecent()
2821
4000
  const fragment = titlebarFragmentOf(input.value)
2822
4001
  if (fragment === undefined) {
2823
4002
  // Nothing here names a route: leave the address bar saying what is on screen.
@@ -2834,6 +4013,11 @@ window.__ModuleLoader__.load({
2834
4013
  settle(fragment, false)
2835
4014
  }
2836
4015
  input.addEventListener('keydown', onKeyDown)
4016
+ input.addEventListener('focus', onFocus)
4017
+ input.addEventListener('mousedown', onMouseDown)
4018
+ input.addEventListener('input', onInput)
4019
+ input.addEventListener('paste', onPaste)
4020
+ input.addEventListener('blur', onBlur)
2837
4021
  // Both platforms mount the bar in the body: Windows pins it into the
2838
4022
  // title-bar strip, macOS pins it over the resident header row.
2839
4023
  document.body.appendChild(bar)
@@ -2847,9 +4031,12 @@ window.__ModuleLoader__.load({
2847
4031
  * node, so what was typed into it survives.
2848
4032
  */
2849
4033
  let headerWatcher
2850
- if (macInHeader && typeof MutationObserver === 'function') {
4034
+ // The window owns both, so a page that replaces `document` (or a host with its own
4035
+ // observers) still gets the watcher; a global-only read silently found nothing.
4036
+ const Observer = attemptValue(() => window.MutationObserver)
4037
+ if (macInHeader && typeof Observer === 'function') {
2851
4038
  try {
2852
- headerWatcher = new MutationObserver(() => schedulePlace())
4039
+ headerWatcher = new Observer(() => schedulePlace())
2853
4040
  headerWatcher.observe(document.body, { childList: true, subtree: true })
2854
4041
  } catch {
2855
4042
  headerWatcher = undefined
@@ -2866,7 +4053,16 @@ window.__ModuleLoader__.load({
2866
4053
  return () => {
2867
4054
  offRoute?.()
2868
4055
  stopSettle()
4056
+ if (placeFrame !== undefined) attemptValue(() => window.cancelAnimationFrame?.(placeFrame))
2869
4057
  input.removeEventListener?.('keydown', onKeyDown)
4058
+ input.removeEventListener?.('focus', onFocus)
4059
+ input.removeEventListener?.('mousedown', onMouseDown)
4060
+ input.removeEventListener?.('input', onInput)
4061
+ input.removeEventListener?.('paste', onPaste)
4062
+ input.removeEventListener?.('blur', onBlur)
4063
+ for (const timer of copiedTimers) attemptValue(() => window.clearTimeout(timer))
4064
+ copiedTimers.clear()
4065
+ if (toastTimer !== undefined) attemptValue(() => window.clearTimeout(toastTimer))
2870
4066
  if (onResize !== undefined) window.removeEventListener?.('resize', onResize)
2871
4067
  headerWatcher?.disconnect?.()
2872
4068
  bar.remove?.()
@@ -2874,6 +4070,158 @@ window.__ModuleLoader__.load({
2874
4070
  }
2875
4071
  }
2876
4072
 
4073
+ // ---------------------------------------------------------------------
4074
+ // settings page: the plugin manager's row page is this plugin's settings surface
4075
+ //
4076
+ // The manager dispatches a row's configuration under `<package name>#<row id>` and
4077
+ // renders the entry twice: once as the row's one-line summary and once as the page
4078
+ // the row's configure control opens. Registering here is what turns
4079
+ // `#/panel/plugins/dsh-url-router/dsh-url-router` from a read-only overview into the
4080
+ // page these two preferences are set on — the same shape every other plugin in this
4081
+ // profile has, instead of a settings tab of its own.
4082
+ // ---------------------------------------------------------------------
4083
+ /** The card's stylesheet id, so a re-apply replaces its own rather than stacking copies. */
4084
+ const SETTINGS_STYLE_ID = 'dsh-url-router-settings-style'
4085
+ /** The card's own styles: the manager's class names are hashed, so it cannot borrow them. */
4086
+ const SETTINGS_STYLE_TEXT = [
4087
+ '.dshur{display:flex;flex-direction:column;gap:8px;color:var(--dsw-alias-label-primary,inherit);font-size:13px;line-height:20px}',
4088
+ '.dshur-block{display:flex;flex-direction:column;gap:4px;padding:10px 12px;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.26));border-radius:10px}',
4089
+ '.dshur-row{display:flex;align-items:center;gap:10px;justify-content:space-between}',
4090
+ '.dshur-label{display:flex;align-items:center;gap:8px;font-weight:500;cursor:pointer}',
4091
+ '.dshur-check{width:16px;height:16px;margin:0;accent-color:var(--dsw-alias-brand-primary,#4d6bfe)}',
4092
+ '.dshur-number{width:96px;height:28px;box-sizing:border-box;padding:0 8px;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.34));border-radius:8px;background:transparent;color:inherit;font:inherit;text-align:right}',
4093
+ '.dshur-number:focus{outline:none;border-color:var(--dsw-alias-brand-primary,#4d6bfe)}',
4094
+ '.dshur-state{color:var(--dsw-alias-label-secondary,#4b5872);font-size:12px}',
4095
+ '.dshur-hint{margin:0;color:var(--dsw-alias-label-tertiary,#81858c);font-size:12px;line-height:18px}',
4096
+ '.dshur-meta{display:flex;align-items:center;gap:10px;color:var(--dsw-alias-label-secondary,#4b5872);font-size:12px}',
4097
+ '.dshur-clear{height:26px;padding:0 10px;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.34));border-radius:7px;background:transparent;color:inherit;font:inherit;font-size:12px;cursor:pointer}',
4098
+ '.dshur-clear:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.14))}',
4099
+ ].join('')
4100
+
4101
+ /**
4102
+ * The settings card on this plugin's row page.
4103
+ *
4104
+ * The manager calls it twice: with `view: 'summary'` for the row's one-liner (it must
4105
+ * stay cheap — it renders on every row paint and must not touch the disk) and with the
4106
+ * page's own view for the settings themselves.
4107
+ * @param {object} props - slot props, `view` names which of the two is wanted.
4108
+ * @returns {object|string} the element to draw, or the one-liner.
4109
+ */
4110
+ function RouterSettingsCard({ view }) {
4111
+ const h = react.createElement
4112
+ const [settings, setSettings] = react.useState(readSettings())
4113
+ const [draft, setDraft] = react.useState(String(readSettings().recentLimit))
4114
+ const [remembered, setRemembered] = react.useState(() => recentEntries().length)
4115
+ react.useEffect(() => subscribeSettings(next => {
4116
+ setSettings(next)
4117
+ setDraft(String(next.recentLimit))
4118
+ setRemembered(recentEntries().length)
4119
+ }), [])
4120
+ if (view === 'summary') {
4121
+ return `点击全选 ${settings.selectAllOnFocus ? '开' : '关'} · 记忆 ${settings.recentLimit} 条`
4122
+ }
4123
+ /**
4124
+ * Take a typed count: an empty field, a minus sign or anything else unusable is a
4125
+ * half-typed value and must not be committed as 0.
4126
+ * @param {string} text - what the field holds.
4127
+ */
4128
+ const commitCount = text => {
4129
+ setDraft(text)
4130
+ const number = Number(text)
4131
+ if (String(text).trim() === '' || !Number.isFinite(number) || number < 0) return
4132
+ setSettings(updateSettings({ recentLimit: Math.floor(number) }))
4133
+ }
4134
+ return h('div', { className: 'dshur' }, [
4135
+ h('div', { className: 'dshur-block', key: 'select' }, [
4136
+ h('div', { className: 'dshur-row' }, [
4137
+ h('label', { className: 'dshur-label' }, [
4138
+ h('input', {
4139
+ type: 'checkbox',
4140
+ className: 'dshur-check',
4141
+ checked: settings.selectAllOnFocus,
4142
+ onChange: event => setSettings(updateSettings({ selectAllOnFocus: event?.target?.checked === true })),
4143
+ }),
4144
+ h('span', null, '首次点击地址栏时全选'),
4145
+ ]),
4146
+ h('span', { className: 'dshur-state' }, settings.selectAllOnFocus ? '开' : '关'),
4147
+ ]),
4148
+ h('p', { className: 'dshur-hint' }, '像浏览器地址栏:第一次点进去,框里原来的链接整条选中,直接输入就替换它;已经在框里时再点,才是放光标。'),
4149
+ ]),
4150
+ h('div', { className: 'dshur-block', key: 'recent' }, [
4151
+ h('div', { className: 'dshur-row' }, [
4152
+ h('label', { className: 'dshur-label', htmlFor: 'dshur-recent-limit' }, '记住最近链接的条数'),
4153
+ h('input', {
4154
+ id: 'dshur-recent-limit',
4155
+ type: 'number',
4156
+ min: 0,
4157
+ step: 1,
4158
+ inputMode: 'numeric',
4159
+ className: 'dshur-number',
4160
+ value: draft,
4161
+ onChange: event => commitCount(event?.target?.value ?? ''),
4162
+ onBlur: () => setDraft(String(readSettings().recentLimit)),
4163
+ }),
4164
+ ]),
4165
+ h('p', { className: 'dshur-hint' }, '默认 8 条,最低 0 条(0 = 不记忆,地址栏也不再弹最近列表),没有上限。这个数就是点地址栏时下拉框里能看到的条数 —— 地址栏里正显示的那个界面不算(它已经在你眼前了,离开之后才会回到列表里)。'),
4166
+ h('p', { className: 'dshur-meta' }, [
4167
+ h('span', { key: 'count' }, `已记住 ${remembered} 条`),
4168
+ h('button', {
4169
+ type: 'button',
4170
+ className: 'dshur-clear',
4171
+ key: 'clear',
4172
+ onClick: () => {
4173
+ clearRecent()
4174
+ setRemembered(0)
4175
+ },
4176
+ }, '清空记录'),
4177
+ ]),
4178
+ ]),
4179
+ ])
4180
+ }
4181
+
4182
+ /**
4183
+ * Register the settings card with the plugin manager.
4184
+ *
4185
+ * A composition without the manager, without the slot service, or without React has
4186
+ * no card and needs none: nothing else in the plugin depends on this.
4187
+ * @param {object} ctx - client context.
4188
+ * @returns {() => void} the disposer.
4189
+ */
4190
+ function startSettingsCard(ctx) {
4191
+ if (react === undefined || typeof window === 'undefined' || window.document === undefined) return () => {}
4192
+ const slots = ctx?.slots
4193
+ if (typeof slots?.inject !== 'function' || typeof slots?.register !== 'function') return () => {}
4194
+ const document = window.document
4195
+ let style
4196
+ try {
4197
+ style = document.createElement('style')
4198
+ style.id = SETTINGS_STYLE_ID
4199
+ style.textContent = SETTINGS_STYLE_TEXT
4200
+ const parent = document.head ?? document.body
4201
+ if (typeof parent?.appendChild === 'function') parent.appendChild(style)
4202
+ } catch {
4203
+ style = undefined
4204
+ }
4205
+ let off
4206
+ try {
4207
+ off = slots.inject('plugins.row.config', () => slots.register({
4208
+ name: 'plugins.row.config',
4209
+ key: ROW_CONFIG_KEY,
4210
+ }, RouterSettingsCard))
4211
+ } catch {
4212
+ // A page that never declares the slot is a page without a card, not a failure.
4213
+ off = undefined
4214
+ }
4215
+ return () => {
4216
+ try {
4217
+ if (typeof off === 'function') off()
4218
+ } catch {
4219
+ // A slot service that is already gone needs no undo.
4220
+ }
4221
+ style?.remove?.()
4222
+ }
4223
+ }
4224
+
2877
4225
  function apply(ctx) {
2878
4226
  // Fragment routing: the address bar names the view, and a link opens it.
2879
4227
  ctx.effect(() => {
@@ -2928,6 +4276,10 @@ window.__ModuleLoader__.load({
2928
4276
  if (typeof window === 'undefined' || window.document === undefined) return () => {}
2929
4277
  return startTitlebarAddress(window.document, subscribeRoute)
2930
4278
  }, 'url-router: desktop address bar')
4279
+ // Settings page: the plugin manager's row page carries the two preferences, so
4280
+ // there is no separate settings tab to find them in.
4281
+ ctx.effect(() => startSettingsCard(ctx), 'url-router: settings card')
4282
+
2931
4283
  }
2932
4284
 
2933
4285
  exports.apply = apply