dsh-url-router 0.4.0 → 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.0'
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'
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,12 +188,234 @@ 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
- /** Gap between the shell's menu and the bar. */
159
- const TITLEBAR_GAP = 8
160
- /** How long the bar waits for a handed-over link to land before it says it did not. */
161
- const TITLEBAR_SETTLE_MS = 900
198
+ /** Gap between the shell's menu and the bar. */
199
+ const TITLEBAR_GAP = 8
200
+ /**
201
+ * macOS has no in-page title-bar strip, so the bar cannot borrow window chrome
202
+ * the way the Windows build does. It borrows the next best thing: the shell's
203
+ * resident header row, which exists on every screen. The bar stays mounted in
204
+ * the body with fixed positioning — exactly the Windows approach — and `place`
205
+ * measures the title cluster and the right-hand cluster to get its insets.
206
+ */
207
+ const MAC_BAR_HEIGHT = 26
208
+ /** The least room the bar will draw itself into; below this it stays hidden. */
209
+ const MAC_BAR_MIN_WIDTH = 120
210
+ /** The breathing space the bar keeps from whatever stands next to it. */
211
+ const MAC_BAR_GAP = 8
212
+ /** The margin the bar leaves inside the free stretch, on each side. */
213
+ const MAC_BAR_INSET = 25
214
+ /**
215
+ * The bar's own length.
216
+ *
217
+ * The free stretch begins wherever the conversation's title happens to end, so
218
+ * without a length of its own the bar would grow and shrink as titles change.
219
+ * It stops here instead, and only gets shorter when the row cannot spare this
220
+ * much.
221
+ */
222
+ const MAC_BAR_MAX_WIDTH = 1150
223
+ /**
224
+ * How much the window may change before a remembered stretch stops counting.
225
+ *
226
+ * The stretch was measured against one window; a different one moves the
227
+ * header's buttons, and there is nothing to re-measure from on a screen that
228
+ * has no power button. A couple of pixels is rounding, more is a resize.
229
+ */
230
+ const MAC_BAR_RESIZE_SLACK = 8
231
+ const MAC_STYLE_TEXT = [
232
+ `#${TITLEBAR_BAR_ID}[data-dsh-url-router-platform="mac"]{position:fixed;display:flex;align-items:center;height:${MAC_BAR_HEIGHT}px;z-index:1200;-webkit-app-region:no-drag}`,
233
+ `#${TITLEBAR_BAR_ID}[data-dsh-url-router-platform="mac"] input{height:${MAC_BAR_HEIGHT}px;width:100%;box-sizing:border-box;border:1px solid rgba(127,127,127,.34);border-radius:8px;padding:0 10px;font:inherit;font-size:12px;color:inherit;background:rgba(127,127,127,.16);outline:none;-webkit-app-region:no-drag}`,
234
+ `#${TITLEBAR_BAR_ID}[data-dsh-url-router-platform="mac"] input:focus{border-color:var(--dsw-alias-brand-primary,rgba(79,124,255,.9))}`,
235
+ `#${TITLEBAR_BAR_ID}[data-dsh-url-router-platform="mac"][data-dsh-url-router-state="refused"] input{border-color:rgba(214,69,69,.85);color:rgba(214,69,69,1)}`,
236
+ ].join('')
237
+ /** Where the bar's last spot is kept, so a screen without a power button still gets it. */
238
+ const MAC_PLACEMENT_KEY = 'dsh-url-router-placement'
239
+ /**
240
+ * Describe one element: what it is, and where it sits.
241
+ * @param {object} el - the element to describe.
242
+ * @returns {object|null} the description, or null when there is no element.
243
+ */
244
+ function macDescribe(el) {
245
+ if (el === null || el === undefined) return null
246
+ const rect = el.getBoundingClientRect?.()
247
+ const cls = el.className?.baseVal ?? el.className ?? ''
248
+ return {
249
+ tag: String(el.tagName ?? '?').toLowerCase(),
250
+ cls: typeof cls === 'string' ? cls.slice(0, 70) : '',
251
+ rect: rect === undefined || rect === null
252
+ ? null
253
+ : { top: Math.round(rect.top), left: Math.round(rect.left), width: Math.round(rect.width), height: Math.round(rect.height) },
254
+ }
255
+ }
256
+ /**
257
+ * Every descendant of an element that carries text of its own or answers a
258
+ * click — the header's real contents, so the bar can find the gap between
259
+ * them instead of guessing where it is.
260
+ * @param {object} el - the element to walk.
261
+ * @param {number} depth - how far down to walk.
262
+ * @returns {Array<object>} the leaves, with their boxes.
263
+ */
264
+ function macLeaves(el, depth) {
265
+ const leaves = []
266
+ if (el === null || el === undefined || depth > 6) return leaves
267
+ for (const child of el.children ?? []) {
268
+ const own = ownText(child)
269
+ const interactive = typeof child.closest === 'function'
270
+ && child.matches?.('button, a, input, [role="button"], [role="tab"], img, svg') === true
271
+ if (own !== '' || interactive || child.children.length === 0) {
272
+ leaves.push({ ...macDescribe(child), own: own.slice(0, 40) })
273
+ } else {
274
+ leaves.push(...macLeaves(child, depth + 1))
275
+ }
276
+ }
277
+ return leaves
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
+ }
332
+ /**
333
+ * The header row the bar has to live in: the row that carries the
334
+ * conversation's title on the left and the header's own buttons on the right.
335
+ *
336
+ * The shell names the row in its stylesheet, so that name is tried first; when
337
+ * it has changed, the row is taken from the power button's own corner, whose
338
+ * parent is the row the button was mounted on.
339
+ *
340
+ * @param {object} trigger - the power-menu trigger.
341
+ * @returns {object|undefined} the row element, or undefined when there is none.
342
+ */
343
+ function macHeaderRow(trigger) {
344
+ const page = attemptValue(() => window.document)
345
+ const corner = attemptValue(() => page?.querySelector?.('[data-conversation-header-corner]'))
346
+ const named = attemptValue(() => page?.querySelector?.('[class*="titleRow"]'))
347
+ if (named !== undefined && named !== null) {
348
+ // Only the row that actually holds the button is the row to measure.
349
+ if (trigger === undefined || trigger === null) return named
350
+ if (typeof named.contains !== 'function' || named.contains(trigger)) return named
351
+ }
352
+ return corner?.parentElement ?? undefined
353
+ }
354
+ /**
355
+ * The free stretch of the header row the bar can live in.
356
+ *
357
+ * The row is measured rather than guessed: every leaf that shares the power
358
+ * button's row — the title text, the mode label, the header's own buttons — is
359
+ * turned into an interval, overlapping intervals are merged, and the widest
360
+ * stretch between them is the one the bar goes into. Nothing is covered,
361
+ * because that stretch holds nothing; nothing is pushed, because the bar is a
362
+ * fixed-position layer of its own.
363
+ *
364
+ * @param {Array<object>} leaves - the row's measured contents.
365
+ * @param {object} rect - the power button's rectangle, which ends the search.
366
+ * @param {object} [bounds] - the row's own rectangle, which begins it.
367
+ * @returns {{left: number, right: number}|null} the stretch, or null when the row has no room.
368
+ */
369
+ function macFreeGap(leaves, rect, bounds) {
370
+ if (rect === undefined || rect === null) return null
371
+ const rowTop = rect.top
372
+ const rowBottom = rect.top + rect.height
373
+ // The power button is the anchor: the bar never reaches past its left edge.
374
+ const limit = Math.round(rect.left)
375
+ const occupied = []
376
+ for (const leaf of leaves ?? []) {
377
+ const box = leaf?.rect
378
+ if (box === undefined || box === null) continue
379
+ if (!(box.width > 0) || !(box.height > 0)) continue
380
+ // Only what stands on the power button's row can be in the way.
381
+ if (box.top >= rowBottom || box.top + box.height <= rowTop) continue
382
+ const left = Math.round(box.left)
383
+ const right = Math.round(box.left + box.width)
384
+ if (right <= 0 || left >= limit) continue
385
+ occupied.push({ left, right })
386
+ }
387
+ occupied.sort((a, b) => a.left - b.left)
388
+ // Merge what overlaps, so a box inside another cannot split a stretch.
389
+ const merged = []
390
+ for (const span of occupied) {
391
+ const last = merged[merged.length - 1]
392
+ if (last !== undefined && span.left <= last.right) {
393
+ if (span.right > last.right) last.right = span.right
394
+ continue
395
+ }
396
+ merged.push({ left: span.left, right: span.right })
397
+ }
398
+ // The row begins at its own left edge when that is known, and at the first
399
+ // thing standing on it otherwise.
400
+ const start = bounds !== undefined && bounds !== null && bounds.left > 0
401
+ ? Math.round(bounds.left)
402
+ : (merged[0]?.left ?? limit)
403
+ const runs = []
404
+ let cursor = start
405
+ for (const span of merged) {
406
+ if (span.left > cursor) runs.push({ left: cursor, right: span.left })
407
+ if (span.right > cursor) cursor = span.right
408
+ }
409
+ if (limit > cursor) runs.push({ left: cursor, right: limit })
410
+ let best = null
411
+ for (const run of runs) {
412
+ if (run.right - run.left < MAC_BAR_MIN_WIDTH) continue
413
+ if (best === null || run.right - run.left > best.right - best.left) best = run
414
+ }
415
+ return best
416
+ }
417
+ /** How long the bar waits for a handed-over link to land before it says it did not. */
418
+ const TITLEBAR_SETTLE_MS = 900
162
419
  /** What the address bar accepts, said in its placeholder. */
163
420
  const TITLEBAR_PLACEHOLDER = '#/session/… · #/panel/… · #/settings · 会话 id · 链接'
164
421
  /** The strip belongs to the shell, so only the bar itself is styled. */
@@ -168,6 +425,24 @@ window.__ModuleLoader__.load({
168
425
  `#${TITLEBAR_BAR_ID} input:focus{border-color:var(--dsw-alias-brand-primary,rgba(79,124,255,.9))}`,
169
426
  `#${TITLEBAR_BAR_ID}[data-dsh-url-router-state="refused"] input{border-color:rgba(214,69,69,.85);color:rgba(214,69,69,1)}`,
170
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('')
171
446
  /** Listeners told the fragment this router just wrote; the desktop bar reads it. */
172
447
  const routeFeed = new Set()
173
448
  /**
@@ -175,6 +450,9 @@ window.__ModuleLoader__.load({
175
450
  * @param {string} hash - the fragment just written.
176
451
  */
177
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)
178
456
  for (const listener of [...routeFeed]) {
179
457
  try {
180
458
  listener(hash)
@@ -200,6 +478,282 @@ window.__ModuleLoader__.load({
200
478
  */
201
479
  let driveTypedFragment = () => {}
202
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
+
203
757
  /**
204
758
  * The conversation view ids the registry publishes, in registration order — the same order
205
759
  * the header renders its tabs in, which is what lets a tab position map back to an id.
@@ -284,12 +838,6 @@ window.__ModuleLoader__.load({
284
838
  return undefined
285
839
  }
286
840
 
287
- /**
288
- * The kind inside a tab id: the bar builds one as `sidebar://<kind>` and appends a uuid for
289
- * kinds that may be opened more than once, so the kind is right there in the address.
290
- * @param {string} value - a tab id.
291
- * @returns {string|undefined} the kind, when the value is one of these addresses.
292
- */
293
841
  /**
294
842
  * The right bar's registry, normalised: one row per page type with its kind, the label
295
843
  * the tab shows, whether more than one may be open, and its empty-state card (measured
@@ -297,36 +845,6 @@ window.__ModuleLoader__.load({
297
845
  * @param {object} faces - the probed host faces.
298
846
  * @returns {{kind: string, label: string|undefined, multiple: boolean}[]|undefined} the rows.
299
847
  */
300
- /**
301
- * The seq of a turn's start event, from the `turnOutline` projection.
302
- *
303
- * The projection is a public read on the session controller (the core reads it the same
304
- * way: `session.projections.faceOf("turnOutline").getSnapshot()`), and each entry carries
305
- * the `seq` of that turn's `turn/start` — which is exactly what `loadThrough` pages back
306
- * to. Reading it is what makes a turn link work for a turn that is not loaded yet.
307
- * @param {object} faces - the probed host faces.
308
- * @param {string|undefined} sessionId - the session the turn belongs to.
309
- * @param {string} turn - the turn number.
310
- * @returns {number|undefined} the seq, when the projection names that turn.
311
- */
312
- function turnSeqOf(faces, sessionId, turn) {
313
- if (typeof sessionId !== 'string' || sessionId === '') return undefined
314
- // Reached by retaining the session's scope: `sessions.binding(id)` answers undefined
315
- // for a session nobody materialised yet (measured), while `retainScope(id, source)`
316
- // materialises it and returns a reference whose `binding` is the live one. Released here
317
- // because only the projection is read.
318
- const reference = attemptValue(() => faces.sessions?.retainScope?.(sessionId, 'url-router'))
319
- const outline = attemptValue(() => session?.projections?.faceOf?.('turnOutline')?.getSnapshot?.())
320
- attempt(() => reference?.release?.())
321
- if (!Array.isArray(outline)) return undefined
322
- const wanted = String(turn)
323
- for (const entry of outline) {
324
- if (entry === null || typeof entry !== 'object') continue
325
- if (String(entry.turn) !== wanted) continue
326
- return typeof entry.seq === 'number' ? entry.seq : undefined
327
- }
328
- return undefined
329
- }
330
848
 
331
849
  function paneRegistryOf(faces) {
332
850
  const rows = attemptValue(() => faces.sidebarRightTabs?.entries?.())
@@ -392,6 +910,72 @@ window.__ModuleLoader__.load({
392
910
  warn(`${TAG} ${message}`)
393
911
  }
394
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
+
395
979
  function activePaneTabOf(document, registry) {
396
980
  // Scoped to the right bar's own root: the same dockkit attributes dress the main area
397
981
  // too, and reading them unscoped reports the main area's panes, not this bar's tabs.
@@ -430,7 +1014,11 @@ window.__ModuleLoader__.load({
430
1014
  if (named !== undefined) {
431
1015
  const wanted = flatLabel(named).toLowerCase()
432
1016
  const match = Array.isArray(registry) ? registry.find(row => typeof row.label === 'string' && flatLabel(row.label).toLowerCase() === wanted) : undefined
433
- 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
434
1022
  }
435
1023
  // 2) an address-shaped id, for a future build that puts one there
436
1024
  const fromTab = kindOfTabId(attemptValue(() => chosen.getAttribute?.('data-dockkit-tab')))
@@ -444,6 +1032,206 @@ window.__ModuleLoader__.load({
444
1032
  return undefined
445
1033
  }
446
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
+
447
1235
  /**
448
1236
  * Read a route out of a location fragment.
449
1237
  * @param {string} hash - the raw `window.location.hash`, including the leading `#`.
@@ -523,7 +1311,7 @@ window.__ModuleLoader__.load({
523
1311
  }
524
1312
  // A panel may have an inner page (a plugin's detail view, for instance): the
525
1313
  // rest of the path names it, per panel.
526
- const inner = segments.map(decodePart).filter(segment => segment.trim() !== '')
1314
+ const inner = normalisePanelInner(kind, id, segments.map(decodePart).filter(segment => segment.trim() !== ''))
527
1315
  return { kind, id, inner: inner.length === 0 ? undefined : inner }
528
1316
  }
529
1317
 
@@ -584,6 +1372,27 @@ window.__ModuleLoader__.load({
584
1372
  return rowId === '' ? undefined : { packageName: undefined, rowId }
585
1373
  }
586
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
+
587
1396
  function innerPath(value) {
588
1397
  const parts = Array.isArray(value) ? value : String(value ?? '').split('/')
589
1398
  return parts.map(part => String(part).trim()).filter(part => part !== '')
@@ -653,7 +1462,7 @@ window.__ModuleLoader__.load({
653
1462
  let panelScope
654
1463
  /** The turn the user last clicked inside a conversation, with its session. */
655
1464
  /** What the report can say about this run: fragments it refused, kinds it left alone. */
656
- const observed = { foreignFragment: undefined, ignored: [], warnings: [] }
1465
+ const observed = { foreignFragment: undefined, ignored: [], warnings: [], compat: [] }
657
1466
  let rowAnchor
658
1467
  /** The pane kind this claim already opened (one drive per claim, see the pane branch). */
659
1468
  let paneOpenedFor
@@ -665,6 +1474,8 @@ window.__ModuleLoader__.load({
665
1474
  let innerAttempts = 0
666
1475
  /** Whether the current link already asked for a modal standing in front of it to go away. */
667
1476
  let overlayDismissed = false
1477
+ /** When the compatibility checks last read the page (`-Infinity`: never swept). */
1478
+ let lastCompatSweep = -Infinity
668
1479
  /** The view state observed on the previous notification. */
669
1480
  let seen = 'none'
670
1481
  let cancelTimer
@@ -694,22 +1505,32 @@ window.__ModuleLoader__.load({
694
1505
  if (settingsOverlayOf(window.document) !== undefined) {
695
1506
  const section = host.activeSettingsSection() ?? ''
696
1507
  const tab = section === '' ? undefined : host.activeSettingsTab()
697
- 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 }
698
1513
  }
699
1514
  const panelId = host.activePanelId()
700
1515
  if (panelId !== undefined && panelId !== null) {
701
1516
  const id = String(panelId)
702
- // A right-hand pane in front is part of what is on screen, so it is named first:
703
- // it is true whether or not the panel itself reports an inner page.
704
- const paneInPanel = host.activePaneKind()
705
- 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).
706
1521
  const page = host.panelPage(id, panelScope)
707
- if (page === undefined) return { kind: 'panel', id, inner: undefined }
708
1522
  const segments = innerPath(page)
709
1523
  // Remember the package a page was opened under, so a bare component page (which
710
1524
  // reports only its row) can still be named fully.
711
1525
  if (id === PLUGINS_PANEL_ID && segments.length === 1) panelScope = segments[0]
712
- 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 }
713
1534
  }
714
1535
  const session = host.mainViewSessionId()
715
1536
  if (session !== undefined && session !== null) {
@@ -738,7 +1559,8 @@ window.__ModuleLoader__.load({
738
1559
  // The right-hand pane is its own layer, not a position inside the conversation:
739
1560
  // it is named next to whatever the main column shows, never instead of it — so it
740
1561
  // is read *before* the workspace branch, or the workspace would swallow it.
741
- 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()
742
1564
  const tail = pane === undefined ? [] : [PANE_SEGMENT, pane]
743
1565
  const workspaceName = host.workspaceNameOf(id)
744
1566
  if (workspaceName !== undefined) {
@@ -784,6 +1606,27 @@ window.__ModuleLoader__.load({
784
1606
  // that is no longer in flight is never "the same route" as anything.
785
1607
  if (linked === undefined || linked === null) return false
786
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
+ }
787
1630
  const currentSession = sessionIdOfRoute(current)
788
1631
  const linkedSession = sessionIdOfRoute(linked)
789
1632
  if (currentSession !== undefined && linkedSession !== undefined) {
@@ -827,8 +1670,12 @@ window.__ModuleLoader__.load({
827
1670
  if (current?.kind !== 'settings' || linked?.kind !== 'settings') return false
828
1671
  if (linked.id === '') return true
829
1672
  if (current.id !== linked.id) return false
830
- const linkedTab = innerPath(linked.inner)[0] ?? ''
831
- 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
832
1679
  }
833
1680
  /** Write the fragment for the current view, leaving path and query untouched. */
834
1681
  const reflect = route => {
@@ -861,6 +1708,7 @@ window.__ModuleLoader__.load({
861
1708
  */
862
1709
  const reconcile = (source = 'boot') => {
863
1710
  if (disposed) return
1711
+ workCounts.reconciles += 1
864
1712
 
865
1713
  const current = canonical()
866
1714
  const currentKey = routeKey(current)
@@ -873,6 +1721,22 @@ window.__ModuleLoader__.load({
873
1721
  if (paneTabs > 0 && host.activePaneKind() === undefined) {
874
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'}`)
875
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
+ }
876
1740
  // A position the previous step asked for: it is driven here, one step later, so the step
877
1741
  // that claimed it never drives while it is still running.
878
1742
  if (linked === undefined) {
@@ -889,8 +1753,18 @@ window.__ModuleLoader__.load({
889
1753
  // The link has landed: it stops being authoritative right here, so the user
890
1754
  // keeps owning the address bar afterwards (walking deeper inside a panel
891
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
+ }
892
1766
  dropLink()
893
- reflect(current)
1767
+ reflect(canonical())
894
1768
  return
895
1769
  }
896
1770
  if (host.now() >= link.deadline) {
@@ -972,7 +1846,8 @@ window.__ModuleLoader__.load({
972
1846
  if (link.kind === 'settings') {
973
1847
  if (source !== 'hash' && innerAttempts < INNER_ATTEMPT_LIMIT) {
974
1848
  innerAttempts += 1
975
- host.openSettings(link.id, innerPath(link.inner)[0])
1849
+ const parts = settingsPartsOf(link.inner)
1850
+ host.openSettings(link.id, parts.tab, parts.provider)
976
1851
  }
977
1852
  armRetry()
978
1853
  return
@@ -1063,7 +1938,16 @@ window.__ModuleLoader__.load({
1063
1938
  // with a bound so an unopenable page cannot turn into a click loop.
1064
1939
  if (link.inner !== undefined && routeKey(current) !== routeKey(link) && source !== 'hash' && innerAttempts < INNER_ATTEMPT_LIMIT) {
1065
1940
  innerAttempts += 1
1066
- 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
+ }
1067
1951
  }
1068
1952
  armRetry()
1069
1953
  return
@@ -1266,6 +2150,14 @@ window.__ModuleLoader__.load({
1266
2150
  paneActive: attemptValue(() => host.activePaneKind()) ?? null,
1267
2151
  paneKinds: attemptValue(() => host.paneKinds?.()) ?? null,
1268
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 },
1269
2161
  compat: observed.compat,
1270
2162
  foreignFragment: observed.foreignFragment,
1271
2163
  ignoredKinds: observed.ignored,
@@ -1775,6 +2667,13 @@ window.__ModuleLoader__.load({
1775
2667
  }
1776
2668
  const host = {
1777
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),
1778
2677
  // The sidebar lists workspaces and their sessions in one tree, in order, so the
1779
2678
  // workspace a conversation belongs to is the nearest workspace row before it.
1780
2679
  workspaceNameOf: sessionId => {
@@ -1799,6 +2698,19 @@ window.__ModuleLoader__.load({
1799
2698
  // The inner page a panel is showing, in that panel's own vocabulary. Each panel
1800
2699
  // publishes it with a data attribute; reading it is the whole reflect direction.
1801
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
+ }
1802
2714
  if (panelId === TASK_BOARD_PANEL_ID) {
1803
2715
  // The form and a task are mutually exclusive, and the form wins when both
1804
2716
  // would be reported (it is the page the user is looking at).
@@ -1822,6 +2734,15 @@ window.__ModuleLoader__.load({
1822
2734
  },
1823
2735
  openPanelPage: (panelId, inner) => {
1824
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
+ }
1825
2746
  if (panelId === TASK_BOARD_PANEL_ID) {
1826
2747
  // The task board owns how its overlays open: we hand over the request on its
1827
2748
  // own contract (window events) and nothing else crosses the seam.
@@ -1847,8 +2768,9 @@ window.__ModuleLoader__.load({
1847
2768
  const pkg =
1848
2769
  window.document?.querySelector?.(`li[data-plugin-package="${segments[0]}"]`) ??
1849
2770
  window.document?.querySelector?.(`li[data-plugin-item="${segments[0]}"]`)
1850
- const button = pkg?.querySelector?.('button')
1851
- 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()
1852
2774
  if (segments.length === 1) return
1853
2775
  }
1854
2776
  const rowId = segments[segments.length - 1]
@@ -1856,8 +2778,8 @@ window.__ModuleLoader__.load({
1856
2778
  for (const row of rows) {
1857
2779
  const parsed = splitRowHook(typeof row.getAttribute === 'function' ? row.getAttribute('data-plugin-row') : null)
1858
2780
  if (parsed === undefined || parsed.rowId !== rowId) continue
1859
- const button = row.querySelector?.('button')
1860
- if (typeof button?.click === 'function') button.click()
2781
+ const button = openControlOf(row)
2782
+ if (button !== undefined) button.click()
1861
2783
  return
1862
2784
  }
1863
2785
  },
@@ -1929,6 +2851,7 @@ window.__ModuleLoader__.load({
1929
2851
  revealPosition: (parts, wantedSession) => {
1930
2852
  const wanted = innerPath(parts)
1931
2853
  const document = window.document
2854
+
1932
2855
  if (wanted[0] === ROW_SEGMENT && wanted.length >= 3) {
1933
2856
  const key = wanted.slice(1).join('%00')
1934
2857
  for (const candidate of document?.querySelectorAll?.(`[${TRAJECTORY_ROW_ATTRIBUTE}]`) ?? []) {
@@ -1961,16 +2884,46 @@ window.__ModuleLoader__.load({
1961
2884
  // from the catalog here is how this step used to look up "no session".
1962
2885
  const reference = attemptValue(() => faces.sessions?.retainScope?.(wantedSession, 'url-router'))
1963
2886
  const session = attemptValue(() => reference?.binding?.session)
1964
- const loadThrough = attemptValue(() => (session === undefined || session === null ? undefined : session.loadThrough))
1965
2887
  const outline = attemptValue(() => session?.projections?.faceOf?.('turnOutline')?.getSnapshot?.())
1966
2888
  const seq = Array.isArray(outline) ? outline.find(entry => entry && String(entry.turn) === String(turn))?.seq : undefined
1967
- 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'
1968
2893
  if (typeof seq !== 'number' || !usable) {
1969
2894
  attemptValue(() => reference?.release?.())
1970
2895
  return false
1971
2896
  }
1972
- 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))
1973
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
+ }
1974
2927
  return true
1975
2928
  },
1976
2929
  // Which conversation view is showing. The registry is the source of truth for ids
@@ -2031,10 +2984,13 @@ window.__ModuleLoader__.load({
2031
2984
  const snapshot = attemptValue(() => (typeof owner.getSnapshot === 'function' ? owner.getSnapshot() : undefined))
2032
2985
  return snapshot === undefined ? undefined : kindOf(snapshot.activeTabId ?? snapshot.activeTab ?? snapshot.active)
2033
2986
  }
2034
- const fromStrip = activePaneTabOf(window.document, paneRegistryOf(faces))
2035
- 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.
2036
2990
  const fromController = read(faces.sidebarRight)
2037
2991
  if (fromController !== undefined) return fromController
2992
+ const fromStrip = activePaneTabOf(window.document, paneRegistryOf(faces))
2993
+ if (fromStrip !== undefined) return fromStrip
2038
2994
  const fromRegistry = read(faces.sidebarRightTabs)
2039
2995
  if (fromRegistry !== undefined) return fromRegistry
2040
2996
  // DOM fallback, deliberately narrow: the active-pane marker is shared with the
@@ -2062,6 +3018,8 @@ window.__ModuleLoader__.load({
2062
3018
  }
2063
3019
  return undefined
2064
3020
  },
3021
+ // The model-provider card whose editor is open, when one is (see the helper).
3022
+ activeSettingsProvider: () => openSettingsProviderId(window.document),
2065
3023
  // The tab a settings section is showing: the same ARIA contract one level deeper.
2066
3024
  activeSettingsTab: () => {
2067
3025
  const overlay = settingsOverlayOf(window.document)
@@ -2087,7 +3045,7 @@ window.__ModuleLoader__.load({
2087
3045
  window.document?.dispatchEvent?.(event)
2088
3046
  })
2089
3047
  },
2090
- openSettings: (section, tab) => {
3048
+ openSettings: (section, tab, provider) => {
2091
3049
  if (typeof section === 'string' && section !== '') {
2092
3050
  // A section is named by the label the user reads, so the request is a label match.
2093
3051
  const overlay = settingsOverlayOf(window.document)
@@ -2100,7 +3058,11 @@ window.__ModuleLoader__.load({
2100
3058
  const wanted = section.trim().toLowerCase()
2101
3059
  const match = candidates.find(candidate => (candidate.textContent ?? '').trim().toLowerCase() === wanted)
2102
3060
  if (match !== undefined && typeof match.click === 'function') {
2103
- 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())
2104
3066
  // The section may own tabs: a named one is clicked once the section is up.
2105
3067
  if (typeof tab === 'string' && tab !== '') {
2106
3068
  const wantedTab = tab.trim().toLowerCase()
@@ -2108,6 +3070,8 @@ window.__ModuleLoader__.load({
2108
3070
  const matchTab = tabs.find(candidate => (candidate.textContent ?? '').trim().toLowerCase() === wantedTab)
2109
3071
  if (matchTab !== undefined && typeof matchTab.click === 'function') attempt(() => matchTab.click())
2110
3072
  }
3073
+ // And one of its cards may be the page a link named.
3074
+ if (typeof provider === 'string' && provider !== '') clickSettingsProvider(provider)
2111
3075
  return
2112
3076
  }
2113
3077
  }
@@ -2228,7 +3192,22 @@ window.__ModuleLoader__.load({
2228
3192
  attempt(() => {
2229
3193
  const root = window.document?.body ?? window.document?.documentElement
2230
3194
  if (root === undefined || root === null || typeof window.MutationObserver !== 'function') return () => {}
2231
- 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)
2232
3211
  // Every published page hook must be watched: a filter that names only some of
2233
3212
  // them silently starves the retry loop of notifications (real bug: the task
2234
3213
  // board's drive looked flaky because its attributes were not observed).
@@ -2247,6 +3226,10 @@ window.__ModuleLoader__.load({
2247
3226
  'aria-current',
2248
3227
  // Switching a settings *tab* moves this one, for the same reason.
2249
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',
2250
3233
  SETTINGS_SURFACE_ATTRIBUTE,
2251
3234
  ],
2252
3235
  })
@@ -2383,6 +3366,56 @@ window.__ModuleLoader__.load({
2383
3366
  return `打不开 ${name}(当前 ${shown},侧栏 ${rows} 行)`
2384
3367
  }
2385
3368
 
3369
+ /**
3370
+ * Whether this window is the macOS desktop build.
3371
+ *
3372
+ * The macOS desktop shell draws a native title bar and publishes no in-page
3373
+ * strip, so the Windows marker is absent there — which used to mean "draw
3374
+ * nothing". Two facts together identify a macOS desktop window: the shell marks
3375
+ * `<html data-platform="darwin">` (or the user agent says Mac), and the window
3376
+ * is the application rather than a browser tab (Electron in the user agent, or
3377
+ * the page served over the app's own `dsh-app:` scheme). The plain web app in a
3378
+ * browser satisfies neither and stays empty, as before.
3379
+ *
3380
+ * @param {object} root - `document.documentElement`.
3381
+ * @returns {boolean} true when the address bar belongs on screen.
3382
+ */
3383
+ function macDesktopWindow(root) {
3384
+ const ua = typeof navigator === 'object' && navigator !== null ? String(navigator.userAgent ?? '') : ''
3385
+ const platform = typeof root?.getAttribute === 'function' ? root.getAttribute('data-platform') : null
3386
+ const isMac = platform === 'darwin' || /Mac OS X|Macintosh/u.test(ua)
3387
+ if (!isMac) return false
3388
+ const protocol = String(globalThis.location?.protocol ?? '')
3389
+ return /Electron\/\d/u.test(ua) || protocol.startsWith('dsh-app')
3390
+ }
3391
+
3392
+
3393
+ /**
3394
+ * The power-menu trigger, the one element that is guaranteed to sit on the
3395
+ * same row on every screen. The address bar anchors to it.
3396
+ *
3397
+ * @returns {object|undefined} the trigger element, or undefined when absent.
3398
+ */
3399
+ function powerTrigger() {
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="电源菜单"]')
3405
+ ?? undefined
3406
+ }
3407
+
3408
+ /** An element's own text, ignoring the text of its children. */
3409
+ function ownText(el) {
3410
+ if (el === null || el === undefined || typeof el.childNodes !== 'object') return ''
3411
+ let text = ''
3412
+ for (const node of el.childNodes) {
3413
+ if (node !== null && node.nodeType === 3) text += node.textContent ?? ''
3414
+ }
3415
+ return text.trim()
3416
+ }
3417
+
3418
+
2386
3419
  /**
2387
3420
  * Draw the address bar into the desktop window's title bar strip.
2388
3421
  * @param {object} document - the document to draw into.
@@ -2393,14 +3426,32 @@ window.__ModuleLoader__.load({
2393
3426
  if (document === undefined || document === null) return () => {}
2394
3427
  const root = document.documentElement
2395
3428
  // The marker is the shell's own statement that this page has a title bar drawn
2396
- // inside it. Without it there is no strip, and nothing is drawn.
2397
- if (typeof root?.getAttribute !== 'function' || root.getAttribute(WINDOWS_TITLEBAR_ATTRIBUTE) === null) return () => {}
3429
+ // inside it. Without it there is no strip — but the macOS desktop build still
3430
+ // gets a bar, drawn into the session header row instead.
3431
+ const windowsStrip = typeof root?.getAttribute === 'function' && root.getAttribute(WINDOWS_TITLEBAR_ATTRIBUTE) !== null
3432
+ const macInHeader = !windowsStrip && macDesktopWindow(root)
3433
+ if (!windowsStrip && !macInHeader) return () => {}
2398
3434
  if (typeof document.createElement !== 'function' || typeof document.body?.appendChild !== 'function') return () => {}
3435
+ // One bar, one stylesheet — however many times this runs.
3436
+ //
3437
+ // The effect that draws the bar can run again in a live window (a hot
3438
+ // reload of this plugin re-applies it), and a second mount would leave a
3439
+ // second box on screen. Both are identified by id, so anything already
3440
+ // there is taken down before the new one goes up: the platform split
3441
+ // decides *where* the one bar lives, never how many there are.
3442
+ try {
3443
+ const staleBar = document.getElementById?.(TITLEBAR_BAR_ID)
3444
+ if (staleBar !== null && staleBar !== undefined) staleBar.remove?.()
3445
+ const staleStyle = document.getElementById?.(TITLEBAR_STYLE_ID)
3446
+ if (staleStyle !== null && staleStyle !== undefined) staleStyle.remove?.()
3447
+ } catch {
3448
+ // A document without getElementById keeps the disposer as its only guard.
3449
+ }
2399
3450
  let style
2400
3451
  try {
2401
3452
  style = document.createElement('style')
2402
3453
  style.id = TITLEBAR_STYLE_ID
2403
- style.textContent = TITLEBAR_STYLE_TEXT
3454
+ style.textContent = `${macInHeader ? `${TITLEBAR_STYLE_TEXT}\n${MAC_STYLE_TEXT}` : TITLEBAR_STYLE_TEXT}\n${RECENT_STYLE_TEXT}`
2404
3455
  const parent = document.head ?? document.body
2405
3456
  if (parent !== undefined && parent !== null && typeof parent.appendChild === 'function') parent.appendChild(style)
2406
3457
  } catch {
@@ -2408,26 +3459,201 @@ window.__ModuleLoader__.load({
2408
3459
  }
2409
3460
  const bar = document.createElement('div')
2410
3461
  bar.id = TITLEBAR_BAR_ID
2411
- if (typeof bar.setAttribute === 'function') bar.setAttribute('data-dsh-part', TITLEBAR_PART)
3462
+ if (typeof bar.setAttribute === 'function') {
3463
+ bar.setAttribute('data-dsh-part', TITLEBAR_PART)
3464
+ bar.setAttribute('data-dsh-url-router-platform', macInHeader ? 'mac' : 'windows')
3465
+ }
2412
3466
  const input = document.createElement('input')
2413
3467
  input.type = 'text'
2414
3468
  input.spellcheck = false
2415
3469
  input.placeholder = TITLEBAR_PLACEHOLDER
2416
3470
  if (typeof input.setAttribute === 'function') input.setAttribute('aria-label', '地址栏 / address bar')
2417
3471
  bar.appendChild(input)
2418
- /** Start after the shell's menu, stop before its window buttons. */
2419
- const place = () => {
2420
- let menuWidth = 0
2421
- try {
2422
- const menu = typeof document.querySelector === 'function' ? document.querySelector(`[${WINDOWS_MENU_ATTRIBUTE}]`) : null
2423
- if (menu !== null && menu !== undefined && typeof menu.getBoundingClientRect === 'function') menuWidth = menu.getBoundingClientRect().width || 0
2424
- } catch {
2425
- menuWidth = 0
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)
3482
+ /**
3483
+ * The free stretch the bar was last fitted into.
3484
+ *
3485
+ * A screen with no power button — a panel, the plugins page, settings — has
3486
+ * nothing to align to. The bar keeps this stretch there, so changing views
3487
+ * neither moves it nor makes it vanish. The stretch is also kept on disk, so
3488
+ * a window that starts on such a screen still gets the bar where it belongs;
3489
+ * and because the box is fitted from the stretch each time, a change to the
3490
+ * bar's margins applies there too, without waiting for a conversation.
3491
+ */
3492
+ let lastPlacement = attemptValue(() => {
3493
+ const raw = globalThis.localStorage?.getItem?.(MAC_PLACEMENT_KEY)
3494
+ if (raw === null || raw === undefined || raw === '') return null
3495
+ const parsed = JSON.parse(raw)
3496
+ const gap = parsed?.gap
3497
+ if (typeof parsed?.top !== 'number' || typeof parsed?.viewport !== 'number') return null
3498
+ if (typeof gap?.left !== 'number' || typeof gap?.right !== 'number') return null
3499
+ return { top: parsed.top, viewport: parsed.viewport, gap: { left: gap.left, right: gap.right } }
3500
+ }) ?? null
3501
+ /**
3502
+ * Fit the bar into a free stretch of the header row.
3503
+ *
3504
+ * The bar takes the whole stretch bar a margin on each side, so it is as
3505
+ * long as the row can spare without touching what stands beside it. A
3506
+ * narrow row keeps the whole stretch rather than a sliver of it.
3507
+ *
3508
+ * @param {{left: number, right: number}} gap - the free stretch.
3509
+ * @param {number} top - the row's line, already centred on the power button.
3510
+ * @returns {{left: number, top: number, width: number}} the box to draw.
3511
+ */
3512
+ const fitBar = (gap, top) => {
3513
+ const right = gap.right - MAC_BAR_GAP - MAC_BAR_INSET
3514
+ const room = gap.left + MAC_BAR_INSET
3515
+ const available = right - room
3516
+ const width = available >= MAC_BAR_MIN_WIDTH
3517
+ ? Math.round(Math.min(MAC_BAR_MAX_WIDTH, available))
3518
+ : Math.round(gap.right - MAC_BAR_GAP - gap.left)
3519
+ return { left: Math.round(right - width), top, width }
3520
+ }
3521
+ /**
3522
+ * Remember the stretch the bar went into, and put it on disk when it moved.
3523
+ * @param {object} placement - the line and the stretch just used.
3524
+ */
3525
+ const remember = placement => {
3526
+ const previous = lastPlacement
3527
+ lastPlacement = placement
3528
+ if (previous !== null
3529
+ && previous.top === placement.top
3530
+ && previous.viewport === placement.viewport
3531
+ && previous.gap.left === placement.gap.left
3532
+ && previous.gap.right === placement.gap.right) return
3533
+ attemptValue(() => globalThis.localStorage?.setItem?.(MAC_PLACEMENT_KEY, JSON.stringify(placement)))
3534
+ }
3535
+ /**
3536
+ * Put the bar where it belongs.
3537
+ *
3538
+ * Windows: start after the shell's menu, stop before its window buttons.
3539
+ * macOS: sit in the free stretch of the header row, on the power button's
3540
+ * own line.
3541
+ */
3542
+ /** The shape the last placement was measured from, so an unchanged header is not re-walked. */
3543
+ let lastShape
3544
+ const place = () => {
3545
+ if (macInHeader) {
3546
+ // The bar lives on the header row, on the same line as the power
3547
+ // button, in the free stretch between the title text and the
3548
+ // header's own buttons. The row's real contents are measured and the
3549
+ // widest free stretch is chosen, so the bar covers nothing — and,
3550
+ // being a fixed-position layer, it pushes nothing either.
3551
+ const trigger = powerTrigger()
3552
+ const rect = trigger?.getBoundingClientRect?.()
3553
+ const row = macHeaderRow(trigger)
3554
+ const rowRect = row?.getBoundingClientRect?.()
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
3570
+ // The row is only walked when there is a button to measure from.
3571
+ const gap = anchored ? macFreeGap(macLeaves(row, 0), rect, rowRect) : null
3572
+ if (gap === null) {
3573
+ // Nothing on this screen to align to. Keep the stretch the
3574
+ // conversation gave the bar, so switching views does not move it
3575
+ // and does not make it disappear.
3576
+ //
3577
+ // Only while the window is still the one that stretch was measured
3578
+ // in: the header's buttons sit at the right end of the window, so a
3579
+ // resize moves them, and reusing an old stretch would draw the bar
3580
+ // straight across the title and the buttons. With no button to
3581
+ // measure from there is nothing to re-derive it from either, so a
3582
+ // resized window hides the bar until a conversation places it again.
3583
+ const viewport = Math.round(globalThis.innerWidth ?? 0)
3584
+ if (lastPlacement === null || viewport <= 0
3585
+ || Math.abs(viewport - lastPlacement.viewport) > MAC_BAR_RESIZE_SLACK) {
3586
+ bar.style.display = 'none'
3587
+ return
3588
+ }
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)
3610
+ bar.style.display = ''
3611
+ bar.style.top = `${box.top}px`
3612
+ bar.style.left = `${box.left}px`
3613
+ bar.style.right = 'auto'
3614
+ bar.style.width = `${box.width}px`
3615
+ return
2426
3616
  }
2427
- bar.style.left = `${WINDOWS_MENU_START + menuWidth + TITLEBAR_GAP}px`
2428
- bar.style.right = `${TITLEBAR_CAPTION_WIDTH}px`
3617
+ const top = Math.round(rect.top + (rect.height - MAC_BAR_HEIGHT) / 2)
3618
+ const box = fitBar(gap, top)
3619
+ bar.style.display = ''
3620
+ bar.style.top = `${box.top}px`
3621
+ bar.style.left = `${box.left}px`
3622
+ bar.style.right = 'auto'
3623
+ bar.style.width = `${box.width}px`
3624
+ remember({ top, viewport: Math.round(globalThis.innerWidth ?? 0), gap })
3625
+ return
2429
3626
  }
2430
- place()
3627
+ let menuWidth = 0
3628
+ try {
3629
+ const menu = typeof document.querySelector === 'function' ? document.querySelector(`[${WINDOWS_MENU_ATTRIBUTE}]`) : null
3630
+ if (menu !== null && menu !== undefined && typeof menu.getBoundingClientRect === 'function') menuWidth = menu.getBoundingClientRect().width || 0
3631
+ } catch {
3632
+ menuWidth = 0
3633
+ }
3634
+ bar.style.left = `${WINDOWS_MENU_START + menuWidth + TITLEBAR_GAP}px`
3635
+ bar.style.right = `${TITLEBAR_CAPTION_WIDTH}px`
3636
+ bar.style.width = 'auto'
3637
+ }
3638
+ /**
3639
+ * Place the bar at most once per frame.
3640
+ *
3641
+ * A streaming answer mutates the document constantly, and every mutation
3642
+ * would otherwise re-measure the header row. Coalescing the runs into one
3643
+ * frame keeps the bar where it belongs without measuring the header dozens
3644
+ * of times a second.
3645
+ */
3646
+ let placeFrame
3647
+ const schedulePlace = () => {
3648
+ if (placeFrame !== undefined) return
3649
+ placeFrame = attemptValue(() => window.requestAnimationFrame?.(() => {
3650
+ placeFrame = undefined
3651
+ place()
3652
+ }))
3653
+ // No animation frames in this host: place it straight away.
3654
+ if (placeFrame === undefined) place()
3655
+ }
3656
+ place()
2431
3657
  /** How the bar looks right now: idle, waiting for a link to land, or refused. */
2432
3658
  const mark = (state, hint) => {
2433
3659
  if (typeof bar.setAttribute === 'function') bar.setAttribute('data-dsh-url-router-state', state)
@@ -2486,14 +3712,291 @@ window.__ModuleLoader__.load({
2486
3712
  input.value = why
2487
3713
  }, TITLEBAR_SETTLE_MS))
2488
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
+ }
2489
3988
  const onKeyDown = event => {
2490
3989
  if (event?.key === 'Escape') {
2491
3990
  stopSettle()
3991
+ narrowed = false
3992
+ hideRecent()
2492
3993
  mark('idle', undefined)
2493
3994
  input.value = readableFragment(window.location?.hash)
2494
3995
  return
2495
3996
  }
2496
3997
  if (event?.key !== 'Enter') return
3998
+ narrowed = false
3999
+ hideRecent()
2497
4000
  const fragment = titlebarFragmentOf(input.value)
2498
4001
  if (fragment === undefined) {
2499
4002
  // Nothing here names a route: leave the address bar saying what is on screen.
@@ -2510,11 +4013,39 @@ window.__ModuleLoader__.load({
2510
4013
  settle(fragment, false)
2511
4014
  }
2512
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)
4021
+ // Both platforms mount the bar in the body: Windows pins it into the
4022
+ // title-bar strip, macOS pins it over the resident header row.
2513
4023
  document.body.appendChild(bar)
4024
+ // The first placement measured the header before the bar was in the
4025
+ // document. Place it again now that it is, so the bar's own box is laid
4026
+ // out and can be checked, and so the first paint is already correct.
4027
+ place()
4028
+ /**
4029
+ * The shell re-renders the session header whenever the view changes, which
4030
+ * takes the bar with it. A watcher puts it back where it belongs — the same
4031
+ * node, so what was typed into it survives.
4032
+ */
4033
+ let headerWatcher
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') {
4038
+ try {
4039
+ headerWatcher = new Observer(() => schedulePlace())
4040
+ headerWatcher.observe(document.body, { childList: true, subtree: true })
4041
+ } catch {
4042
+ headerWatcher = undefined
4043
+ }
4044
+ }
2514
4045
  const offRoute = subscribe(show)
2515
4046
  let onResize
2516
4047
  try {
2517
- onResize = () => place()
4048
+ onResize = () => schedulePlace()
2518
4049
  window.addEventListener('resize', onResize)
2519
4050
  } catch {
2520
4051
  onResize = undefined
@@ -2522,13 +4053,175 @@ window.__ModuleLoader__.load({
2522
4053
  return () => {
2523
4054
  offRoute?.()
2524
4055
  stopSettle()
4056
+ if (placeFrame !== undefined) attemptValue(() => window.cancelAnimationFrame?.(placeFrame))
2525
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))
2526
4066
  if (onResize !== undefined) window.removeEventListener?.('resize', onResize)
4067
+ headerWatcher?.disconnect?.()
2527
4068
  bar.remove?.()
2528
4069
  style?.remove?.()
2529
4070
  }
2530
4071
  }
2531
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
+
2532
4225
  function apply(ctx) {
2533
4226
  // Fragment routing: the address bar names the view, and a link opens it.
2534
4227
  ctx.effect(() => {
@@ -2583,6 +4276,10 @@ window.__ModuleLoader__.load({
2583
4276
  if (typeof window === 'undefined' || window.document === undefined) return () => {}
2584
4277
  return startTitlebarAddress(window.document, subscribeRoute)
2585
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
+
2586
4283
  }
2587
4284
 
2588
4285
  exports.apply = apply