dsh-url-router 0.4.1 → 0.4.3

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.
Files changed (4) hide show
  1. package/AGENTS.md +137 -5
  2. package/README.md +66 -5
  3. package/lib/client.js +2313 -104
  4. package/package.json +4 -3
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
@@ -63,7 +78,69 @@ window.__ModuleLoader__.load({
63
78
  // another router's route may legitimately be pasted in.
64
79
  // ---------------------------------------------------------------------
65
80
  /** Route kind aliases accepted on input. */
66
- const ROUTE_ALIASES = { session: 'session', sessions: 'session', panel: 'panel', panels: 'panel', settings: 'settings', workspace: 'workspace', workspaces: 'workspace' }
81
+ const ROUTE_ALIASES = { session: 'session', sessions: 'session', panel: 'panel', panels: 'panel', settings: 'settings', power: 'power', workspace: 'workspace', workspaces: 'workspace' }
82
+ /**
83
+ * The power actions a link may name, and the endpoint the power plugin publishes for each.
84
+ *
85
+ * These are *commands*, not places: a URL that carries one ends the whole application (or
86
+ * restarts it), so a fragment that names one is honoured only when the reader typed it into
87
+ * the address bar themselves — never when it arrived as a hash change from a link somebody
88
+ * else wrote. `dsh-power-menu` already exposes both endpoints (`POST
89
+ * /api/dsh-power-menu/{shutdown,restart}`), so no click and no cooperation is needed.
90
+ */
91
+ const POWER_ACTIONS = { shutdown: 'shutdown', restart: 'restart' }
92
+ /**
93
+ * The bases the power actions live under, in the order they are tried.
94
+ *
95
+ * Two packages publish the same two actions under different paths: the desktop build ships
96
+ * `dsh-power-menu-mac` (`/api/dsh-power-menu-mac`) while the plain package uses
97
+ * `/api/dsh-power-menu`. Measured 2026-10-29: posting to the wrong one is a silent 404, which
98
+ * is exactly how "cannot restart" looked — so both are tried, and a wrong base costs nothing.
99
+ */
100
+ const POWER_ACTION_BASES = ['/api/dsh-power-menu-mac', '/api/dsh-power-menu']
101
+ /**
102
+ * How many drive steps the plugin's own menu gets before the endpoint is used instead.
103
+ *
104
+ * The drive retries every half second, so this is a couple of seconds: long enough for the
105
+ * menu to render, short enough that a plugin which never publishes the item still acts.
106
+ */
107
+ const POWER_ACTION_MENU_LIMIT = 4
108
+
109
+ /**
110
+ * The power plugin's own menu, when it is on screen.
111
+ *
112
+ * Contract attribute first (`data-dshpm-menu`), then the class the plugin ships. Neither is
113
+ * guessed at: an unreadable menu sends the drive down the endpoint fallback instead.
114
+ * @param {object} document - the document.
115
+ * @returns {object|undefined} the menu root.
116
+ */
117
+ function powerMenuOf(document) {
118
+ const contracted = attemptValue(() => document?.querySelector?.('[data-dshpm-menu]'))
119
+ if (contracted !== undefined && contracted !== null) return contracted
120
+ const known = attemptValue(() => document?.getElementsByClassName?.('dshpm-menu')?.[0])
121
+ return known === undefined || known === null ? undefined : known
122
+ }
123
+
124
+ /**
125
+ * The power action a route names, if any.
126
+ * @param {string} id - the route's identity segment.
127
+ * @returns {string|undefined} the action.
128
+ */
129
+ function powerActionOf(id) {
130
+ if (typeof id !== 'string') return undefined
131
+ return Object.prototype.hasOwnProperty.call(POWER_ACTIONS, id) ? POWER_ACTIONS[id] : undefined
132
+ }
133
+ /**
134
+ * The power menu: a transient popover rendered by a separate plugin, and the one surface
135
+ * this router does not own. Its contract is an attribute; the class names are the fallback
136
+ * for a build that has not added it yet.
137
+ */
138
+ /** How many times one link may press the settings launcher while no menu appears. */
139
+ const SETTINGS_PRESS_LIMIT = 3
140
+ const POWER_MENU_ATTRIBUTE = 'data-dsh-power-menu'
141
+ const POWER_MENU_CLASS = 'dshpm-menu'
142
+ const POWER_TRIGGER_ATTRIBUTE = 'data-dsh-power-trigger'
143
+ const POWER_TRIGGER_CLASS = 'dshpm-trigger'
67
144
  /** Attribute the settings overlay puts on its root element. */
68
145
  const SETTINGS_SURFACE_ATTRIBUTE = 'data-dsh-surface'
69
146
  /**
@@ -78,6 +155,8 @@ window.__ModuleLoader__.load({
78
155
  const SETTINGS_SURFACE_VALUE = 'settings'
79
156
  /** Slot the settings entry is rendered into (its button opens the overlay). */
80
157
  const SETTINGS_TRIGGER_SLOT = 'sidebar.settings'
158
+ /** Slot the settings sections are registered into — the same list the shell renders. */
159
+ const SETTINGS_SECTION_SLOT = 'settings.section'
81
160
  /** Canonical fragment shape, plus the bare `#/<kind>` form a modal surface uses. */
82
161
  const ROUTE_PATTERN = /^#\/?([a-z][a-z0-9-]*)(?:\/(.+))?$/
83
162
  /** How often an unsatisfied link is retried while it is authoritative. */
@@ -122,23 +201,85 @@ window.__ModuleLoader__.load({
122
201
  const TURN_SEGMENT = 'turn'
123
202
  /** Inner segment that names the right-hand pane in front. */
124
203
  const PANE_SEGMENT = 'pane'
204
+ /**
205
+ * The parent of a subagent session, as a decoration after the session id.
206
+ *
207
+ * A subagent session is a session of its own (it opens, it has turns and links), but the
208
+ * printed id says nothing about *whose* subagent it is. The catalog knows: the session
209
+ * controller resolves a child's durable (parent, child) address without opening anything.
210
+ * The parent is written like a workspace name — decoration, never the thing that lands.
211
+ */
212
+ const SUBAGENT_SEGMENT = 'subagent-of'
213
+ /**
214
+ * Segment names other parsers look for, so a decoration never becomes one.
215
+ *
216
+ * The parent of a subagent is written as a readable segment when the catalog has a title.
217
+ * Those parsers (`viewIdOfRoute`, the turn/row/pane lookups) search by segment name, so a
218
+ * title that *is* one of these names would be read as a position. Such a title falls back
219
+ * to the parent id instead.
220
+ */
221
+ const ROUTE_RESERVED_TOKENS = new Set(['turn', 'row', 'view', 'pane', 'subagent-of', 'provider', 'filter', 'task', 'rule', 'records', 'session', 'workspace', 'settings', 'panel'])
222
+ /** How long a title may be inside a route before it is clipped. */
223
+ const SUBAGENT_TITLE_LIMIT = 80
224
+ /**
225
+ * Whether a route segment is a session id (a uuid, with or without the `session-` prefix).
226
+ *
227
+ * The subagent decoration is a title when the catalog has one and the parent id otherwise,
228
+ * so the drive has to tell the two apart: an id is opened by address, anything else is a
229
+ * label and the child is opened by its own id.
230
+ * @param {unknown} value - the segment.
231
+ * @returns {boolean} whether it reads as a session id.
232
+ */
233
+ function isSessionIdLike(value) {
234
+ return typeof value === 'string' && /^(?:session-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(value.trim())
235
+ }
236
+ /**
237
+ * The readable name of a subagent's parent, or its id when there is no usable title.
238
+ * @param {object} parent - `{ id, title }` from the catalog.
239
+ * @returns {string} the segment to write.
240
+ */
241
+ function subagentDecorationOf(parent) {
242
+ const title = typeof parent?.title === 'string' ? parent.title.trim().replace(/\s+/gu, ' ') : ''
243
+ const usable = title !== '' && !ROUTE_RESERVED_TOKENS.has(title.toLowerCase())
244
+ return usable ? title.slice(0, SUBAGENT_TITLE_LIMIT) : String(parent?.id ?? '')
245
+ }
125
246
  /** Service the right-hand pane registers, whose snapshot names the active tab. */
126
247
  const SIDEBAR_RIGHT_SERVICE = 'sidebarRightTabs'
127
248
  /** Panel id of the official plugin manager, whose list rows and detail pages are routed. */
128
249
  const PLUGINS_PANEL_ID = 'plugins'
250
+ /** Panel id of the automation-task manager, whose status filter and open task are routed. */
251
+ const SCHEDULES_PANEL_ID = 'schedules'
252
+ /** Inner segment that names the automation task a list has open. */
253
+ const TASK_SEGMENT = 'task'
254
+ /** Inner segment that names the model-provider card an open editor belongs to. */
255
+ const PROVIDER_SEGMENT = 'provider'
256
+ /** Inner segment that names the automation list's status filter. */
257
+ const FILTER_SEGMENT = 'filter'
258
+ /**
259
+ * The status filters, in the fixed order the manager renders them.
260
+ *
261
+ * Its own source maps `["all", "active", "inactive"]` over the three buttons, and the
262
+ * buttons carry no value of their own (only `aria-pressed`), so the position is the
263
+ * identity — the same positional contract the conversation view tabs already use.
264
+ */
265
+ const SCHEDULE_FILTERS = ['all', 'active', 'inactive']
266
+ /** Attribute the manager puts on a task-detail tab, whose value is `rule` or `records`. */
267
+ const SCHEDULE_TAB_ATTRIBUTE = 'data-detail-tab'
268
+ /** The right bar's own "collapsed" marker: its tabs stay mounted while the bar is shut. */
269
+ const RIGHT_BAR_COLLAPSED_ATTRIBUTE = 'data-rightbar-collapsed'
129
270
  /** Log prefix for every diagnostic. */
130
271
  /**
131
272
  * The router's own version, for the report a human files. A test keeps it equal to the
132
273
  * package version, so a release cannot forget to bump it.
133
274
  */
134
- const ROUTER_VERSION = '0.4.1'
275
+ const ROUTER_VERSION = '0.4.3'
135
276
  /**
136
277
  * Hand-maintained build marker, reported alongside the version. A working copy that is
137
278
  * linked into a profile serves this file straight from disk, so the package version
138
279
  * cannot say *which* copy a live window loaded — this can, and `report().build` is how a
139
280
  * real-machine check answers that. Bump it whenever a live window must be told apart.
140
281
  */
141
- const ROUTER_BUILD = 'desktop-bar+settings-marker+modal-dismiss+mac-header-bar.2'
282
+ 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+subagent-title+hover+view-labels+power+settings-launcher+settings-menu-visible+settings-seat+settings-once+landed-forms+settings-press+settings-store+settings-depth+settings-class-fallback+node-list+power-actions+power-action-lands+power-bases+power-menu-first+typed-rearms+power-contract-only+power-typed-gate+power-leave-focus+power-records+power-gate-precise+power-echo.39'
142
283
 
143
284
  const TAG = '[url-router]'
144
285
 
@@ -153,6 +294,11 @@ window.__ModuleLoader__.load({
153
294
  /** The bar's structural hook, the way the row anchors carry theirs. */
154
295
  const TITLEBAR_PART = 'url-router-address-bar'
155
296
  const TITLEBAR_STYLE_ID = 'dsh-url-router-titlebar-style'
297
+ /** Structural hooks the bar's recent list publishes (tests and the observer read them). */
298
+ const RECENT_LIST_ATTRIBUTE = 'data-dsh-url-router-recent'
299
+ const RECENT_ITEM_ATTRIBUTE = 'data-dsh-url-router-recent-item'
300
+ /** The bar's copy-confirmation chip. */
301
+ const TOAST_ATTRIBUTE = 'data-dsh-url-router-toast'
156
302
  /** Width the window's caption buttons take at the right end of the strip. */
157
303
  const TITLEBAR_CAPTION_WIDTH = 138
158
304
  /** Gap between the shell's menu and the bar. */
@@ -188,11 +334,22 @@ window.__ModuleLoader__.load({
188
334
  * has no power button. A couple of pixels is rounding, more is a resize.
189
335
  */
190
336
  const MAC_BAR_RESIZE_SLACK = 8
337
+ /**
338
+ * The bar is hidden until the pointer is over it, when the preference asks for it.
339
+ *
340
+ * The bar keeps its box (only its opacity drops), so hovering the place where it lives is
341
+ * what reveals it — no extra hit area, and nothing else on the row moves.
342
+ */
343
+ const HOVER_ATTRIBUTE = 'data-dsh-url-router-hover'
344
+ /** The reveal itself, set by the pointer/focus handlers and read by the stylesheet. */
345
+ const SHOWN_ATTRIBUTE = 'data-dsh-url-router-shown'
191
346
  const MAC_STYLE_TEXT = [
192
347
  `#${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}`,
193
348
  `#${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}`,
194
349
  `#${TITLEBAR_BAR_ID}[data-dsh-url-router-platform="mac"] input:focus{border-color:var(--dsw-alias-brand-primary,rgba(79,124,255,.9))}`,
195
350
  `#${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)}`,
351
+ `#${TITLEBAR_BAR_ID}[${HOVER_ATTRIBUTE}]{opacity:0;transition:opacity .14s ease}`,
352
+ `#${TITLEBAR_BAR_ID}[${HOVER_ATTRIBUTE}][${SHOWN_ATTRIBUTE}]{opacity:1}`,
196
353
  ].join('')
197
354
  /** Where the bar's last spot is kept, so a screen without a power button still gets it. */
198
355
  const MAC_PLACEMENT_KEY = 'dsh-url-router-placement'
@@ -236,6 +393,59 @@ window.__ModuleLoader__.load({
236
393
  }
237
394
  return leaves
238
395
  }
396
+ /**
397
+ * The things whose presence on a line means "do not draw over this".
398
+ *
399
+ * Controls only: a page heading or an icon is decoration the bar may sit on, while a tab, a
400
+ * button or a field is something to click or type into (measured: the automation task's detail
401
+ * tabs were exactly what the bar covered).
402
+ */
403
+ const BAND_SELECTORS = ['button', '[role="tab"]', '[role="button"]', 'a', 'input', 'textarea', 'select', '[contenteditable="true"]', 'h1', 'h2', 'h3']
404
+ /**
405
+ * The page's own contents that stand on one line of the window, as merged spans.
406
+ *
407
+ * A screen with no header row to measure (a panel, the settings overlay) still draws things
408
+ * at the bar's line: the automation task's detail tabs, a page heading, a toolbar. Reusing a
409
+ * stretch measured on a conversation put the bar straight across those tabs (measured on the
410
+ * automation panel). Only controls, headings and icons count — a background or a plain
411
+ * container is not something to give way to.
412
+ * @param {object} document - the page.
413
+ * @param {object} own - the bar itself, whose box is never an obstacle.
414
+ * @param {number} top - the line's top edge.
415
+ * @param {number} height - the line's height.
416
+ * @returns {Array<{left: number, right: number}>} merged spans, left to right.
417
+ */
418
+ function bandSpans(document, own, top, height) {
419
+ const bottom = top + height
420
+ const seen = new Set()
421
+ const spans = []
422
+ // One selector at a time: a comma list is beyond a small DOM stub.
423
+ for (const selector of BAND_SELECTORS) {
424
+ for (const node of attemptValue(() => [...(document?.querySelectorAll?.(selector) ?? [])]) ?? []) {
425
+ if (node === null || node === undefined || seen.has(node)) continue
426
+ seen.add(node)
427
+ const mine = node === own || (typeof own?.contains === 'function' && own.contains(node) === true)
428
+ if (mine) continue
429
+ const rect = attemptValue(() => node.getBoundingClientRect?.())
430
+ if (rect === undefined || rect === null) continue
431
+ if (!(rect.height > 0) || rect.top >= bottom || rect.top + rect.height <= top) continue
432
+ const left = Math.round(rect.left)
433
+ const right = Math.round(rect.left + rect.width)
434
+ if (right > left) spans.push({ left, right })
435
+ }
436
+ }
437
+ spans.sort((first, second) => first.left - second.left)
438
+ const merged = []
439
+ for (const span of spans) {
440
+ const last = merged[merged.length - 1]
441
+ if (last !== undefined && span.left <= last.right) {
442
+ if (span.right > last.right) last.right = span.right
443
+ continue
444
+ }
445
+ merged.push({ left: span.left, right: span.right })
446
+ }
447
+ return merged
448
+ }
239
449
  /**
240
450
  * The header row the bar has to live in: the row that carries the
241
451
  * conversation's title on the left and the header's own buttons on the right.
@@ -248,8 +458,9 @@ window.__ModuleLoader__.load({
248
458
  * @returns {object|undefined} the row element, or undefined when there is none.
249
459
  */
250
460
  function macHeaderRow(trigger) {
251
- const corner = document.querySelector?.('[data-conversation-header-corner]')
252
- const named = document.querySelector?.('[class*="titleRow"]')
461
+ const page = attemptValue(() => window.document)
462
+ const corner = attemptValue(() => page?.querySelector?.('[data-conversation-header-corner]'))
463
+ const named = attemptValue(() => page?.querySelector?.('[class*="titleRow"]'))
253
464
  if (named !== undefined && named !== null) {
254
465
  // Only the row that actually holds the button is the row to measure.
255
466
  if (trigger === undefined || trigger === null) return named
@@ -330,6 +541,26 @@ window.__ModuleLoader__.load({
330
541
  `#${TITLEBAR_BAR_ID} input{height:26px;width:100%;box-sizing:border-box;border:1px solid transparent;border-radius:6px;padding:0 8px;font:inherit;font-size:12px;color:inherit;background:var(--dsw-specific-sidebar-fill,rgba(127,127,127,.14));outline:none;-webkit-app-region:no-drag}`,
331
542
  `#${TITLEBAR_BAR_ID} input:focus{border-color:var(--dsw-alias-brand-primary,rgba(79,124,255,.9))}`,
332
543
  `#${TITLEBAR_BAR_ID}[data-dsh-url-router-state="refused"] input{border-color:rgba(214,69,69,.85);color:rgba(214,69,69,1)}`,
544
+ `#${TITLEBAR_BAR_ID}[${HOVER_ATTRIBUTE}]{opacity:0;transition:opacity .14s ease}`,
545
+ `#${TITLEBAR_BAR_ID}[${HOVER_ATTRIBUTE}][${SHOWN_ATTRIBUTE}]{opacity:1}`,
546
+ ].join('')
547
+ /**
548
+ * The recent list's own stylesheet: a browser-style drop-down under the bar.
549
+ *
550
+ * Both platforms share it — the bar is the same fixed-position element in both — so
551
+ * the picker can never drift into two different-looking lists.
552
+ */
553
+ const RECENT_STYLE_TEXT = [
554
+ `#${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}`,
555
+ `#${TITLEBAR_BAR_ID} [${RECENT_LIST_ATTRIBUTE}][data-open]{display:block}`,
556
+ `#${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}`,
557
+ `#${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}`,
558
+ `#${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}`,
559
+ `#${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)}`,
560
+ `#${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}`,
561
+ `#${TITLEBAR_BAR_ID} [${RECENT_ITEM_ATTRIBUTE}][data-copied] .dshur-link{color:var(--dsw-alias-brand-primary,#4d6bfe);font-weight:600}`,
562
+ `#${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}`,
563
+ `#${TITLEBAR_BAR_ID} [${TOAST_ATTRIBUTE}][data-show]{opacity:1}`,
333
564
  ].join('')
334
565
  /** Listeners told the fragment this router just wrote; the desktop bar reads it. */
335
566
  const routeFeed = new Set()
@@ -338,6 +569,9 @@ window.__ModuleLoader__.load({
338
569
  * @param {string} hash - the fragment just written.
339
570
  */
340
571
  function publishRoute(hash) {
572
+ // A fragment this router just wrote names the screen the reader moved to, so it is
573
+ // remembered (the bar's recent list). An empty write names nothing and is skipped.
574
+ rememberRecent(hash)
341
575
  for (const listener of [...routeFeed]) {
342
576
  try {
343
577
  listener(hash)
@@ -363,6 +597,285 @@ window.__ModuleLoader__.load({
363
597
  */
364
598
  let driveTypedFragment = () => {}
365
599
 
600
+ // ---------------------------------------------------------------------
601
+ // preferences: how the address bar behaves, remembered on this machine
602
+ //
603
+ // Two small preferences make the bar act like a browser's: the first click
604
+ // selects all of the old link, and the bar remembers the screens it has named
605
+ // plus the links pasted into it. They belong to this window rather than to the
606
+ // profile, so they live in localStorage; the plugin manager page writes them
607
+ // (`#/panel/plugins/dsh-url-router/dsh-url-router`).
608
+ // ---------------------------------------------------------------------
609
+ /** Preference record: the two switches the settings page owns. */
610
+ const SETTINGS_KEY = 'dsh-url-router-settings'
611
+ /** Remembered links, newest first. */
612
+ const RECENT_KEY = 'dsh-url-router-recent'
613
+ /** What the bar does before the settings page has ever been opened. */
614
+ const DEFAULT_SETTINGS = { selectAllOnFocus: true, hoverReveal: false, recentLimit: 8 }
615
+ /**
616
+ * The key the plugin manager dispatches a row's configuration under:
617
+ * `<package name>#<row id>`. This package declares one row whose package name and
618
+ * row id are the same string, so the key names it twice — and the page it opens is
619
+ * exactly `#/panel/plugins/dsh-url-router/dsh-url-router`.
620
+ */
621
+ const ROW_CONFIG_KEY = 'dsh-url-router#dsh-url-router'
622
+
623
+ /**
624
+ * How much work the two observers have caused, for the report.
625
+ *
626
+ * A streaming answer mutates the page dozens of times a second; these counters are what a
627
+ * real-machine check reads to tell "coalesced" from "reconciled on every batch".
628
+ */
629
+ const workCounts = { reconciles: 0, placements: 0 }
630
+ /** Listeners told the preferences changed; the settings card redraws from them. */
631
+ const settingsListeners = new Set()
632
+ /** The parsed record, read once and kept in step with every write. */
633
+ let settingsCache
634
+
635
+ /**
636
+ * The settings record, with anything unusable replaced by its default.
637
+ * @param {unknown} raw - the parsed record, or anything that was on disk.
638
+ * @returns {{selectAllOnFocus: boolean, hoverReveal: boolean, recentLimit: number}} the record.
639
+ */
640
+ function normaliseSettings(raw) {
641
+ const source = raw !== null && typeof raw === 'object' ? raw : {}
642
+ const limit = Number(source.recentLimit)
643
+ return {
644
+ selectAllOnFocus: typeof source.selectAllOnFocus === 'boolean' ? source.selectAllOnFocus : DEFAULT_SETTINGS.selectAllOnFocus,
645
+ hoverReveal: typeof source.hoverReveal === 'boolean' ? source.hoverReveal : DEFAULT_SETTINGS.hoverReveal,
646
+ // A count of remembered links: whole, never negative, and with no upper bound
647
+ // the reader did not ask for.
648
+ recentLimit: Number.isFinite(limit) && limit >= 0 ? Math.floor(limit) : DEFAULT_SETTINGS.recentLimit,
649
+ }
650
+ }
651
+
652
+ /**
653
+ * Read the settings, once.
654
+ * @returns {{selectAllOnFocus: boolean, hoverReveal: boolean, recentLimit: number}} the record in force.
655
+ */
656
+ function readSettings() {
657
+ if (settingsCache !== undefined) return settingsCache
658
+ settingsCache = normaliseSettings(attemptValue(() => {
659
+ const raw = globalThis.localStorage?.getItem?.(SETTINGS_KEY)
660
+ return raw === null || raw === undefined || raw === '' ? undefined : JSON.parse(raw)
661
+ }))
662
+ return settingsCache
663
+ }
664
+
665
+ /**
666
+ * Change the settings, keep them, and tell the card.
667
+ * @param {object} patch - the fields to change.
668
+ * @returns {object} the record now in force.
669
+ */
670
+ function updateSettings(patch) {
671
+ const next = normaliseSettings({ ...readSettings(), ...(patch !== null && typeof patch === 'object' ? patch : {}) })
672
+ settingsCache = next
673
+ attemptValue(() => globalThis.localStorage?.setItem?.(SETTINGS_KEY, JSON.stringify(next)))
674
+ // A lower count takes effect now, not at the next remembered link.
675
+ trimRecent()
676
+ for (const listener of [...settingsListeners]) {
677
+ try {
678
+ listener(next)
679
+ } catch {
680
+ // A card that throws must not stop the bar from reading the new record.
681
+ }
682
+ }
683
+ return next
684
+ }
685
+
686
+ /**
687
+ * Follow the settings from something that draws them.
688
+ * @param {Function} listener - called with the record after every change.
689
+ * @returns {() => void} the unsubscribe.
690
+ */
691
+ function subscribeSettings(listener) {
692
+ settingsListeners.add(listener)
693
+ return () => settingsListeners.delete(listener)
694
+ }
695
+
696
+ /**
697
+ * One remembered link, exactly as it was stored.
698
+ * @param {unknown} raw - one entry off disk.
699
+ * @returns {{hash: string, at: number, source: string}|undefined} the entry, or undefined when unusable.
700
+ */
701
+ function recentEntryOf(raw) {
702
+ if (raw === null || typeof raw !== 'object') return undefined
703
+ const hash = typeof raw.hash === 'string' ? raw.hash.trim() : ''
704
+ if (!hash.startsWith('#') || hash === '#') return undefined
705
+ const at = Number(raw.at)
706
+ return { hash, at: Number.isFinite(at) ? at : 0, source: raw.source === 'paste' ? 'paste' : 'visit' }
707
+ }
708
+
709
+ /**
710
+ * The remembered links, newest first.
711
+ * @returns {Array<object>} the entries, in the order they are shown.
712
+ */
713
+ function readRecent() {
714
+ const raw = attemptValue(() => {
715
+ const text = globalThis.localStorage?.getItem?.(RECENT_KEY)
716
+ return text === null || text === undefined || text === '' ? [] : JSON.parse(text)
717
+ })
718
+ if (!Array.isArray(raw)) return []
719
+ return raw.map(recentEntryOf).filter(entry => entry !== undefined)
720
+ }
721
+
722
+ /**
723
+ * Put the remembered links on disk.
724
+ * @param {Array<object>} entries - the entries to keep, newest first.
725
+ */
726
+ function writeRecent(entries) {
727
+ attemptValue(() => globalThis.localStorage?.setItem?.(RECENT_KEY, JSON.stringify(entries)))
728
+ }
729
+
730
+ /**
731
+ * The screen a remembered link stands for.
732
+ *
733
+ * A link may name a position inside a screen (`turn/…`, `row/…`, a panel's inner
734
+ * page); the memory is of *screens*, so the identity stops at the surface. Without
735
+ * this every click inside a conversation would push another entry of the same
736
+ * conversation and crowd the list out.
737
+ * @param {string} hash - the fragment.
738
+ * @returns {string} the identity two links of the same screen share.
739
+ */
740
+ function recentIdentity(hash) {
741
+ const route = parseRoute(String(hash ?? ''))
742
+ if (route === undefined || route.unknown !== undefined) return String(hash ?? '')
743
+ // A command has no place to come back to, so it never enters the recent list.
744
+ if (route.kind === 'power' && powerActionOf(route.id) !== undefined) return ''
745
+ if (route.kind === 'workspace') {
746
+ const inner = innerPath(route.inner)
747
+ return inner.length >= 2 ? `${ROW_KEY_PREFIX}${inner[1]}` : `${route.kind}:${route.id}`
748
+ }
749
+ return `${route.kind}:${route.id}`
750
+ }
751
+
752
+ /**
753
+ * The screen the address bar names right now.
754
+ * @returns {string} the current fragment, or an empty string off a page.
755
+ */
756
+ function currentScreenHash() {
757
+ return typeof window?.location?.hash === 'string' ? window.location.hash : ''
758
+ }
759
+
760
+ /**
761
+ * How many entries are kept on disk.
762
+ *
763
+ * The count the reader sets is the number of links the drop-down offers — the screen in
764
+ * the address bar is not one of them, because that is where the reader already is. That
765
+ * screen is still remembered (leave it and it comes back in the list), so it may not eat
766
+ * one of the slots: one spare slot covers it, since a screen has exactly one entry.
767
+ * @returns {number} the entries to keep.
768
+ */
769
+ function recentKeep() {
770
+ const limit = readSettings().recentLimit
771
+ return limit <= 0 ? 0 : limit + 1
772
+ }
773
+
774
+ /**
775
+ * Keep the remembered links at the count in force, newest first.
776
+ * @returns {Array<object>} the entries that are kept.
777
+ */
778
+ function trimRecent() {
779
+ const keep = recentKeep()
780
+ const stored = readRecent()
781
+ const kept = keep <= 0 ? [] : stored.slice(0, keep)
782
+ if (kept.length !== stored.length) writeRecent(kept)
783
+ return kept
784
+ }
785
+
786
+ /**
787
+ * The remembered links the drop-down offers: the count in force, without the screen the
788
+ * address bar already names, newest first.
789
+ * @returns {Array<object>} the entries, in the order they are shown.
790
+ */
791
+ function recentEntries() {
792
+ const limit = readSettings().recentLimit
793
+ // The memory is off: nothing is kept and nothing is shown.
794
+ if (limit <= 0) return []
795
+ const keep = recentKeep()
796
+ const stored = readRecent()
797
+ const kept = stored.length > keep ? stored.slice(0, keep) : stored
798
+ // Reading never writes: `report()` calls this and it must stay side-effect free.
799
+ // The disc is trimmed when a link is written and when the count changes.
800
+ const current = currentScreenHash()
801
+ return kept.filter(entry => entry.hash !== current).slice(0, limit)
802
+ }
803
+
804
+ /**
805
+ * Remember a link: a screen the view just moved to, or a link pasted into the bar.
806
+ * @param {string} hash - the fragment.
807
+ * @param {string} [source] - `paste` for a pasted link, `visit` (the default) otherwise.
808
+ */
809
+ function rememberRecent(hash, source) {
810
+ const text = typeof hash === 'string' ? hash.trim() : ''
811
+ if (!text.startsWith('#') || text === '#') return
812
+ // Nothing is remembered while the memory is off, so turning it on later starts
813
+ // from an empty list instead of resurrecting links from before.
814
+ const limit = readSettings().recentLimit
815
+ if (limit <= 0) return
816
+ const identity = recentIdentity(text)
817
+ const entries = readRecent().filter(entry => recentIdentity(entry.hash) !== identity)
818
+ entries.unshift({ hash: text, at: attemptValue(() => Date.now(), 0), source: source === 'paste' ? 'paste' : 'visit' })
819
+ writeRecent(entries.slice(0, recentKeep()))
820
+ }
821
+
822
+ /** Forget every remembered link. */
823
+ function clearRecent() {
824
+ writeRecent([])
825
+ }
826
+
827
+ /**
828
+ * The conversation title the sidebar shows for an id, when it is rendered.
829
+ * @param {string} sessionId - the conversation id.
830
+ * @returns {string} the title, or an empty string when the row is not on screen.
831
+ */
832
+ function sessionTitles() {
833
+ const titles = new Map()
834
+ const rows = attemptValue(() => window.document?.querySelectorAll?.('[data-row-key]')) ?? []
835
+ for (const row of rows) {
836
+ const key = typeof row?.getAttribute === 'function' ? row.getAttribute('data-row-key') : null
837
+ if (typeof key !== 'string' || !key.startsWith(ROW_KEY_PREFIX)) continue
838
+ const text = String(row.textContent ?? '').trim().replace(/\s+/gu, ' ').slice(0, 80)
839
+ if (text !== '') titles.set(key, text)
840
+ }
841
+ return titles
842
+ }
843
+
844
+ /**
845
+ * The title of one conversation, read from a map made once for the whole list.
846
+ * @param {string} sessionId - the conversation id.
847
+ * @param {Map<string, string>} [titles] - the row titles, when the caller already has them.
848
+ * @returns {string} the title, or an empty string when the row is not on screen.
849
+ */
850
+ function sessionTitleOf(sessionId, titles) {
851
+ const key = `${ROW_KEY_PREFIX}${sessionId}`
852
+ if (titles !== undefined) return titles.get(key) ?? ''
853
+ for (const row of attemptValue(() => window.document?.querySelectorAll?.('[data-row-key]')) ?? []) {
854
+ if (typeof row?.getAttribute !== 'function' || row.getAttribute('data-row-key') !== key) continue
855
+ return String(row.textContent ?? '').trim().replace(/\s+/gu, ' ').slice(0, 80)
856
+ }
857
+ return ''
858
+ }
859
+
860
+ /**
861
+ * The words a remembered link reads as: the conversation's own title when the
862
+ * sidebar has it, a settings section's label, or nothing (the fragment then says it).
863
+ * @param {string} hash - the fragment.
864
+ * @returns {string} the label, or an empty string.
865
+ */
866
+ function recentLabel(hash, titles) {
867
+ const route = parseRoute(String(hash ?? ''))
868
+ if (route === undefined || route.unknown !== undefined) return ''
869
+ if (route.kind === 'session') return sessionTitleOf(route.id, titles)
870
+ if (route.kind === 'workspace') {
871
+ const inner = innerPath(route.inner)
872
+ return inner.length >= 2 ? sessionTitleOf(inner[1], titles) : route.id
873
+ }
874
+ // A settings section's identity is its own display name.
875
+ if (route.kind === 'settings') return route.id
876
+ return ''
877
+ }
878
+
366
879
  /**
367
880
  * The conversation view ids the registry publishes, in registration order — the same order
368
881
  * the header renders its tabs in, which is what lets a tab position map back to an id.
@@ -387,6 +900,30 @@ window.__ModuleLoader__.load({
387
900
  return ids.length === 0 ? undefined : ids
388
901
  }
389
902
 
903
+ /**
904
+ * The conversation views the slot contributes, with the labels the header prints.
905
+ *
906
+ * The header renders only some of these tabs (the rest go into the "…" menu), so a tab's
907
+ * *text* is the only reliable identity a reader can be mapped by — the registry ids alone
908
+ * can only be matched positionally, and only while every view is rendered.
909
+ * @param {object} slots - the slot service.
910
+ * @returns {Array<{id: string, label: string}>} contributions in registration order.
911
+ */
912
+ function conversationViewLabelsOf(slots) {
913
+ const rows = attemptValue(() => slots?.entries?.('conversation.view'))
914
+ if (!Array.isArray(rows)) return []
915
+ const out = []
916
+ for (const row of rows) {
917
+ const options = row === null || typeof row !== 'object' ? undefined : row.options
918
+ const id = options?.id
919
+ if (typeof id !== 'string' || id === '') continue
920
+ let label = options?.label
921
+ if (typeof label === 'function') label = attemptValue(() => label())
922
+ out.push({ id, label: typeof label === 'string' ? label.trim() : '' })
923
+ }
924
+ return out
925
+ }
926
+
390
927
  /**
391
928
  * Read a value, or undefined when reading it throws.
392
929
  * @param {Function} read - the reader.
@@ -519,6 +1056,72 @@ window.__ModuleLoader__.load({
519
1056
  warn(`${TAG} ${message}`)
520
1057
  }
521
1058
 
1059
+ /** Notes already given, per check: a compatibility problem is worth saying once. */
1060
+ const compatNotes = new Set()
1061
+ /** At most one compatibility sweep per this interval: each check reads the page. */
1062
+ const COMPAT_EVERY_MS = 1000
1063
+ /** The phrase every compatibility note carries, so a log can be searched for it. */
1064
+ const COMPAT_MESSAGE = '与当前 DSH 版本不兼容'
1065
+
1066
+ /**
1067
+ * The screens whose hooks this router reads, and what it expects to find on them.
1068
+ *
1069
+ * Each check pairs a *weak* signal that the screen is up (something this build still
1070
+ * recognises) with a *strong* hook the router actually needs. A screen that is up while
1071
+ * its strong hook is gone is exactly the silent breakage this plugin cannot afford: links
1072
+ * stop naming that surface and nothing says so. The checks only report — they never change
1073
+ * what the router does.
1074
+ * @param {object} document - the page.
1075
+ * @param {object} context - what the router knows: the active panel, conversation count and registered views.
1076
+ * @returns {Array<{name: string, expected: string, found: string}>} the problems, empty when healthy.
1077
+ */
1078
+ function compatibilityProblems(document, context) {
1079
+ const problems = []
1080
+ if (document === undefined || document === null || typeof document.querySelectorAll !== 'function') return problems
1081
+ const count = selector => attemptValue(() => document.querySelectorAll?.(selector)?.length) ?? 0
1082
+ // 1) The settings overlay. A modal carrying a nav of several buttons is this app's
1083
+ // settings page; when neither official marker is on it, the router is reading a page it
1084
+ // cannot name (this is the marker that already moved once).
1085
+ for (const dialog of attemptValue(() => [...(document.querySelectorAll('[role="dialog"]') ?? [])]) ?? []) {
1086
+ if (attemptValue(() => dialog.getAttribute?.('aria-modal')) !== 'true') continue
1087
+ let navButtons = 0
1088
+ for (const nav of attemptValue(() => [...(dialog.querySelectorAll('nav') ?? [])]) ?? []) {
1089
+ navButtons += attemptValue(() => nav.querySelectorAll?.('button')?.length) ?? 0
1090
+ }
1091
+ if (navButtons < 3) continue
1092
+ if (settingsOverlayOf(document) !== undefined) continue
1093
+ problems.push({ name: 'settings-overlay', expected: 'data-shortcut-modal="settings"', found: `a modal with a ${navButtons}-button nav and no settings marker` })
1094
+ break
1095
+ }
1096
+ // 2) The plugin manager. Its panel root is stable; its row/detail hooks are what links
1097
+ // are built from.
1098
+ if (count('[data-plugin-panel]') > 0) {
1099
+ const hooks = count('[data-plugin-package]') + count('[data-plugin-item]') + count('[data-plugin-row]')
1100
+ + count('[data-plugin-detail]') + count('[data-plugin-item-detail]') + count('[data-plugin-row-detail]')
1101
+ if (hooks === 0) {
1102
+ 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' })
1103
+ }
1104
+ }
1105
+ // 3) The automation list's status filter: three aria-pressed buttons in a role=group.
1106
+ if (context?.activePanelId === 'schedules' || count('[data-plugin-panel="schedules"]') > 0) {
1107
+ const groups = attemptValue(() => [...(document.querySelectorAll('[role="group"]') ?? [])]) ?? []
1108
+ if (groups.length > 0 && scheduleFilterOf(document) === undefined) {
1109
+ problems.push({ name: 'schedules-filter', expected: 'a [role="group"] holding three aria-pressed buttons', found: `${groups.length} group(s), none matching` })
1110
+ }
1111
+ }
1112
+ // 4) The conversation header's view tabs. Only when the registry really publishes more
1113
+ // than one view: a composition with a single view has no tabs to find.
1114
+ if ((context?.views ?? 0) >= 2 && count('[data-chat-turn]') > 0 && count('[data-conversation-tabs]') === 0) {
1115
+ problems.push({ name: 'conversation-tabs', expected: `[${CONVERSATION_TABS_ATTRIBUTE}] in the header`, found: 'chat turns with no header container' })
1116
+ }
1117
+ // 5) The sidebar's conversation rows. The settings entry is the "a real sidebar is up"
1118
+ // signal, and the catalog says there is something to list.
1119
+ if (count(`[data-slot="${SETTINGS_TRIGGER_SLOT}"]`) > 0 && (context?.sessions ?? 0) > 0 && count('[data-row-key^="session:"]') === 0) {
1120
+ problems.push({ name: 'sidebar-rows', expected: '[data-row-key="session:<id>"] rows', found: `a sidebar with ${context.sessions} conversation(s) and no rows` })
1121
+ }
1122
+ return problems
1123
+ }
1124
+
522
1125
  function activePaneTabOf(document, registry) {
523
1126
  // Scoped to the right bar's own root: the same dockkit attributes dress the main area
524
1127
  // too, and reading them unscoped reports the main area's panes, not this bar's tabs.
@@ -557,7 +1160,11 @@ window.__ModuleLoader__.load({
557
1160
  if (named !== undefined) {
558
1161
  const wanted = flatLabel(named).toLowerCase()
559
1162
  const match = Array.isArray(registry) ? registry.find(row => typeof row.label === 'string' && flatLabel(row.label).toLowerCase() === wanted) : undefined
560
- return match === undefined ? named : match.kind
1163
+ if (match !== undefined) return match.kind
1164
+ // With a registry to check against, a label that matches nothing is a *title* — the
1165
+ // open file's name, a terminal's own name — never a kind. Without a registry the
1166
+ // label is the only name there is, so it is taken as the kind.
1167
+ return Array.isArray(registry) && registry.length > 0 ? undefined : named
561
1168
  }
562
1169
  // 2) an address-shaped id, for a future build that puts one there
563
1170
  const fromTab = kindOfTabId(attemptValue(() => chosen.getAttribute?.('data-dockkit-tab')))
@@ -571,6 +1178,329 @@ window.__ModuleLoader__.load({
571
1178
  return undefined
572
1179
  }
573
1180
 
1181
+ /**
1182
+ * Whether the right-hand bar is collapsed.
1183
+ *
1184
+ * A collapsed bar keeps its tab strip mounted, so "a tab is selected in the strip" is not
1185
+ * the same question as "that pane is on screen" — measured: after closing the bar the
1186
+ * address bar kept naming its last pane, which was no longer anywhere.
1187
+ * @param {object} document - the page.
1188
+ * @returns {boolean} whether the bar is collapsed.
1189
+ */
1190
+ function rightBarCollapsedOf(document) {
1191
+ const marker = attemptValue(() => document?.querySelector?.(`[${RIGHT_BAR_COLLAPSED_ATTRIBUTE}]`))
1192
+ if (marker === null || marker === undefined) return false
1193
+ const value = attemptValue(() => marker.getAttribute?.(RIGHT_BAR_COLLAPSED_ATTRIBUTE))
1194
+ return value !== 'false'
1195
+ }
1196
+
1197
+ /**
1198
+ * The status filter the automation-task list is showing.
1199
+ *
1200
+ * Read from the group's own buttons: the pressed one's *position* among the three is the
1201
+ * filter (see `SCHEDULE_FILTERS`). A group is the manager's when it holds exactly three
1202
+ * buttons, all of them pressed-state buttons, and it is not the right bar's own.
1203
+ * @param {object} document - the page.
1204
+ * @returns {string|undefined} `all`, `active` or `inactive`.
1205
+ */
1206
+ function scheduleFilterOf(document) {
1207
+ const groups = attemptValue(() => [...(document?.querySelectorAll?.('[role="group"]') ?? [])]) ?? []
1208
+ for (const group of groups) {
1209
+ const inside = attemptValue(() => group?.closest?.(`[${RIGHT_BAR_ATTRIBUTE}]`))
1210
+ if (inside !== null && inside !== undefined) continue
1211
+ const buttons = attemptValue(() => [...(group?.querySelectorAll?.('button') ?? [])]) ?? []
1212
+ if (buttons.length !== SCHEDULE_FILTERS.length) continue
1213
+ if (!buttons.every(button => attemptValue(() => button.getAttribute?.('aria-pressed')) !== null)) continue
1214
+ const pressed = buttons.findIndex(button => attemptValue(() => button.getAttribute?.('aria-pressed')) === 'true')
1215
+ if (pressed < 0) continue
1216
+ return SCHEDULE_FILTERS[pressed]
1217
+ }
1218
+ return undefined
1219
+ }
1220
+
1221
+ /**
1222
+ * The automation task the manager has open, and the detail tab it shows.
1223
+ *
1224
+ * The row states both facts: `aria-expanded` marks the open one, and its
1225
+ * `aria-describedby` ends with the task id (`<useId>-metadata-<taskId>`). The detail's
1226
+ * tabs carry their own value (`data-detail-tab="rule" | "records"`).
1227
+ * @param {object} document - the page.
1228
+ * @returns {{id: string, tab: string}|undefined} the open task, or undefined when none is.
1229
+ */
1230
+ function scheduleTaskOf(document) {
1231
+ const buttons = attemptValue(() => [...(document?.querySelectorAll?.('button') ?? [])]) ?? []
1232
+ for (const button of buttons) {
1233
+ const described = attemptValue(() => button.getAttribute?.('aria-describedby'))
1234
+ if (typeof described !== 'string' || described === '') continue
1235
+ const match = /-metadata-(.+)$/u.exec(described)
1236
+ if (match === null) continue
1237
+ if (attemptValue(() => button.getAttribute?.('aria-expanded')) !== 'true') continue
1238
+ const tabs = attemptValue(() => [...(document?.querySelectorAll?.(`[${SCHEDULE_TAB_ATTRIBUTE}]`) ?? [])]) ?? []
1239
+ const chosen = tabs.find(tab => attemptValue(() => tab.getAttribute?.('aria-selected')) === 'true')
1240
+ const tab = attemptValue(() => chosen?.getAttribute?.(SCHEDULE_TAB_ATTRIBUTE))
1241
+ return { id: match[1], tab: typeof tab === 'string' && tab !== '' ? tab : 'rule' }
1242
+ }
1243
+ return undefined
1244
+ }
1245
+
1246
+ /**
1247
+ * The parts of a settings inner path.
1248
+ *
1249
+ * A settings route may name a tab (`[<tab>]`) and, one level deeper, the model-provider
1250
+ * card whose editor is open (`[<tab>?/provider/<id>]`). Keeping one reader for both shapes
1251
+ * is what lets the link's landing rule and the drive agree.
1252
+ * @param {string|Array<string>} inner - the inner path.
1253
+ * @returns {{tab: string|undefined, provider: string|undefined}} the parts.
1254
+ */
1255
+ /** A row whose state lives in a styling class: the class has to end in one of these words. */
1256
+ const ROW_ON_CLASS = /(?:On|Active|Selected|Current|Checked)$/
1257
+
1258
+ /**
1259
+ * The label a control is known by: its own text, without a nested badge.
1260
+ *
1261
+ * A chip can read `宠物 12`, where `12` is a count inside a nested element (measured on the
1262
+ * market's filter row). A count is not identity — it moves — so only the control's own text
1263
+ * names it, and a link stays valid when the number changes.
1264
+ * @param {object} control - the control.
1265
+ * @returns {string} its label.
1266
+ */
1267
+ function controlLabelOf(control) {
1268
+ let own = ''
1269
+ for (const node of control?.childNodes ?? []) {
1270
+ if (node?.nodeType === 3) own += String(node.textContent ?? '')
1271
+ }
1272
+ const text = own.trim() !== '' ? own : String(control?.textContent ?? '')
1273
+ return text.trim().replace(/\s+/g, ' ')
1274
+ }
1275
+
1276
+ /**
1277
+ * Whether one settings row's control is the one selected.
1278
+ * @param {object} row - a row from `settingsRowsOf`.
1279
+ * @param {object} control - a control of that row.
1280
+ * @returns {boolean} whether it is on.
1281
+ */
1282
+ function controlIsOn(row, control) {
1283
+ if (row.marker === 'class') return typeof control.className === 'string' && ROW_ON_CLASS.test(control.className)
1284
+ return control.getAttribute(row.marker) === 'true'
1285
+ }
1286
+
1287
+ /**
1288
+ * The rows of selectable controls inside the settings overlay, in document order.
1289
+ *
1290
+ * The overlay is deeper than one tab row: a section owns tabs, a tab may own another row
1291
+ * (measured 2026-10-28: `Web 扩展 / 创意工坊` holds `编辑推荐 / 皮肤 / 宠物 / 插件 / 预设`),
1292
+ * and that row may hold filters of its own. A row is a parent whose children all carry the
1293
+ * same marker — `aria-selected` for tabs, `aria-pressed` for toggles — with exactly one of
1294
+ * them true, and the row's place is its first control's place in the document.
1295
+ * @param {object} overlay - the settings overlay.
1296
+ * @returns {Array<{node: object, marker: string}>} the rows.
1297
+ */
1298
+ function settingsRowsOf(overlay) {
1299
+ if (overlay === null || overlay === undefined || typeof overlay.querySelectorAll !== 'function') return []
1300
+ const rows = []
1301
+ const seen = new Set()
1302
+ for (const marker of ['aria-selected', 'aria-pressed']) {
1303
+ // A query result is a NodeList, not an array: spread it *inside* the attempt, or the
1304
+ // guard below silently skips the whole scan in a real DOM (measured 2026-10-28).
1305
+ const nodes = attemptValue(() => [...overlay.querySelectorAll(`[${marker}]`)])
1306
+ if (!Array.isArray(nodes)) continue
1307
+ for (const node of nodes) {
1308
+ const parent = node?.parentElement
1309
+ if (parent === null || parent === undefined || seen.has(parent)) continue
1310
+ const children = attemptValue(() => [...parent.children]) ?? []
1311
+ const marked = children.filter(child => typeof child.getAttribute === 'function' && child.getAttribute(marker) !== null)
1312
+ if (marked.length < 2) continue
1313
+ const on = marked.find(child => child.getAttribute(marker) === 'true')
1314
+ if (on === undefined) continue
1315
+ if (controlLabelOf(on) === '') continue
1316
+ seen.add(parent)
1317
+ rows.push({ node: parent, marker })
1318
+ }
1319
+ }
1320
+ // A row with no ARIA at all: some plugins only toggle a styling class (measured on the
1321
+ // market's filter chips: `…_filterChipOn`, no aria-* anywhere). It is a last resort and it
1322
+ // is deliberately narrow — the class has to *end* in one of these words, exactly one
1323
+ // sibling may carry it, and the row must have at least two controls.
1324
+ const buttons = attemptValue(() => [...overlay.querySelectorAll('button')])
1325
+ if (Array.isArray(buttons)) {
1326
+ for (const node of buttons) {
1327
+ const parent = node?.parentElement
1328
+ if (parent === null || parent === undefined || seen.has(parent)) continue
1329
+ const children = (attemptValue(() => [...parent.children]) ?? []).filter(child => typeof child.className === 'string')
1330
+ if (children.length < 2) continue
1331
+ const marked = children.filter(child => ROW_ON_CLASS.test(child.className))
1332
+ if (marked.length !== 1) continue
1333
+ if (controlLabelOf(marked[0]) === '') continue
1334
+ seen.add(parent)
1335
+ rows.push({ node: parent, marker: 'class' })
1336
+ }
1337
+ }
1338
+ return rows.sort((a, b) => {
1339
+ if (a.node === b.node) return 0
1340
+ const position = attemptValue(() => a.node.compareDocumentPosition?.(b.node)) ?? 0
1341
+ // 4 is DOCUMENT_POSITION_FOLLOWING: `a` comes first.
1342
+ return (position & 4) !== 0 ? -1 : 1
1343
+ })
1344
+ }
1345
+
1346
+ /**
1347
+ * What each settings row has selected, in row order — the path a link can name.
1348
+ * @param {object} overlay - the settings overlay.
1349
+ * @returns {string[]} the labels.
1350
+ */
1351
+ function settingsSelectedLabelsOf(overlay) {
1352
+ const out = []
1353
+ for (const row of settingsRowsOf(overlay)) {
1354
+ const on = [...row.node.children].find(child => controlIsOn(row, child))
1355
+ const label = on === undefined ? '' : controlLabelOf(on)
1356
+ if (label !== '') out.push(label)
1357
+ }
1358
+ return out
1359
+ }
1360
+
1361
+ /**
1362
+ * The control inside one settings row whose label is the one a link names.
1363
+ * @param {object} row - a row from `settingsRowsOf`.
1364
+ * @param {string} label - the wanted label.
1365
+ * @returns {object|undefined} the control.
1366
+ */
1367
+ function settingsControlOf(row, label) {
1368
+ const wanted = label.trim().toLowerCase()
1369
+ for (const child of attemptValue(() => [...row.node.children]) ?? []) {
1370
+ if (typeof child.getAttribute !== 'function') continue
1371
+ if (row.marker !== 'class' && child.getAttribute(row.marker) === null) continue
1372
+ if (controlLabelOf(child).toLowerCase() !== wanted) continue
1373
+ return child
1374
+ }
1375
+ return undefined
1376
+ }
1377
+
1378
+ function settingsPartsOf(inner) {
1379
+ const parts = innerPath(inner)
1380
+ const at = parts.indexOf(PROVIDER_SEGMENT)
1381
+ const levels = at < 0 ? parts : parts.slice(0, at)
1382
+ return { levels, provider: at < 0 ? undefined : parts[at + 1] }
1383
+ }
1384
+
1385
+ /**
1386
+ * The model-provider card that is open in the settings overlay, when one is.
1387
+ *
1388
+ * The models section renders a card's editor inline and publishes no data hook for it: the
1389
+ * card is an `li` that suddenly holds a text field, and its identity lives in an action's
1390
+ * accessible name, which the official copy builds as `<label> <displayName>
1391
+ * (<provider>)`. Only the parenthesised provider id is language-independent, so that is
1392
+ * what a link carries.
1393
+ * @param {object} document - the page.
1394
+ * @returns {string|undefined} the open provider id.
1395
+ */
1396
+ function openSettingsProviderId(document) {
1397
+ const overlay = settingsOverlayOf(document)
1398
+ if (overlay === null || overlay === undefined || typeof overlay.querySelectorAll !== 'function') return undefined
1399
+ for (const card of overlay.querySelectorAll('li')) {
1400
+ if (typeof card?.querySelector !== 'function') continue
1401
+ if (card.querySelector('input') === null && card.querySelector('textarea') === null) continue
1402
+ for (const button of card.querySelectorAll('button')) {
1403
+ const label = attemptValue(() => button.getAttribute?.('aria-label'))
1404
+ if (typeof label !== 'string' || label === '') continue
1405
+ const match = /\(([^()]*)\)\s*$/u.exec(label)
1406
+ if (match === null || match[1].trim() === '') continue
1407
+ return match[1].trim()
1408
+ }
1409
+ }
1410
+ return undefined
1411
+ }
1412
+
1413
+ /**
1414
+ * Open one model-provider card's editor in the settings overlay.
1415
+ * @param {string} id - the provider id the link named.
1416
+ */
1417
+ function clickSettingsProvider(id) {
1418
+ const wanted = String(id ?? '').trim().toLowerCase()
1419
+ if (wanted === '') return
1420
+ const overlay = settingsOverlayOf(window.document)
1421
+ const scope = overlay ?? window.document
1422
+ const buttons = attemptValue(() => [...(scope?.querySelectorAll?.('button') ?? [])]) ?? []
1423
+ for (const button of buttons) {
1424
+ const label = attemptValue(() => button.getAttribute?.('aria-label'))
1425
+ if (typeof label !== 'string') continue
1426
+ const match = /\(([^()]*)\)\s*$/u.exec(label)
1427
+ if (match === null || match[1].trim().toLowerCase() !== wanted) continue
1428
+ // Already open: the next press would close it, and the retry loop would then flip the
1429
+ // card open and shut until the link expired.
1430
+ const card = attemptValue(() => button.closest?.('li'))
1431
+ if (card !== null && card !== undefined) {
1432
+ const open = attemptValue(() => card.querySelector?.('input') !== null || card.querySelector?.('textarea') !== null)
1433
+ if (open === true) return
1434
+ }
1435
+ // The edit control comes before the delete control in a card's actions.
1436
+ if (typeof button.click === 'function') attemptValue(() => button.click())
1437
+ return
1438
+ }
1439
+ }
1440
+
1441
+ /**
1442
+ * Press one status filter of the automation list.
1443
+ * @param {string} token - `all`, `active` or `inactive`.
1444
+ */
1445
+ function clickScheduleFilter(token) {
1446
+ const order = SCHEDULE_FILTERS.indexOf(String(token ?? ''))
1447
+ if (order < 0) return
1448
+ const document = window.document
1449
+ const groups = attemptValue(() => [...(document?.querySelectorAll?.('[role="group"]') ?? [])]) ?? []
1450
+ for (const group of groups) {
1451
+ const inside = attemptValue(() => group?.closest?.(`[${RIGHT_BAR_ATTRIBUTE}]`))
1452
+ if (inside !== null && inside !== undefined) continue
1453
+ const buttons = attemptValue(() => [...(group?.querySelectorAll?.('button') ?? [])]) ?? []
1454
+ if (buttons.length !== SCHEDULE_FILTERS.length) continue
1455
+ if (!buttons.every(button => attemptValue(() => button.getAttribute?.('aria-pressed')) !== null)) continue
1456
+ const button = buttons[order]
1457
+ if (typeof button?.click === 'function') attemptValue(() => button.click())
1458
+ return
1459
+ }
1460
+ }
1461
+
1462
+ /**
1463
+ * Open one automation task's detail, and the detail tab it names.
1464
+ * @param {string} id - the task id.
1465
+ * @param {string} [tab] - `rule` or `records`.
1466
+ */
1467
+ function clickScheduleTask(id, tab) {
1468
+ const wanted = String(id ?? '')
1469
+ if (wanted === '') return
1470
+ const document = window.document
1471
+ const buttons = attemptValue(() => [...(document?.querySelectorAll?.('button') ?? [])]) ?? []
1472
+ let found = false
1473
+ for (const button of buttons) {
1474
+ const described = attemptValue(() => button.getAttribute?.('aria-describedby'))
1475
+ if (typeof described !== 'string' || !described.endsWith(`-metadata-${wanted}`)) continue
1476
+ found = true
1477
+ if (typeof button.click === 'function') attemptValue(() => button.click())
1478
+ break
1479
+ }
1480
+ // A filter hides the rows it does not match, so a link to a task outside it has nothing
1481
+ // to open. The list is widened to "all" and the next retry finds the row.
1482
+ if (!found) clickScheduleFilter(SCHEDULE_FILTERS[0])
1483
+ if (typeof tab !== 'string' || tab === '' || tab === 'rule') return
1484
+ const tabs = attemptValue(() => [...(document?.querySelectorAll?.(`[${SCHEDULE_TAB_ATTRIBUTE}]`) ?? [])]) ?? []
1485
+ const chosen = tabs.find(candidate => attemptValue(() => candidate.getAttribute?.(SCHEDULE_TAB_ATTRIBUTE)) === tab)
1486
+ if (typeof chosen?.click === 'function') attemptValue(() => chosen.click())
1487
+ }
1488
+
1489
+ /**
1490
+ * `filter/all` names the state a bare automation list is already in, so a link carrying it
1491
+ * is read as the bare panel: the router never writes it, and dropping it here keeps the
1492
+ * link comparable (it lands instead of waiting for a page that will never say `all`).
1493
+ * @param {string} kind - the route kind.
1494
+ * @param {string} id - the route identity.
1495
+ * @param {Array<string>} parts - the inner path segments.
1496
+ * @returns {Array<string>} the segments to use.
1497
+ */
1498
+ function normalisePanelInner(kind, id, parts) {
1499
+ if (kind !== 'panel' || id !== SCHEDULES_PANEL_ID) return parts
1500
+ if (parts.length >= 2 && parts[0] === FILTER_SEGMENT && parts[1] === SCHEDULE_FILTERS[0]) return parts.slice(2)
1501
+ return parts
1502
+ }
1503
+
574
1504
  /**
575
1505
  * Read a route out of a location fragment.
576
1506
  * @param {string} hash - the raw `window.location.hash`, including the leading `#`.
@@ -608,7 +1538,7 @@ window.__ModuleLoader__.load({
608
1538
  const kind = ROUTE_ALIASES[match[1]]
609
1539
  if (kind === undefined) return { unknown: match[1] }
610
1540
  // A modal surface needs no identity: `#/settings` is the whole route.
611
- if (match[2] === undefined) return kind === 'settings' ? { kind, id: '' } : undefined
1541
+ if (match[2] === undefined) return kind === 'settings' || kind === 'power' ? { kind, id: '' } : undefined
612
1542
  // A conversation can be named inside its workspace — the shape the sidebar itself shows,
613
1543
  // and the only way a link says *where* a conversation lives.
614
1544
  if (kind === 'workspace') {
@@ -635,9 +1565,9 @@ window.__ModuleLoader__.load({
635
1565
  }
636
1566
  const segments = match[2].replace(/\/+$/, '').split('/')
637
1567
  const head = segments.shift() ?? ''
638
- if (head === '') return kind === 'settings' ? { kind, id: '' } : undefined
1568
+ if (head === '') return kind === 'settings' || kind === 'power' ? { kind, id: '' } : undefined
639
1569
  const id = decodePart(head).trim()
640
- if (id === '') return kind === 'settings' ? { kind, id: '' } : undefined
1570
+ if (id === '') return kind === 'settings' || kind === 'power' ? { kind, id: '' } : undefined
641
1571
  // A settings section has no stable id in the DOM (measured: the nav item carries only
642
1572
  // aria-current), so its identity is its *label* — the string the user actually sees and
643
1573
  // clicks. Round-tripping the label keeps the link self-consistent; a locale change
@@ -650,7 +1580,7 @@ window.__ModuleLoader__.load({
650
1580
  }
651
1581
  // A panel may have an inner page (a plugin's detail view, for instance): the
652
1582
  // rest of the path names it, per panel.
653
- const inner = segments.map(decodePart).filter(segment => segment.trim() !== '')
1583
+ const inner = normalisePanelInner(kind, id, segments.map(decodePart).filter(segment => segment.trim() !== ''))
654
1584
  return { kind, id, inner: inner.length === 0 ? undefined : inner }
655
1585
  }
656
1586
 
@@ -663,6 +1593,10 @@ window.__ModuleLoader__.load({
663
1593
  function routeHash(kind, id, inner) {
664
1594
  // A modal surface has no identity: `#/settings`, not `#/settings/`.
665
1595
  if (kind === 'settings' && (id === undefined || id === '')) return '#/settings'
1596
+ // The power menu has no identity of its own — there is one of it — but an *action* does:
1597
+ // dropping it here would make the bar compare a typed `#/power/restart` against a bare
1598
+ // `#/power` and call a command it had already sent "打不开" (measured 2026-10-29).
1599
+ if (kind === 'power') return powerActionOf(id) === undefined ? '#/power' : `#/power/${encodeURIComponent(id)}`
666
1600
  if (kind === 'workspace') {
667
1601
  const rest = innerPath(inner)
668
1602
  const tail = rest.length === 0 ? '' : `/${rest.map(encodeURIComponent).join('/')}`
@@ -711,6 +1645,27 @@ window.__ModuleLoader__.load({
711
1645
  return rowId === '' ? undefined : { packageName: undefined, rowId }
712
1646
  }
713
1647
 
1648
+ /**
1649
+ * The control that opens the page a plugin row describes.
1650
+ *
1651
+ * The manager puts the row's own link first and its enable switch beside it, and both
1652
+ * are buttons. Clicking the switch on behalf of a pasted link would silently turn a
1653
+ * plugin off (measured: a component page link did exactly that while the row had no
1654
+ * configuration), so the switch is never picked. A row whose only button is the switch
1655
+ * has no page to open, and its link is left to expire with the usual diagnostic.
1656
+ * @param {object} element - the row, card or list item.
1657
+ * @returns {object|undefined} the control to click.
1658
+ */
1659
+ function openControlOf(element) {
1660
+ const buttons = attemptValue(() => [...(element?.querySelectorAll?.('button') ?? [])]) ?? []
1661
+ for (const button of buttons) {
1662
+ const role = typeof button?.getAttribute === 'function' ? button.getAttribute('role') : null
1663
+ if (role === 'switch') continue
1664
+ if (typeof button?.click === 'function') return button
1665
+ }
1666
+ return undefined
1667
+ }
1668
+
714
1669
  function innerPath(value) {
715
1670
  const parts = Array.isArray(value) ? value : String(value ?? '').split('/')
716
1671
  return parts.map(part => String(part).trim()).filter(part => part !== '')
@@ -780,7 +1735,7 @@ window.__ModuleLoader__.load({
780
1735
  let panelScope
781
1736
  /** The turn the user last clicked inside a conversation, with its session. */
782
1737
  /** What the report can say about this run: fragments it refused, kinds it left alone. */
783
- const observed = { foreignFragment: undefined, ignored: [], warnings: [] }
1738
+ const observed = { foreignFragment: undefined, ignored: [], warnings: [], compat: [] }
784
1739
  let rowAnchor
785
1740
  /** The pane kind this claim already opened (one drive per claim, see the pane branch). */
786
1741
  let paneOpenedFor
@@ -792,6 +1747,8 @@ window.__ModuleLoader__.load({
792
1747
  let innerAttempts = 0
793
1748
  /** Whether the current link already asked for a modal standing in front of it to go away. */
794
1749
  let overlayDismissed = false
1750
+ /** When the compatibility checks last read the page (`-Infinity`: never swept). */
1751
+ let lastCompatSweep = -Infinity
795
1752
  /** The view state observed on the previous notification. */
796
1753
  let seen = 'none'
797
1754
  let cancelTimer
@@ -809,6 +1766,10 @@ window.__ModuleLoader__.load({
809
1766
  panelAttempts = 0
810
1767
  innerAttempts = 0
811
1768
  paneOpenedFor = undefined
1769
+ powerActionSent = false
1770
+ powerActionAttempts = 0
1771
+ linkedTyped = false
1772
+ attemptValue(() => host.resetSettingsDrive?.())
812
1773
  dropTimer()
813
1774
  }
814
1775
  /**
@@ -816,27 +1777,41 @@ window.__ModuleLoader__.load({
816
1777
  * conversation behind it, because the panel is what fills the screen.
817
1778
  */
818
1779
  const canonical = () => {
1780
+ // The power menu is a popover, not a surface: it is on top of everything, it closes
1781
+ // the moment the reader touches anything else, and a link can only ever *open* it.
1782
+ // While it is up it names itself, the same way the settings overlay does.
1783
+ if (powerMenuOpenOf(window.document)) return { kind: 'power', id: '' }
819
1784
  // The settings overlay covers the main column, so while it is up it is what is on
820
1785
  // screen; when it closes the ordinary surface takes the address bar back.
821
1786
  if (settingsOverlayOf(window.document) !== undefined) {
822
1787
  const section = host.activeSettingsSection() ?? ''
823
- const tab = section === '' ? undefined : host.activeSettingsTab()
824
- return tab === undefined ? { kind: 'settings', id: section } : { kind: 'settings', id: section, inner: [tab] }
1788
+ const levels = section === '' ? [] : (attemptValue(() => host.activeSettingsLevels?.()) ?? [])
1789
+ // A model-provider card whose editor is open is a page of its own: its id rides
1790
+ // after the section (and after the tab, when the section has one).
1791
+ const provider = section === '' ? undefined : host.activeSettingsProvider()
1792
+ const inner = [...levels, ...(provider === undefined ? [] : [PROVIDER_SEGMENT, provider])]
1793
+ return inner.length === 0 ? { kind: 'settings', id: section } : { kind: 'settings', id: section, inner }
825
1794
  }
826
1795
  const panelId = host.activePanelId()
827
1796
  if (panelId !== undefined && panelId !== null) {
828
1797
  const id = String(panelId)
829
- // A right-hand pane in front is part of what is on screen, so it is named first:
830
- // it is true whether or not the panel itself reports an inner page.
831
- const paneInPanel = host.activePaneKind()
832
- if (paneInPanel !== undefined) return { kind: 'panel', id, inner: innerPath([PANE_SEGMENT, paneInPanel]) }
1798
+ // The panel's own inner page comes first: a panel that reports a page has a page on
1799
+ // screen that the reader can name, and the right-hand pane is a layer *beside* it.
1800
+ // Naming the pane first is what used to hide the automation task's own link behind
1801
+ // `pane/<kind>` (measured live).
833
1802
  const page = host.panelPage(id, panelScope)
834
- if (page === undefined) return { kind: 'panel', id, inner: undefined }
835
1803
  const segments = innerPath(page)
836
1804
  // Remember the package a page was opened under, so a bare component page (which
837
1805
  // reports only its row) can still be named fully.
838
1806
  if (id === PLUGINS_PANEL_ID && segments.length === 1) panelScope = segments[0]
839
- return { kind: 'panel', id, inner: segments }
1807
+ // A panel's own page names what the main column shows, and the right-hand pane is
1808
+ // secondary to it: the reader asked for the task (or the plugin row), not for the
1809
+ // pane standing beside it, so the pane gives way. It is named only on a panel that
1810
+ // has no page of its own. A collapsed bar keeps its tabs mounted, so it never
1811
+ // counts at all (measured: the pane segment outlived its own bar).
1812
+ const pane = segments.length > 0 || rightBarCollapsedOf(window.document) ? undefined : host.activePaneKind()
1813
+ const inner = pane === undefined ? segments : [PANE_SEGMENT, pane]
1814
+ return inner.length === 0 ? { kind: 'panel', id, inner: undefined } : { kind: 'panel', id, inner }
840
1815
  }
841
1816
  const session = host.mainViewSessionId()
842
1817
  if (session !== undefined && session !== null) {
@@ -857,7 +1832,14 @@ window.__ModuleLoader__.load({
857
1832
  // but never while a turn link is in flight: that link owns the address bar until it lands,
858
1833
  // and naming the turn here too is what made it look "landed" before it ever drove.
859
1834
  const anchoredTurn = linked === undefined && turnAnchor !== undefined && turnAnchor.session === id && turnAnchor.turn !== undefined && turnAnchor.turn !== '' ? [TURN_SEGMENT, turnAnchor.turn] : []
1835
+ // Whose subagent this conversation is (empty for an ordinary session). It sits right
1836
+ // after the id: everything after it is a position *inside* the conversation. The
1837
+ // parent is decoration — landing compares the child id — so it is written as the
1838
+ // parent's *title* when the catalog has one, and as its id otherwise.
1839
+ const subagentOf = host.subagentParentOf(id)
1840
+ const subagentLabel = subagentOf === undefined ? undefined : subagentDecorationOf(subagentOf)
860
1841
  const position = [
1842
+ ...(subagentLabel === undefined || subagentLabel === '' ? [] : [SUBAGENT_SEGMENT, subagentLabel]),
861
1843
  ...(rowAnchor !== undefined && rowAnchor.session === id ? (rowAnchor.turn === undefined || rowAnchor.turn === '' ? [ROW_SEGMENT, ...rowAnchor.parts] : [TURN_SEGMENT, rowAnchor.turn, ROW_SEGMENT, ...rowAnchor.parts]) : []),
862
1844
  ...(anchoredTurn.length > 0 && rowAnchor !== undefined && rowAnchor.session === id ? [] : anchoredTurn),
863
1845
  ...(view !== undefined && view.index !== 0 ? [VIEW_SEGMENT, view.id] : []),
@@ -865,7 +1847,8 @@ window.__ModuleLoader__.load({
865
1847
  // The right-hand pane is its own layer, not a position inside the conversation:
866
1848
  // it is named next to whatever the main column shows, never instead of it — so it
867
1849
  // is read *before* the workspace branch, or the workspace would swallow it.
868
- const pane = host.activePaneKind()
1850
+ // A collapsed bar keeps its tabs mounted, so it only counts while it is on screen.
1851
+ const pane = rightBarCollapsedOf(window.document) ? undefined : host.activePaneKind()
869
1852
  const tail = pane === undefined ? [] : [PANE_SEGMENT, pane]
870
1853
  const workspaceName = host.workspaceNameOf(id)
871
1854
  if (workspaceName !== undefined) {
@@ -895,6 +1878,11 @@ window.__ModuleLoader__.load({
895
1878
  if (route?.kind === 'session') return route.id
896
1879
  return undefined
897
1880
  }
1881
+ const subagentParentOfRoute = route => {
1882
+ const parts = innerPath(route?.inner)
1883
+ const at = parts.indexOf(SUBAGENT_SEGMENT)
1884
+ return at < 0 ? undefined : parts[at + 1]
1885
+ }
898
1886
  const viewIdOfRoute = route => {
899
1887
  const parts = innerPath(route?.inner)
900
1888
  const at = parts.indexOf(VIEW_SEGMENT)
@@ -911,6 +1899,27 @@ window.__ModuleLoader__.load({
911
1899
  // that is no longer in flight is never "the same route" as anything.
912
1900
  if (linked === undefined || linked === null) return false
913
1901
  if (routeKey(current) === routeKey(linked)) return true
1902
+ // A panel's page may be *more* specific than the link (`plugins/<pkg>/<row>` for a
1903
+ // `plugins/<pkg>` link), and the right-hand pane is a layer beside that page rather
1904
+ // than part of it — so it is compared only when the link names one. Exact comparison
1905
+ // is what left a link waiting on a page that was already open.
1906
+ if (current?.kind === 'panel' || linked?.kind === 'panel') {
1907
+ if (current?.kind !== linked.kind || current.id !== linked.id) return false
1908
+ const pageOf = route => {
1909
+ const parts = innerPath(route?.inner)
1910
+ const at = parts.indexOf(PANE_SEGMENT)
1911
+ return at < 0 ? parts : parts.slice(0, at)
1912
+ }
1913
+ // A link that names a pane asks for that pane, whatever else it names.
1914
+ const wantedPane = paneOf(linked)
1915
+ if (wantedPane !== undefined && paneOf(current) !== wantedPane) return false
1916
+ const wanted = pageOf(linked)
1917
+ if (wanted.length === 0) return true
1918
+ // A page link is satisfied once that page is on screen; a link may be wider than the
1919
+ // view (an open component satisfies a package link) but never narrower.
1920
+ const shown = pageOf(current)
1921
+ return wanted.length <= shown.length && wanted.every((part, index) => shown[index] === part)
1922
+ }
914
1923
  const currentSession = sessionIdOfRoute(current)
915
1924
  const linkedSession = sessionIdOfRoute(linked)
916
1925
  if (currentSession !== undefined && linkedSession !== undefined) {
@@ -954,8 +1963,15 @@ window.__ModuleLoader__.load({
954
1963
  if (current?.kind !== 'settings' || linked?.kind !== 'settings') return false
955
1964
  if (linked.id === '') return true
956
1965
  if (current.id !== linked.id) return false
957
- const linkedTab = innerPath(linked.inner)[0] ?? ''
958
- return linkedTab === '' || innerPath(current.inner)[0] === linkedTab
1966
+ const wanted = settingsPartsOf(linked.inner)
1967
+ const shown = settingsPartsOf(current.inner)
1968
+ // Every level a link names has to be showing; extra levels on screen are a refinement
1969
+ // of it (the same prefix rule the panel pages use, one row deeper each time).
1970
+ if (wanted.levels.length > shown.levels.length) return false
1971
+ if (!wanted.levels.every((level, index) => level === shown.levels[index])) return false
1972
+ // A provider card is a page one level deeper than its section: a link that names one
1973
+ // has to see that one open (the section alone is satisfied by any of its cards).
1974
+ return wanted.provider === undefined || wanted.provider === shown.provider
959
1975
  }
960
1976
  /** Write the fragment for the current view, leaving path and query untouched. */
961
1977
  const reflect = route => {
@@ -988,6 +2004,7 @@ window.__ModuleLoader__.load({
988
2004
  */
989
2005
  const reconcile = (source = 'boot') => {
990
2006
  if (disposed) return
2007
+ workCounts.reconciles += 1
991
2008
 
992
2009
  const current = canonical()
993
2010
  const currentKey = routeKey(current)
@@ -1000,6 +2017,22 @@ window.__ModuleLoader__.load({
1000
2017
  if (paneTabs > 0 && host.activePaneKind() === undefined) {
1001
2018
  notePaneShape(message => host.warn(message), 'pane-shape', `the right bar shows ${paneTabs} tab(s) but this build cannot read the active one; the first tab is: ${activePaneTabOf.observed ?? 'no attributes read'}`)
1002
2019
  }
2020
+ // Say each compatibility problem once, before anything else in this step: a screen whose
2021
+ // hooks moved does not throw, it just stops being nameable.
2022
+ const sweepAt = host.now()
2023
+ if (sweepAt - lastCompatSweep >= COMPAT_EVERY_MS) {
2024
+ lastCompatSweep = sweepAt
2025
+ const problems = compatibilityProblems(window.document, {
2026
+ activePanelId: host.activePanelId(),
2027
+ sessions: attemptValue(() => host.listedSessions?.()) ?? 0,
2028
+ views: attemptValue(() => host.conversationViewIds?.())?.length ?? 0,
2029
+ })
2030
+ for (const problem of problems) {
2031
+ if (observed.compat.includes(problem.name)) continue
2032
+ observed.compat.push(problem.name)
2033
+ host.warn(`${TAG} ${COMPAT_MESSAGE}:${problem.name} —— 期望 ${problem.expected},实际 ${problem.found}。这一层的链接会静默失效,请升级 DSH 或本插件`)
2034
+ }
2035
+ }
1003
2036
  // A position the previous step asked for: it is driven here, one step later, so the step
1004
2037
  // that claimed it never drives while it is still running.
1005
2038
  if (linked === undefined) {
@@ -1010,14 +2043,26 @@ window.__ModuleLoader__.load({
1010
2043
  // (and re-entry can drop the link), so every read below must use the snapshot: reading the
1011
2044
  // live binding again is how a drive turned into "Cannot read properties of undefined".
1012
2045
  const link = linked
2046
+ // The parent a subagent link names (decoration: the child alone is what opens).
2047
+ const linkParent = subagentParentOfRoute(link)
1013
2048
 
1014
2049
  if (sameRoute(current, link)) {
1015
2050
  dropRetry()
1016
2051
  // The link has landed: it stops being authoritative right here, so the user
1017
2052
  // keeps owning the address bar afterwards (walking deeper inside a panel
1018
2053
  // updates the URL instead of being pulled back to the link).
2054
+ //
2055
+ // A turn link that was already satisfied (its seat is on screen) still names where
2056
+ // the reader is, so it becomes the anchor: writing the plain view instead is how a
2057
+ // pasted `turn/<n>` link used to lose its turn the instant it landed.
2058
+ const parts = innerPath(link.inner)
2059
+ const at = parts.indexOf(TURN_SEGMENT)
2060
+ if (at >= 0 && parts[at + 1] !== undefined && parts[at + 1] !== '') {
2061
+ const session = link.kind === 'workspace' ? parts[1] : link.id
2062
+ turnAnchor = { session, turn: parts[at + 1] }
2063
+ }
1019
2064
  dropLink()
1020
- reflect(current)
2065
+ reflect(canonical())
1021
2066
  return
1022
2067
  }
1023
2068
  if (host.now() >= link.deadline) {
@@ -1060,7 +2105,7 @@ window.__ModuleLoader__.load({
1060
2105
  const shown = host.mainViewSessionId()
1061
2106
  if (shown !== rowSession && panelAttempts < INNER_ATTEMPT_LIMIT) {
1062
2107
  panelAttempts += 1
1063
- host.openSession(rowSession)
2108
+ host.openSession(rowSession, linkParent)
1064
2109
  } else if (shown === rowSession && innerAttempts < INNER_ATTEMPT_LIMIT) {
1065
2110
  innerAttempts += 1
1066
2111
  if (!host.revealPosition(rowParts.slice(rowAt)) && wantedTurn >= 0) host.revealPosition(rowParts.slice(wantedTurn, wantedTurn + 2), rowSession)
@@ -1076,7 +2121,7 @@ window.__ModuleLoader__.load({
1076
2121
  const shown = host.mainViewSessionId()
1077
2122
  if (shown !== wantedSession && panelAttempts < INNER_ATTEMPT_LIMIT) {
1078
2123
  panelAttempts += 1
1079
- host.openSession(wantedSession)
2124
+ host.openSession(wantedSession, linkParent)
1080
2125
  } else if (shown === wantedSession && innerAttempts < INNER_ATTEMPT_LIMIT) {
1081
2126
  innerAttempts += 1
1082
2127
  host.openConversationView(wantedView)
@@ -1091,7 +2136,88 @@ window.__ModuleLoader__.load({
1091
2136
  const shown = host.mainViewSessionId()
1092
2137
  if (shown !== target && source !== 'hash' && panelAttempts < INNER_ATTEMPT_LIMIT) {
1093
2138
  panelAttempts += 1
1094
- host.openSession(target)
2139
+ host.openSession(target, linkParent)
2140
+ }
2141
+ armRetry()
2142
+ return
2143
+ }
2144
+ if (link.kind === 'power') {
2145
+ // `#/power/shutdown` and `#/power/restart` are commands, and they are the one route
2146
+ // that must not be reachable by a link somebody else wrote: a URL is easy to click
2147
+ // by accident, and this one ends the application. So the action runs only for a
2148
+ // fragment the reader typed into the address bar (`typedClaim`), exactly once per
2149
+ // claim — and a hash read lowers the flag, which is what makes a link in a chat
2150
+ // message, a README or an AI reply harmless.
2151
+ const action = powerActionOf(link.id)
2152
+ if (action !== undefined) {
2153
+ if (!linkedTyped || powerActionSent) {
2154
+ // This branch is the one that used to answer a typed command with nothing at
2155
+ // all, and it left no trace: a desktop window has no console, so say it in
2156
+ // the report instead of guessing next time.
2157
+ if (!linkedTyped && !powerIgnored) {
2158
+ powerIgnored = true
2159
+ note(`${TAG} ignoring power action "${action}": this fragment did not come from the address bar`)
2160
+ }
2161
+ armRetry()
2162
+ return
2163
+ }
2164
+ // Refuse only when the address bar names a *different* power action: that is the
2165
+ // claim having gone stale, and running it would fire the wrong command (a shutdown
2166
+ // fired by a link that asked for a restart is not something to be clever about).
2167
+ //
2168
+ // It must not refuse merely because the bar does not name *this* action: the router
2169
+ // itself rewrites the bar to the canonical of what is on screen, and opening the
2170
+ // menu makes that canonical `#/power` — so a stricter rule refused our own first
2171
+ // drive step, and the reader had to press Enter twice (measured 2026-10-29).
2172
+ const typed = parseRoute(String(host.readLocation().hash ?? ''))
2173
+ const namesOtherAction = typed !== undefined && typed.kind === 'power' && typed.id !== '' && typed.id !== link.id
2174
+ if (namesOtherAction) {
2175
+ note(`${TAG} refusing power action "${action}": the address bar names "${String(typed.id)}"`)
2176
+ powerActionSent = true
2177
+ armRetry()
2178
+ return
2179
+ }
2180
+ // The plugin's own path first: its menu, then the item it publishes for this
2181
+ // action — that way its own flow runs (its overlay, and any confirmation it may
2182
+ // add later). The endpoint is the fallback for a menu that never yields the
2183
+ // item, so a command can never end in a silent no-op.
2184
+ if (!powerMenuOpenOf(window.document)) {
2185
+ if (powerActionAttempts < POWER_ACTION_MENU_LIMIT) {
2186
+ powerActionAttempts += 1
2187
+ innerAttempts += 1
2188
+ host.openPowerMenu()
2189
+ // The focus is deliberately left alone: the plugin keeps it on its own
2190
+ // trigger and hangs its arrow-key navigation there (fixed 2026-10-29 by
2191
+ // removing its `first.focus()`), so stealing it back would kill that.
2192
+ } else {
2193
+ note(`${TAG} running power action "${action}" through the plugin endpoint (its menu published no item)`)
2194
+ powerActionSent = true
2195
+ host.powerAction(action)
2196
+ }
2197
+ armRetry()
2198
+ return
2199
+ }
2200
+ if (host.clickPowerItem(action)) {
2201
+ note(`${TAG} running power action "${action}" through the plugin's own menu item`)
2202
+ powerActionSent = true
2203
+ armRetry()
2204
+ return
2205
+ }
2206
+ powerActionAttempts += 1
2207
+ if (powerActionAttempts >= POWER_ACTION_MENU_LIMIT) {
2208
+ note(`${TAG} running power action "${action}" through the plugin endpoint (no menu item published it)`)
2209
+ powerActionSent = true
2210
+ host.powerAction(action)
2211
+ }
2212
+ armRetry()
2213
+ return
2214
+ }
2215
+ // A bare `#/power` only opens the menu. It is a toggle: it is clicked only while it
2216
+ // is closed, so a retry can never open it and close it again. The items inside it
2217
+ // are never clicked — opening a menu is a place, acting on it is a person's call.
2218
+ if (innerAttempts < INNER_ATTEMPT_LIMIT && !powerMenuOpenOf(window.document)) {
2219
+ innerAttempts += 1
2220
+ host.openPowerMenu()
1095
2221
  }
1096
2222
  armRetry()
1097
2223
  return
@@ -1099,7 +2225,8 @@ window.__ModuleLoader__.load({
1099
2225
  if (link.kind === 'settings') {
1100
2226
  if (source !== 'hash' && innerAttempts < INNER_ATTEMPT_LIMIT) {
1101
2227
  innerAttempts += 1
1102
- host.openSettings(link.id, innerPath(link.inner)[0])
2228
+ const parts = settingsPartsOf(link.inner)
2229
+ host.openSettings(link.id, parts.levels, parts.provider)
1103
2230
  }
1104
2231
  armRetry()
1105
2232
  return
@@ -1143,7 +2270,7 @@ window.__ModuleLoader__.load({
1143
2270
  // idempotent on a conversation that is not shown yet (no seats, nothing to scroll to, and
1144
2271
  // the paging path is a no-op too), and waiting for the main-view counter to catch up is
1145
2272
  // what left a cold-opened turn link doing nothing at all.
1146
- host.openSession(wantedSession)
2273
+ host.openSession(wantedSession, linkParent)
1147
2274
  host.revealPosition(innerPath([TURN_SEGMENT, wantedTurn]), wantedSession)
1148
2275
  // A row may follow the turn (`turn/<n>/row/…`): reveal it after the turn.
1149
2276
  const rowAt = innerPath(link.inner).indexOf(ROW_SEGMENT)
@@ -1190,7 +2317,16 @@ window.__ModuleLoader__.load({
1190
2317
  // with a bound so an unopenable page cannot turn into a click loop.
1191
2318
  if (link.inner !== undefined && routeKey(current) !== routeKey(link) && source !== 'hash' && innerAttempts < INNER_ATTEMPT_LIMIT) {
1192
2319
  innerAttempts += 1
1193
- host.openPanelPage(link.id, link.inner)
2320
+ // A pane tail names the right bar, not the panel's page: handing it to the
2321
+ // panel would ask that panel to open a page called `files` (measured on a
2322
+ // link the router itself had written while both were open).
2323
+ const parts = innerPath(link.inner)
2324
+ const paneAt = parts.indexOf(PANE_SEGMENT)
2325
+ const wantedPane = paneAt < 0 ? undefined : parts[paneAt + 1]
2326
+ host.openPanelPage(link.id, paneAt < 0 ? parts : parts.slice(0, paneAt))
2327
+ if (wantedPane !== undefined && host.activePaneKind() !== wantedPane) {
2328
+ host.openPane(wantedPane, 'panel', message => host.warn(message))
2329
+ }
1194
2330
  }
1195
2331
  armRetry()
1196
2332
  return
@@ -1205,7 +2341,7 @@ window.__ModuleLoader__.load({
1205
2341
  }
1206
2342
  if ((!claimed || moved) && host.isOpenable(link.id)) {
1207
2343
  claimed = true
1208
- host.openSession(link.id)
2344
+ host.openSession(link.id, linkParent)
1209
2345
  }
1210
2346
  }
1211
2347
  /** Adopt the surface the address bar names, if it names one. */
@@ -1239,6 +2375,7 @@ window.__ModuleLoader__.load({
1239
2375
  return
1240
2376
  }
1241
2377
  if (linked === undefined || linked.kind !== parsed.kind || linked.id !== parsed.id) {
2378
+ linkedTyped = typedClaim
1242
2379
  linked = { kind: parsed.kind, id: parsed.id, inner: parsed.inner, deadline: host.now() + LINK_ENFORCE_WINDOW_MS }
1243
2380
  if (parsed.kind === 'panel' && parsed.inner !== undefined) panelScope = innerPath(parsed.inner)[0]
1244
2381
  claimed = false
@@ -1291,7 +2428,43 @@ window.__ModuleLoader__.load({
1291
2428
  retryTimer?.()
1292
2429
  retryTimer = undefined
1293
2430
  }
2431
+ // A power action is honoured only for a fragment the reader typed themselves: the flag is
2432
+ // raised by the address bar's own hand-off and lowered by every browser-driven hash read.
2433
+ let typedClaim = false
2434
+ /**
2435
+ * The fragment the reader last drove by hand.
2436
+ *
2437
+ * Writing the hash makes the browser deliver a `hashchange` of our own write, one task
2438
+ * later. That echo arrives after the link has already been dropped (a command lands
2439
+ * immediately, by definition), so it would create a *fresh* claim that is not typed —
2440
+ * and the command the reader typed was then refused, silently, until they pressed Enter
2441
+ * a second time (measured 2026-10-29). Recognising the echo keeps the claim theirs.
2442
+ */
2443
+ let typedEcho = ''
2444
+ /** Whether this claim already said out loud that it was not the reader's. */
2445
+ let powerIgnored = false
2446
+ // Whether *the claim in flight* came from the address bar's own hand-off. The flag above
2447
+ // only survives one read; a command needs its authority for the whole claim, because its
2448
+ // drive retries (that is how the plugin's menu gets its budget).
2449
+ let linkedTyped = false
2450
+ // How many steps the plugin's own menu has had for this claim, before the endpoint takes over.
2451
+ let powerActionAttempts = 0
2452
+ // One action per claim: a POST that is retried because the app is slow to die would
2453
+ // otherwise fire twice.
2454
+ let powerActionSent = false
1294
2455
  const safeReadLink = () => guard(readLink)
2456
+ /**
2457
+ * Record a line about a command the reader typed.
2458
+ *
2459
+ * A desktop window has no console, so `host.warn` alone leaves no trace anyone can read:
2460
+ * the observation report is where a person can see what a destructive command actually
2461
+ * did. Both get the line.
2462
+ * @param {string} message - the line.
2463
+ */
2464
+ const note = message => {
2465
+ if (!observed.warnings.includes(message)) observed.warnings.push(message)
2466
+ host.warn(message)
2467
+ }
1295
2468
  // The desktop address bar hands its fragment over here instead of relying on the
1296
2469
  // browser: a programmatic hash write fires `hashchange` in a browser, but a hash
1297
2470
  // equal to the current one fires nothing at all, and the desktop shell serves the
@@ -1299,15 +2472,34 @@ window.__ModuleLoader__.load({
1299
2472
  // listener uses makes the bar work in both cases. The hash is still written, so the
1300
2473
  // address bar, Back/Forward and copied links all agree with what is on screen.
1301
2474
  driveTypedFragment = fragment => {
2475
+ typedClaim = true
2476
+ typedEcho = fragment
2477
+ // A fresh command gets the full menu budget, however many times the reader pastes it.
2478
+ powerActionSent = false
2479
+ powerActionAttempts = 0
2480
+ powerIgnored = false
2481
+ // And it re-arms the claim it names. Without this, a link that had *already* arrived
2482
+ // some other way (a chat message the reader clicked) keeps the claim it made, and the
2483
+ // same fragment typed afterwards found `linked.id === parsed.id`, made no new claim,
2484
+ // and stayed refused — a command the reader typed was answered with nothing at all
2485
+ // (measured 2026-10-29).
2486
+ if (linked !== undefined) linkedTyped = true
1302
2487
  attemptValue(() => {
1303
2488
  window.location.hash = fragment
1304
2489
  })
1305
2490
  safeReadLink()
2491
+ typedClaim = false
1306
2492
  }
1307
2493
  const offPanel = host.onPanelChange(() => safeReconcile('panel'))
1308
2494
  const offCatalog = host.onCatalogChange(() => safeReconcile('catalog'))
1309
2495
  const offDom = host.onDomChange(() => safeReconcile('dom'))
1310
- const offHashChange = host.onHashChange(() => safeReadLink())
2496
+ const offHashChange = host.onHashChange(() => {
2497
+ // A hash read is never a typed link: whoever wrote the fragment (a chat message, a
2498
+ // page, a plugin) does not get to run a command — with one exception, the echo of the
2499
+ // reader's own write, which is what makes a command work on the first Enter.
2500
+ typedClaim = readableFragment(String(attemptValue(() => window.location.hash) ?? '')) === readableFragment(typedEcho)
2501
+ safeReadLink()
2502
+ })
1311
2503
  safeReadLink()
1312
2504
  // Pointing at a turn records a position, never a navigation: the listener is passive
1313
2505
  // and it refuses to act on any interactive element, so clicks keep working exactly as
@@ -1393,6 +2585,14 @@ window.__ModuleLoader__.load({
1393
2585
  paneActive: attemptValue(() => host.activePaneKind()) ?? null,
1394
2586
  paneKinds: attemptValue(() => host.paneKinds?.()) ?? null,
1395
2587
  },
2588
+ // The address bar's own preferences, so a bug report can say what the bar
2589
+ // was told to do without opening the settings page.
2590
+ preferences: {
2591
+ selectAllOnFocus: readSettings().selectAllOnFocus,
2592
+ recentLimit: readSettings().recentLimit,
2593
+ remembered: recentEntries().length,
2594
+ },
2595
+ work: { reconciles: workCounts.reconciles, placements: workCounts.placements },
1396
2596
  compat: observed.compat,
1397
2597
  foreignFragment: observed.foreignFragment,
1398
2598
  ignoredKinds: observed.ignored,
@@ -1865,7 +3065,15 @@ window.__ModuleLoader__.load({
1865
3065
  faces: {
1866
3066
  catalog: list,
1867
3067
  archivedSessionIds: () => registry.getSnapshot().archivedSessionIds,
1868
- openSession: id => openSession.call(navigation, id),
3068
+ openSession: (id, parent) => {
3069
+ if (parent === undefined || parent === null || parent === '') return openSession.call(navigation, id)
3070
+ // A subagent view is opened by its address (parent + child + mode), not by the
3071
+ // plain session id: that is the shape `uiWorkspace.openSession` declares
3072
+ // (`SessionTarget = SessionId | SubagentAddress`). The mode is durable in the
3073
+ // catalog, so it is read back rather than guessed.
3074
+ const mode = attemptValue(() => sessions?.subagentAddress?.(id)?.mode) ?? 'unknown'
3075
+ return openSession.call(navigation, { parentSessionId: parent, childSessionId: id, mode })
3076
+ },
1869
3077
  selectPanel: selectPanel === undefined ? undefined : id => selectPanel(id),
1870
3078
  beginNavigation: beginNavigation === undefined ? undefined : () => beginNavigation(),
1871
3079
  openRightbar: openRightbar === undefined ? undefined : track => openRightbar(track),
@@ -1896,12 +3104,27 @@ window.__ModuleLoader__.load({
1896
3104
  return fallback
1897
3105
  }
1898
3106
  }
3107
+ /**
3108
+ * How many times this link has pressed the settings launcher.
3109
+ *
3110
+ * The launcher is a *toggle*, so it may only be pressed while no menu is on screen: a
3111
+ * press that opened nothing may be retried (bounded), a press that opened something must
3112
+ * never be repeated — that would close it again. Reset with the claim, never per retry.
3113
+ */
3114
+ let settingsLauncherPresses = 0
1899
3115
  const archived = () => {
1900
3116
  const ids = attempt(() => faces.archivedSessionIds(), [])
1901
3117
  return Array.isArray(ids) ? ids : []
1902
3118
  }
1903
3119
  const host = {
1904
3120
  mainViewSessionId: () => attempt(() => mainViewSessionId(faces.catalog.getSnapshot().byId), undefined),
3121
+ // How many conversations the catalog lists: the compatibility check uses it to tell
3122
+ // "an empty sidebar" from "a sidebar whose rows are no longer where they were".
3123
+ listedSessions: () => attemptValue(() => {
3124
+ const snapshot = faces.catalog.getSnapshot()
3125
+ if (Array.isArray(snapshot?.ids)) return snapshot.ids.length
3126
+ return Object.keys(snapshot?.byId ?? {}).length
3127
+ }, 0),
1905
3128
  // The sidebar lists workspaces and their sessions in one tree, in order, so the
1906
3129
  // workspace a conversation belongs to is the nearest workspace row before it.
1907
3130
  workspaceNameOf: sessionId => {
@@ -1926,6 +3149,19 @@ window.__ModuleLoader__.load({
1926
3149
  // The inner page a panel is showing, in that panel's own vocabulary. Each panel
1927
3150
  // publishes it with a data attribute; reading it is the whole reflect direction.
1928
3151
  panelPage: (panelId, scope) => {
3152
+ if (panelId === SCHEDULES_PANEL_ID) {
3153
+ // Two dimensions, in one path: the status filter the list shows (the default
3154
+ // `all` is not written) and the task whose detail is open (with its tab).
3155
+ const segments = []
3156
+ const filter = scheduleFilterOf(window.document)
3157
+ if (filter !== undefined && filter !== SCHEDULE_FILTERS[0]) segments.push(FILTER_SEGMENT, filter)
3158
+ const task = scheduleTaskOf(window.document)
3159
+ if (task !== undefined) {
3160
+ segments.push(TASK_SEGMENT, task.id)
3161
+ if (task.tab !== 'rule') segments.push(task.tab)
3162
+ }
3163
+ return segments.length === 0 ? undefined : segments
3164
+ }
1929
3165
  if (panelId === TASK_BOARD_PANEL_ID) {
1930
3166
  // The form and a task are mutually exclusive, and the form wins when both
1931
3167
  // would be reported (it is the page the user is looking at).
@@ -1949,6 +3185,15 @@ window.__ModuleLoader__.load({
1949
3185
  },
1950
3186
  openPanelPage: (panelId, inner) => {
1951
3187
  const asked = innerPath(inner)
3188
+ if (panelId === SCHEDULES_PANEL_ID) {
3189
+ // Oldest first: the filter decides which rows the list holds, so it is pressed
3190
+ // before a task in that list is asked for.
3191
+ const filterAt = asked.indexOf(FILTER_SEGMENT)
3192
+ if (filterAt >= 0) clickScheduleFilter(asked[filterAt + 1])
3193
+ const taskAt = asked.indexOf(TASK_SEGMENT)
3194
+ if (taskAt >= 0 && asked[taskAt + 1] !== undefined) clickScheduleTask(asked[taskAt + 1], asked[taskAt + 2])
3195
+ return
3196
+ }
1952
3197
  if (panelId === TASK_BOARD_PANEL_ID) {
1953
3198
  // The task board owns how its overlays open: we hand over the request on its
1954
3199
  // own contract (window events) and nothing else crosses the seam.
@@ -1974,8 +3219,9 @@ window.__ModuleLoader__.load({
1974
3219
  const pkg =
1975
3220
  window.document?.querySelector?.(`li[data-plugin-package="${segments[0]}"]`) ??
1976
3221
  window.document?.querySelector?.(`li[data-plugin-item="${segments[0]}"]`)
1977
- const button = pkg?.querySelector?.('button')
1978
- if (typeof button?.click === 'function') button.click()
3222
+ // Never the enable switch: a link opens a page, it never changes a plugin.
3223
+ const button = openControlOf(pkg)
3224
+ if (button !== undefined) button.click()
1979
3225
  if (segments.length === 1) return
1980
3226
  }
1981
3227
  const rowId = segments[segments.length - 1]
@@ -1983,8 +3229,8 @@ window.__ModuleLoader__.load({
1983
3229
  for (const row of rows) {
1984
3230
  const parsed = splitRowHook(typeof row.getAttribute === 'function' ? row.getAttribute('data-plugin-row') : null)
1985
3231
  if (parsed === undefined || parsed.rowId !== rowId) continue
1986
- const button = row.querySelector?.('button')
1987
- if (typeof button?.click === 'function') button.click()
3232
+ const button = openControlOf(row)
3233
+ if (button !== undefined) button.click()
1988
3234
  return
1989
3235
  }
1990
3236
  },
@@ -1999,9 +3245,20 @@ window.__ModuleLoader__.load({
1999
3245
  // A panel id nobody registered is refused by the layout by THROWING
2000
3246
  // (`selectPanel: main panel "x" is not registered`), so the claim is
2001
3247
  // swallowed here and reported as "the view did not move" at expiry.
2002
- openSession: id => {
2003
- attempt(() => faces.openSession(id))
3248
+ openSession: (id, parent) => {
3249
+ // A parent segment that is a title (not an id) is a label: the child still opens by
3250
+ // its own id, which is the identity the route carries anyway.
3251
+ attempt(() => faces.openSession(id, isSessionIdLike(parent) ? parent : undefined))
2004
3252
  },
3253
+ // The parent a subagent session belongs to, from the catalog (no session opened), with the
3254
+ // title the catalog keeps for it — the decoration reads better than a uuid.
3255
+ subagentParentOf: id => attemptValue(() => {
3256
+ const snapshot = faces.catalog.getSnapshot()
3257
+ const parentId = snapshot?.byId?.[id]?.parentSessionId ?? faces.sessions?.subagentAddress?.(id)?.parentSessionId
3258
+ if (parentId === undefined || parentId === null || parentId === '') return undefined
3259
+ const title = snapshot?.byId?.[parentId]?.title
3260
+ return { id: String(parentId), title: typeof title === 'string' ? title : '' }
3261
+ }, undefined),
2005
3262
  // A row's own identity, in the shape the trajectory views publish it. The value is a
2006
3263
  // percent-encoded NUL-separated triple, so the route can read as a plain path.
2007
3264
  rowIdentityOf: element => {
@@ -2056,6 +3313,7 @@ window.__ModuleLoader__.load({
2056
3313
  revealPosition: (parts, wantedSession) => {
2057
3314
  const wanted = innerPath(parts)
2058
3315
  const document = window.document
3316
+
2059
3317
  if (wanted[0] === ROW_SEGMENT && wanted.length >= 3) {
2060
3318
  const key = wanted.slice(1).join('%00')
2061
3319
  for (const candidate of document?.querySelectorAll?.(`[${TRAJECTORY_ROW_ATTRIBUTE}]`) ?? []) {
@@ -2088,16 +3346,46 @@ window.__ModuleLoader__.load({
2088
3346
  // from the catalog here is how this step used to look up "no session".
2089
3347
  const reference = attemptValue(() => faces.sessions?.retainScope?.(wantedSession, 'url-router'))
2090
3348
  const session = attemptValue(() => reference?.binding?.session)
2091
- const loadThrough = attemptValue(() => (session === undefined || session === null ? undefined : session.loadThrough))
2092
3349
  const outline = attemptValue(() => session?.projections?.faceOf?.('turnOutline')?.getSnapshot?.())
2093
3350
  const seq = Array.isArray(outline) ? outline.find(entry => entry && String(entry.turn) === String(turn))?.seq : undefined
2094
- const usable = typeof loadThrough === 'function' && typeof reference?.release === 'function'
3351
+ // The loader is called **on the session**, never through a detached reference:
3352
+ // `loadThrough` is a prototype method in this build and reads `this.openState`, so
3353
+ // destructuring it (`const load = session.loadThrough`) throws on every call.
3354
+ const usable = typeof session?.loadThrough === 'function' && typeof reference?.release === 'function'
2095
3355
  if (typeof seq !== 'number' || !usable) {
2096
3356
  attemptValue(() => reference?.release?.())
2097
3357
  return false
2098
3358
  }
2099
- attemptValue(() => loadThrough(seq))
3359
+ // The loader resolves when the pages are in, but the chat renders them on its own
3360
+ // commit — so the seat shows up a frame or two later, and until something scrolls
3361
+ // to it the virtual list simply trims the freshly loaded turns again. The official
3362
+ // chat does the same two steps (`loadThrough(seq)` then `scrollToTurn(turn)`); this
3363
+ // is the scroll half, without a handle on its navigation object.
3364
+ const scrollToSeat = () => {
3365
+ const seat = attemptValue(() => document?.querySelector?.(`[${TURN_ATTRIBUTE}="${turn}"]`))
3366
+ if (seat === null || seat === undefined) return false
3367
+ attempt(() => seat.scrollIntoView?.({ block: 'center' }))
3368
+ return true
3369
+ }
3370
+ const pending = attemptValue(() => session.loadThrough(seq))
2100
3371
  attemptValue(() => reference.release())
3372
+ if (pending !== undefined && pending !== null && typeof pending.then === 'function') {
3373
+ pending.then(
3374
+ () => {
3375
+ // A few frames of patience: the seat is rendered by the next commit.
3376
+ let tries = 0
3377
+ const tick = () => {
3378
+ tries += 1
3379
+ if (tries > 25 || scrollToSeat()) return
3380
+ attemptValue(() => window.setTimeout?.(tick, 50))
3381
+ }
3382
+ tick()
3383
+ },
3384
+ () => {},
3385
+ )
3386
+ } else {
3387
+ scrollToSeat()
3388
+ }
2101
3389
  return true
2102
3390
  },
2103
3391
  // Which conversation view is showing. The registry is the source of truth for ids
@@ -2117,18 +3405,39 @@ window.__ModuleLoader__.load({
2117
3405
  break
2118
3406
  }
2119
3407
  if (index < 0) return undefined
3408
+ const selected = tabs[index]
3409
+ // The header prints one label per visible tab, and the slot says which id carries
3410
+ // it — that pair survives the "…" overflow, which hides tabs from the DOM while
3411
+ // their views still exist (positional mapping cannot see those at all).
3412
+ const text = typeof selected?.textContent === 'string' ? selected.textContent.trim() : ''
3413
+ const labelled = conversationViewLabelsOf(faces.slots)
3414
+ if (text !== '') {
3415
+ const match = labelled.filter(entry => entry.label === text)
3416
+ if (match.length === 1) {
3417
+ const at = labelled.indexOf(match[0])
3418
+ return { id: match[0].id, index: at, total: labelled.length }
3419
+ }
3420
+ }
3421
+ // No label pair (a build with no slot labels): fall back to registration order,
3422
+ // and only while every view is rendered — a wrong id is worse than none.
2120
3423
  const ids = conversationViewIdsOf(faces)
2121
3424
  if (ids === undefined || ids.length !== tabs.length) return undefined
2122
3425
  return { id: ids[index], index, total: ids.length }
2123
3426
  },
2124
3427
  openConversationView: viewId => {
2125
3428
  const row = window.document?.querySelector?.(`[${CONVERSATION_TABS_ATTRIBUTE}]`)
2126
- const tabs = typeof row?.querySelectorAll === 'function' ? row.querySelectorAll('[role="tab"]') : undefined
2127
- if (tabs === undefined || tabs === null) return
3429
+ const tabs = typeof row?.querySelectorAll === 'function' ? [...row.querySelectorAll('[role="tab"]')] : undefined
3430
+ if (tabs === undefined || tabs.length === 0) return
3431
+ // By label first, for the same reason the reflect side does: the tab's text is
3432
+ // what the reader picks, and the slot says which id sits behind it.
3433
+ const labelled = conversationViewLabelsOf(faces.slots)
3434
+ const entry = labelled.find(item => item.id === viewId)
3435
+ const byLabel = entry === undefined || entry.label === ''
3436
+ ? undefined
3437
+ : tabs.find(tab => (typeof tab.textContent === 'string' ? tab.textContent.trim() : '') === entry.label)
2128
3438
  const ids = conversationViewIdsOf(faces)
2129
- if (ids === undefined) return
2130
- const index = ids.indexOf(viewId)
2131
- const tab = index < 0 ? undefined : tabs[index]
3439
+ const index = ids === undefined ? -1 : ids.indexOf(viewId)
3440
+ const tab = byLabel ?? (index < 0 ? undefined : tabs[index])
2132
3441
  if (typeof tab?.click === 'function') attempt(() => tab.click())
2133
3442
  },
2134
3443
  // The right-hand pane names the tab in front. The service is the source of truth;
@@ -2158,10 +3467,13 @@ window.__ModuleLoader__.load({
2158
3467
  const snapshot = attemptValue(() => (typeof owner.getSnapshot === 'function' ? owner.getSnapshot() : undefined))
2159
3468
  return snapshot === undefined ? undefined : kindOf(snapshot.activeTabId ?? snapshot.activeTab ?? snapshot.active)
2160
3469
  }
2161
- const fromStrip = activePaneTabOf(window.document, paneRegistryOf(faces))
2162
- if (fromStrip !== undefined) return fromStrip
3470
+ // The controller first: it holds the live tab descriptor (`{ id, kind, title }`), so
3471
+ // its kind survives a title that is no longer the kind — a Files tab showing a file
3472
+ // is titled with the file name (`client.js`), and the DOM only carries that title.
2163
3473
  const fromController = read(faces.sidebarRight)
2164
3474
  if (fromController !== undefined) return fromController
3475
+ const fromStrip = activePaneTabOf(window.document, paneRegistryOf(faces))
3476
+ if (fromStrip !== undefined) return fromStrip
2165
3477
  const fromRegistry = read(faces.sidebarRightTabs)
2166
3478
  if (fromRegistry !== undefined) return fromRegistry
2167
3479
  // DOM fallback, deliberately narrow: the active-pane marker is shared with the
@@ -2189,18 +3501,12 @@ window.__ModuleLoader__.load({
2189
3501
  }
2190
3502
  return undefined
2191
3503
  },
3504
+ // The model-provider card whose editor is open, when one is (see the helper).
3505
+ activeSettingsProvider: () => openSettingsProviderId(window.document),
2192
3506
  // The tab a settings section is showing: the same ARIA contract one level deeper.
2193
- activeSettingsTab: () => {
2194
- const overlay = settingsOverlayOf(window.document)
2195
- if (overlay === null || overlay === undefined || typeof overlay.querySelectorAll !== 'function') return undefined
2196
- for (const candidate of overlay.querySelectorAll('[role="tab"]')) {
2197
- if (typeof candidate.getAttribute !== 'function') continue
2198
- if (candidate.getAttribute('aria-selected') !== 'true') continue
2199
- const label = (candidate.textContent ?? '').trim()
2200
- if (label !== '') return label
2201
- }
2202
- return undefined
2203
- },
3507
+ // Every settings row's selected control, in row order: the section's tabs, then the
3508
+ // row a tab reveals, and so on — the path a settings link can name.
3509
+ activeSettingsLevels: () => settingsSelectedLabelsOf(settingsOverlayOf(window.document)),
2204
3510
  // The overlay is a modal, and the platform's own dismiss path is a document-level
2205
3511
  // Escape: the shipped modal layer registers `document.addEventListener('keydown',
2206
3512
  // handler, true)` gated on `key === 'Escape'` and closes the topmost modal there.
@@ -2214,44 +3520,118 @@ window.__ModuleLoader__.load({
2214
3520
  window.document?.dispatchEvent?.(event)
2215
3521
  })
2216
3522
  },
2217
- openSettings: (section, tab) => {
2218
- if (typeof section === 'string' && section !== '') {
2219
- // A section is named by the label the user reads, so the request is a label match.
2220
- const overlay = settingsOverlayOf(window.document)
2221
- const scope = overlay ?? window.document
2222
- if (typeof scope?.querySelectorAll === 'function') {
2223
- const candidates = [...scope.querySelectorAll('button')].filter(candidate => {
2224
- if (typeof candidate.closest === 'function' && candidate.closest('nav') === null) return false
2225
- return (candidate.textContent ?? '').trim() !== ''
2226
- })
2227
- const wanted = section.trim().toLowerCase()
2228
- const match = candidates.find(candidate => (candidate.textContent ?? '').trim().toLowerCase() === wanted)
2229
- if (match !== undefined && typeof match.click === 'function') {
2230
- attempt(() => match.click())
2231
- // The section may own tabs: a named one is clicked once the section is up.
2232
- if (typeof tab === 'string' && tab !== '') {
2233
- const wantedTab = tab.trim().toLowerCase()
2234
- const tabs = [...scope.querySelectorAll('[role="tab"]')]
2235
- const matchTab = tabs.find(candidate => (candidate.textContent ?? '').trim().toLowerCase() === wantedTab)
2236
- if (matchTab !== undefined && typeof matchTab.click === 'function') attempt(() => matchTab.click())
3523
+ // Run a power action through the power plugin's own published endpoint. The endpoint
3524
+ // answers by ending the application (or relaunching it), so nothing is read back: the
3525
+ // call is fire-and-forget, and a composition without that plugin simply 404s.
3526
+ // Click the item the power plugin publishes for an action. Contract attribute first
3527
+ // (`data-dsh-power-action`), then the item's own label; when neither matches, the
3528
+ // caller falls back to the endpoint rather than guessing at a button.
3529
+ clickPowerItem: action => {
3530
+ if (POWER_ACTIONS[action] === undefined) return false
3531
+ const menu = powerMenuOf(window.document)
3532
+ if (menu === undefined) return false
3533
+ // Contract attribute only. Matching the item by its *label* would be guessing at a
3534
+ // button that ends the application, and the two labels are a hair apart (关闭DSH /
3535
+ // 重启DSH) — a wrong guess is not recoverable, so an unlabelled menu is left to the
3536
+ // endpoint, which names the action in the URL itself.
3537
+ const contracted = attemptValue(() => [...(menu.querySelectorAll?.(`[data-dsh-power-action="${action}"]`) ?? [])])
3538
+ if (!Array.isArray(contracted) || contracted.length === 0) return false
3539
+ pressElement(contracted[0])
3540
+ return true
3541
+ },
3542
+ powerAction: action => {
3543
+ // A wrong base answers 404 and does nothing, so the candidates are tried in order
3544
+ // until one accepts the request. The last failure is named once, because a desktop
3545
+ // window has no console and a silent no-op is the worst possible answer here.
3546
+ const send = index => {
3547
+ if (index >= POWER_ACTION_BASES.length) {
3548
+ host.warn(`${TAG} no power plugin answered ${action}: tried ${POWER_ACTION_BASES.join(', ')}`)
3549
+ return
3550
+ }
3551
+ const endpoint = `${POWER_ACTION_BASES[index]}/${action}`
3552
+ const result = attemptValue(() => window.fetch?.(endpoint, {
3553
+ method: 'POST',
3554
+ credentials: 'same-origin',
3555
+ headers: { 'content-type': 'application/json' },
3556
+ body: '{}',
3557
+ }))
3558
+ if (result === undefined || typeof result.then !== 'function') return
3559
+ result.then(response => {
3560
+ if (response?.ok !== true) send(index + 1)
3561
+ }).catch(() => send(index + 1))
3562
+ }
3563
+ send(0)
3564
+ },
3565
+ // Open the power menu. A link may do this; nothing inside the menu is ever clicked.
3566
+ openPowerMenu: () => {
3567
+ if (powerMenuOpenOf(window.document)) return
3568
+ const trigger = powerTriggerOf(window.document)
3569
+ if (typeof trigger?.click === 'function') attempt(() => trigger.click())
3570
+ },
3571
+ openSettings: (section, levels, provider) => {
3572
+ const overlay = settingsOverlayOf(window.document)
3573
+ if (overlay !== undefined) {
3574
+ // The overlay is what the link asked for. A named section (and its tab, and one of
3575
+ // its cards) is picked inside it; a bare link is already satisfied.
3576
+ settingsLauncherPresses = 0
3577
+ if (typeof section !== 'string' || section === '') return
3578
+ if (typeof overlay.querySelectorAll !== 'function') return
3579
+ const candidates = [...overlay.querySelectorAll('button')].filter(candidate => {
3580
+ if (typeof candidate.closest === 'function' && candidate.closest('nav') === null) return false
3581
+ return (candidate.textContent ?? '').trim() !== ''
3582
+ })
3583
+ const wanted = section.trim().toLowerCase()
3584
+ const match = candidates.find(candidate => (candidate.textContent ?? '').trim().toLowerCase() === wanted)
3585
+ if (match === undefined || typeof match.click !== 'function') return
3586
+ // The section that is already showing is not pressed again: a section re-render can
3587
+ // close a card editor the link asked for (its drive is retried every half second).
3588
+ const current = candidates.find(candidate => attemptValue(() => candidate.getAttribute?.('aria-current')) === 'true')
3589
+ if (current !== match) attempt(() => match.click())
3590
+ // Deeper levels, in order, each looked up in a row *after* the one the previous
3591
+ // level matched — two rows can share a label (`皮肤` is both a section tab and a
3592
+ // row inside 创意工坊), and a cursor keeps them apart. One level per call: the
3593
+ // row a click reveals arrives with the next render, and the retry picks it up.
3594
+ let cursor = -1
3595
+ for (const level of innerPath(levels)) {
3596
+ const rows = settingsRowsOf(overlay)
3597
+ let hit
3598
+ for (let index = cursor + 1; index < rows.length; index += 1) {
3599
+ const control = settingsControlOf(rows[index], level)
3600
+ if (control !== undefined) {
3601
+ hit = { index, control, row: rows[index] }
3602
+ break
2237
3603
  }
2238
- return
2239
3604
  }
3605
+ if (hit === undefined) break
3606
+ cursor = hit.index
3607
+ if (!controlIsOn(hit.row, hit.control)) attempt(() => hit.control.click())
2240
3608
  }
3609
+ // And one of its cards may be the page a link named.
3610
+ if (typeof provider === 'string' && provider !== '') clickSettingsProvider(provider)
3611
+ return
2241
3612
  }
2242
- const slot = window.document?.querySelector?.(`[data-slot="${SETTINGS_TRIGGER_SLOT}"]`)
2243
- const button =
2244
- (typeof slot?.querySelector === 'function' ? slot.querySelector('button') : null) ??
2245
- (typeof slot?.closest === 'function' ? slot.closest('button') : null)
2246
- if (typeof button?.click === 'function') {
2247
- attempt(() => button.click())
3613
+ // The seat carries the shell's own store, and its actions own the panel's open state and
3614
+ // active section — so the panel is opened through it, without replaying any clicks.
3615
+ if (openSettingsThroughStore(faces, section)) return
3616
+ // Otherwise the reader's own path is replayed. A settings item already on
3617
+ // screen (the reader's menu, or one this link opened) is pressed instead of the launcher;
3618
+ // otherwise the launcher is pressed — never while a menu is up (that would close it
3619
+ // again), and only a bounded number of times (a press that opened nothing is retried).
3620
+ const item = settingsMenuItemOf(window.document)
3621
+ if (item !== undefined) {
3622
+ settingsLauncherPresses = 0
3623
+ pressElement(item)
2248
3624
  return
2249
3625
  }
2250
- // Fallback for a composition without that slot: the labelled entry.
2251
- const labelled = typeof window.document?.querySelectorAll === 'function'
2252
- ? [...window.document.querySelectorAll('button')].find(candidate => /^(settings|设置)$/i.test((candidate.textContent ?? '').trim()))
2253
- : undefined
2254
- if (typeof labelled?.click === 'function') attempt(() => labelled.click())
3626
+ if (visibleMenuOf(window.document) !== undefined) return
3627
+ if (settingsLauncherPresses >= SETTINGS_PRESS_LIMIT) return
3628
+ const button = settingsLauncherOf(window.document) ?? labelledSettingsEntryOf(window.document)
3629
+ if (button === undefined) return
3630
+ settingsLauncherPresses += 1
3631
+ pressElement(button)
3632
+ },
3633
+ resetSettingsDrive: () => {
3634
+ settingsLauncherPresses = 0
2255
3635
  },
2256
3636
  openPane: (kind, track, warn) => {
2257
3637
  if (typeof kind !== 'string' || kind === '') return
@@ -2355,7 +3735,22 @@ window.__ModuleLoader__.load({
2355
3735
  attempt(() => {
2356
3736
  const root = window.document?.body ?? window.document?.documentElement
2357
3737
  if (root === undefined || root === null || typeof window.MutationObserver !== 'function') return () => {}
2358
- const observer = new window.MutationObserver(handler)
3738
+ // One reconcile per frame. A streaming answer mutates the page dozens of times a
3739
+ // second, and every batch re-reads the whole view state (the sidebar's rows
3740
+ // included). A host without frames (tests, a minimal DOM) runs the handler
3741
+ // straight away, which is what the fake DOM relies on.
3742
+ let queued = false
3743
+ const flush = () => {
3744
+ queued = false
3745
+ handler()
3746
+ }
3747
+ const schedule = () => {
3748
+ if (queued) return
3749
+ queued = true
3750
+ const frame = attemptValue(() => window.requestAnimationFrame?.(flush))
3751
+ if (frame === undefined) flush()
3752
+ }
3753
+ const observer = new window.MutationObserver(schedule)
2359
3754
  // Every published page hook must be watched: a filter that names only some of
2360
3755
  // them silently starves the retry loop of notifications (real bug: the task
2361
3756
  // board's drive looked flaky because its attributes were not observed).
@@ -2374,6 +3769,10 @@ window.__ModuleLoader__.load({
2374
3769
  'aria-current',
2375
3770
  // Switching a settings *tab* moves this one, for the same reason.
2376
3771
  'aria-selected',
3772
+ // The automation list's status filter is three buttons whose only state is
3773
+ // this attribute, and pressing one touches nothing else: without it here the
3774
+ // address bar kept the filter it saw first.
3775
+ 'aria-pressed',
2377
3776
  SETTINGS_SURFACE_ATTRIBUTE,
2378
3777
  ],
2379
3778
  })
@@ -2448,6 +3847,231 @@ window.__ModuleLoader__.load({
2448
3847
  return undefined
2449
3848
  }
2450
3849
 
3850
+ /**
3851
+ * The "Settings" item of the launcher menu, when this build puts the overlay one menu down.
3852
+ *
3853
+ * The sidebar gear opens a popover on rc.3 (measured: Settings / Feedback / Sign in), so
3854
+ * clicking the gear alone leaves the overlay closed and every settings link expires. The
3855
+ * item's text carries its shortcut (`Settings⌥⌘,`), so only the head of the label matches.
3856
+ * @param {object} document - the document.
3857
+ * @returns {object|undefined} the menu item that opens the overlay.
3858
+ */
3859
+ /**
3860
+ * Whether an element is really on screen.
3861
+ *
3862
+ * The shell keeps closed popovers mounted, so "the selector matches" proves nothing: a
3863
+ * hidden item is unpressable, and pressing it also stops the launcher from being pressed
3864
+ * again. `offsetParent` is useless here — a fixed-position popover reports null while it
3865
+ * is perfectly visible — so the rect list is the test.
3866
+ * @param {object} node - the element.
3867
+ * @returns {boolean} whether it is visible.
3868
+ */
3869
+ function onScreen(node) {
3870
+ if (node === null || node === undefined) return false
3871
+ if (typeof node.closest === 'function' && node.closest('[hidden]') !== null) return false
3872
+ const rects = attemptValue(() => node.getClientRects?.())
3873
+ if (Array.isArray(rects) && rects.length === 0) return false
3874
+ return true
3875
+ }
3876
+
3877
+ /**
3878
+ * The open popover, when one is on screen.
3879
+ *
3880
+ * One selector at a time: a comma list is beyond a small DOM stub.
3881
+ * @param {object} document - the document.
3882
+ * @returns {object|undefined} the popover root.
3883
+ */
3884
+ function visibleMenuOf(document) {
3885
+ for (const selector of ['[role="menu"]']) {
3886
+ const nodes = attemptValue(() => [...(document?.querySelectorAll?.(selector) ?? [])])
3887
+ if (!Array.isArray(nodes)) continue
3888
+ for (const node of nodes) {
3889
+ if (onScreen(node)) return node
3890
+ }
3891
+ }
3892
+ return undefined
3893
+ }
3894
+
3895
+ /**
3896
+ * The "Settings" item of the launcher menu, when this build puts the overlay one menu down.
3897
+ *
3898
+ * The seat is single-occupant, so a shell that seats an account row there turns the gear
3899
+ * into a menu trigger (measured on the desktop: the trigger is labelled with the account
3900
+ * name and carries `aria-haspopup="menu"`). The item is matched by its label — its head may
3901
+ * carry a shortcut (`Settings⌥⌘,`) and it need not be a `menuitem` at all — and only while
3902
+ * the menu is really on screen.
3903
+ * @param {object} document - the document.
3904
+ * @returns {object|undefined} the menu item that opens the overlay.
3905
+ */
3906
+ function settingsMenuItemOf(document) {
3907
+ const menu = visibleMenuOf(document)
3908
+ const scopes = menu === undefined ? [document] : [menu]
3909
+ for (const scope of scopes) {
3910
+ for (const selector of ['[role="menuitem"]', 'button', '[role="option"]', 'a']) {
3911
+ const nodes = attemptValue(() => [...(scope?.querySelectorAll?.(selector) ?? [])])
3912
+ if (!Array.isArray(nodes)) continue
3913
+ for (const node of nodes) {
3914
+ if (!onScreen(node)) continue
3915
+ const label = String(node.textContent ?? '').trim()
3916
+ if (!/设置|settings/i.test(label)) continue
3917
+ // Outside a menu, only a real menu item counts: a stray button that happens to
3918
+ // be labelled "设置" is not this drive's to press.
3919
+ if (menu === undefined && node.getAttribute?.('role') !== 'menuitem') continue
3920
+ return node
3921
+ }
3922
+ }
3923
+ }
3924
+ return undefined
3925
+ }
3926
+
3927
+ /**
3928
+ * Whether the power menu is open.
3929
+ *
3930
+ * It is the one surface this router reads from another *plugin*, not from the platform, so
3931
+ * the contract attribute comes first and the plugin's own class names are the fallback for
3932
+ * a build that predates it. Nothing here writes anything: it only answers "is the popover
3933
+ * up", which is what makes the menu a routable (if transient) place.
3934
+ * @param {object} document - the document.
3935
+ * @returns {boolean} whether the menu is on screen.
3936
+ */
3937
+ function powerMenuOpenOf(document) {
3938
+ const marked = attemptValue(() => document?.querySelector?.(`[${POWER_MENU_ATTRIBUTE}]`))
3939
+ if (marked !== null && marked !== undefined) {
3940
+ const value = typeof marked.getAttribute === 'function' ? marked.getAttribute(POWER_MENU_ATTRIBUTE) : null
3941
+ return value === null || value === '' || value === 'open'
3942
+ }
3943
+ const menus = attemptValue(() => document?.getElementsByClassName?.(POWER_MENU_CLASS))
3944
+ if (menus !== undefined && menus !== null && menus.length > 0) return true
3945
+ const triggers = attemptValue(() => document?.getElementsByClassName?.(POWER_TRIGGER_CLASS))
3946
+ const trigger = triggers?.[0]
3947
+ if (trigger === undefined || trigger === null) return false
3948
+ return typeof trigger.getAttribute === 'function' && trigger.getAttribute('aria-expanded') === 'true'
3949
+ }
3950
+
3951
+ /**
3952
+ * Open the settings panel through the shell's own store.
3953
+ *
3954
+ * The `sidebar.settings` seat is a single-occupant slot, and the shell's registration carries
3955
+ * the store that owns the panel: `create().actions.openSection(id)` opens it on a section with
3956
+ * no clicking at all. Measured 2026-10-28: a seat occupied by an account row opens a popover
3957
+ * whose item the drive could not press, and every settings link died there — this path does
3958
+ * not depend on that popover at all.
3959
+ * @param {object} faces - the probed faces.
3960
+ * @param {string} section - the section label a link names (empty for a bare link).
3961
+ * @returns {boolean} whether the store accepted the call.
3962
+ */
3963
+ function openSettingsThroughStore(faces, section) {
3964
+ const seat = attemptValue(() => faces.slots?.entries?.(SETTINGS_TRIGGER_SLOT)?.[0]?.store)
3965
+ if (seat === undefined || seat === null || typeof seat.create !== 'function') return false
3966
+ const actions = attemptValue(() => seat.create()?.actions)
3967
+ if (actions === undefined || actions === null) return false
3968
+ const wanted = typeof section === 'string' ? section.trim() : ''
3969
+ if (wanted === '') {
3970
+ if (typeof actions.open !== 'function') return false
3971
+ return attemptValue(() => { actions.open(); return true }) === true
3972
+ }
3973
+ const id = settingsSectionIdOf(faces, wanted)
3974
+ if (id === undefined) return false
3975
+ if (typeof actions.openSection !== 'function') return false
3976
+ return attemptValue(() => { actions.openSection(id); return true }) === true
3977
+ }
3978
+
3979
+ /**
3980
+ * The id of the section a link names, read from the slot the shell renders its nav from.
3981
+ *
3982
+ * A link names a section by the label the reader sees (that is the only identity the DOM
3983
+ * carries), while the store wants the registration id — this is the one place that maps one
3984
+ * to the other, and it is the same list the shell itself maps over.
3985
+ * @param {object} faces - the probed faces.
3986
+ * @param {string} label - the section label.
3987
+ * @returns {string|undefined} the section id.
3988
+ */
3989
+ function settingsSectionIdOf(faces, label) {
3990
+ const rows = attemptValue(() => faces.slots?.entries?.(SETTINGS_SECTION_SLOT))
3991
+ if (!Array.isArray(rows)) return undefined
3992
+ const wanted = label.trim().toLowerCase()
3993
+ for (const row of rows) {
3994
+ const id = row === null || typeof row !== 'object' ? undefined : row.options?.id
3995
+ if (typeof id !== 'string' || id === '') continue
3996
+ let text = row.options?.label
3997
+ if (typeof text === 'function') text = attemptValue(() => text())
3998
+ if (typeof text !== 'string') continue
3999
+ if (text.trim().toLowerCase() === wanted) return id
4000
+ }
4001
+ return undefined
4002
+ }
4003
+
4004
+ /**
4005
+ * The labelled settings entry, for a composition whose seat carries no launcher at all.
4006
+ * @param {object} document - the document.
4007
+ * @returns {object|undefined} the entry.
4008
+ */
4009
+ function labelledSettingsEntryOf(document) {
4010
+ const buttons = attemptValue(() => [...(document?.querySelectorAll?.('button') ?? [])])
4011
+ if (!Array.isArray(buttons)) return undefined
4012
+ return buttons.find(candidate => /^(settings|设置)$/i.test(String(candidate?.textContent ?? '').trim()))
4013
+ }
4014
+
4015
+ /**
4016
+ * Press an element the way a pointer does.
4017
+ *
4018
+ * A popover trigger commonly opens on `pointerdown`/`mousedown`, so a bare `element.click()`
4019
+ * is a no-op on it (measured: the settings seat's account row opens its menu only for a real
4020
+ * pointer press — the menu was never opened by the drive, so the link never landed). The
4021
+ * sequence is dispatched at the element's centre, and a missing constructor is skipped so a
4022
+ * minimal DOM still works.
4023
+ * @param {object} element - the element to press.
4024
+ * @returns {boolean} whether anything was dispatched.
4025
+ */
4026
+ function pressElement(element) {
4027
+ if (element === null || element === undefined) return false
4028
+ const box = attemptValue(() => element.getBoundingClientRect?.()) ?? { left: 0, top: 0, width: 0, height: 0 }
4029
+ const options = {
4030
+ bubbles: true,
4031
+ cancelable: true,
4032
+ view: window,
4033
+ clientX: (box.left ?? 0) + (box.width ?? 0) / 2,
4034
+ clientY: (box.top ?? 0) + (box.height ?? 0) / 2,
4035
+ }
4036
+ for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup']) {
4037
+ const Constructor = type.startsWith('pointer') ? window.PointerEvent : window.MouseEvent
4038
+ if (typeof Constructor !== 'function') continue
4039
+ attemptValue(() => element.dispatchEvent?.(new Constructor(type, options)))
4040
+ }
4041
+ if (typeof element.click === 'function') attemptValue(() => element.click())
4042
+ return true
4043
+ }
4044
+
4045
+ /**
4046
+ * The settings launcher: the dialog trigger when the shell seats one, the seat's own button
4047
+ * otherwise.
4048
+ *
4049
+ * A dialog trigger opens the overlay in one press; a menu trigger (the account row some
4050
+ * shells seat there) needs its menu followed, which the caller does on the next step.
4051
+ * @param {object} document - the document.
4052
+ * @returns {object|undefined} the element a reader clicks to reach the settings surface.
4053
+ */
4054
+ function settingsLauncherOf(document) {
4055
+ const slot = attemptValue(() => document?.querySelector?.(`[data-slot="${SETTINGS_TRIGGER_SLOT}"]`))
4056
+ const dialog = attemptValue(() => slot?.querySelector?.('button[aria-haspopup="dialog"]'))
4057
+ if (dialog !== null && dialog !== undefined) return dialog
4058
+ const own = attemptValue(() => slot?.querySelector?.('button'))
4059
+ if (own !== null && own !== undefined) return own
4060
+ return undefined
4061
+ }
4062
+
4063
+ /**
4064
+ * The power button, by contract attribute first and by the plugin's class name after it.
4065
+ * @param {object} document - the document.
4066
+ * @returns {object|undefined} the element a reader clicks to open the menu.
4067
+ */
4068
+ function powerTriggerOf(document) {
4069
+ const marked = attemptValue(() => document?.querySelector?.(`[${POWER_TRIGGER_ATTRIBUTE}]`))
4070
+ if (marked !== null && marked !== undefined) return marked
4071
+ const found = attemptValue(() => document?.getElementsByClassName?.(POWER_TRIGGER_CLASS)?.[0])
4072
+ return found === undefined ? undefined : found
4073
+ }
4074
+
2451
4075
  /**
2452
4076
  * The fragment a reader can read: percent escapes decoded, or the raw text when the
2453
4077
  * escapes are malformed. Display only — the router still writes the encoded form.
@@ -2487,6 +4111,34 @@ window.__ModuleLoader__.load({
2487
4111
  */
2488
4112
  function landedFragment(wanted, now) {
2489
4113
  if (String(wanted) === String(now)) return true
4114
+ // A power action is a command, not a place: nothing on screen can ever equal it, so
4115
+ // waiting for a match would make the bar cry "打不开" over the one or two seconds the
4116
+ // application takes to go away. Sending it *is* landing it.
4117
+ const wantedRoute = parseRoute(String(wanted))
4118
+ if (wantedRoute?.kind === 'power' && powerActionOf(wantedRoute.id) !== undefined) return true
4119
+ // The reader hands over what they read — a settings section is raw Chinese — while the
4120
+ // canonical form is percent-encoded, so one route has two spellings. Comparing the raw
4121
+ // strings called a link that had already landed "打不开": the overlay was open on that
4122
+ // very section while the bar said it could not open it (measured 2026-10-28).
4123
+ if (readableFragment(wanted) === readableFragment(now)) return true
4124
+ const nowRoute = parseRoute(String(now))
4125
+ if (wantedRoute === undefined || nowRoute === undefined) return false
4126
+ if (wantedRoute.unknown !== undefined || nowRoute.unknown !== undefined) return false
4127
+ // A section is a refinement of the overlay: a bare settings link is satisfied by any
4128
+ // section, and a named one by that same section.
4129
+ if (wantedRoute.kind === 'settings' && nowRoute.kind === 'settings') {
4130
+ const section = wantedRoute.id ?? ''
4131
+ if (section !== '' && section !== (nowRoute.id ?? '')) return false
4132
+ const wantedLevels = settingsPartsOf(wantedRoute.inner).levels
4133
+ const shownLevels = settingsPartsOf(nowRoute.inner).levels
4134
+ return wantedLevels.length <= shownLevels.length && wantedLevels.every((level, index) => level === shownLevels[index])
4135
+ }
4136
+ // A panel link may be wider than the page on screen (`plugins/<pkg>` for an open row).
4137
+ if (wantedRoute.kind === 'panel' && nowRoute.kind === 'panel' && wantedRoute.id === nowRoute.id) {
4138
+ const wantedPage = innerPath(wantedRoute.inner)
4139
+ const shownPage = innerPath(nowRoute.inner)
4140
+ if (wantedPage.length <= shownPage.length && wantedPage.every((part, index) => shownPage[index] === part)) return true
4141
+ }
2490
4142
  const target = targetSessionOf(wanted)
2491
4143
  return target !== undefined && target === targetSessionOf(now)
2492
4144
  }
@@ -2541,10 +4193,11 @@ window.__ModuleLoader__.load({
2541
4193
  * @returns {object|undefined} the trigger element, or undefined when absent.
2542
4194
  */
2543
4195
  function powerTrigger() {
2544
- if (typeof document?.querySelector !== 'function') return undefined
2545
- return document.querySelector('[data-dshpm-mount] .dshpm-trigger')
2546
- ?? document.querySelector('.dshpm-trigger')
2547
- ?? document.querySelector('[aria-label="电源菜单"]')
4196
+ const page = attemptValue(() => window.document)
4197
+ if (typeof page?.querySelector !== 'function') return undefined
4198
+ return page.querySelector('[data-dshpm-mount] .dshpm-trigger')
4199
+ ?? page.querySelector('.dshpm-trigger')
4200
+ ?? page.querySelector('[aria-label="电源菜单"]')
2548
4201
  ?? undefined
2549
4202
  }
2550
4203
 
@@ -2594,7 +4247,7 @@ window.__ModuleLoader__.load({
2594
4247
  try {
2595
4248
  style = document.createElement('style')
2596
4249
  style.id = TITLEBAR_STYLE_ID
2597
- style.textContent = macInHeader ? `${TITLEBAR_STYLE_TEXT}\n${MAC_STYLE_TEXT}` : TITLEBAR_STYLE_TEXT
4250
+ style.textContent = `${macInHeader ? `${TITLEBAR_STYLE_TEXT}\n${MAC_STYLE_TEXT}` : TITLEBAR_STYLE_TEXT}\n${RECENT_STYLE_TEXT}`
2598
4251
  const parent = document.head ?? document.body
2599
4252
  if (parent !== undefined && parent !== null && typeof parent.appendChild === 'function') parent.appendChild(style)
2600
4253
  } catch {
@@ -2612,6 +4265,16 @@ window.__ModuleLoader__.load({
2612
4265
  input.placeholder = TITLEBAR_PLACEHOLDER
2613
4266
  if (typeof input.setAttribute === 'function') input.setAttribute('aria-label', '地址栏 / address bar')
2614
4267
  bar.appendChild(input)
4268
+ // The recent list: the drop-down a browser shows when its address bar is focused.
4269
+ // It is a child of the bar so it moves with it and can never end up somewhere
4270
+ // else on a screen where the bar is repositioned.
4271
+ const recentList = document.createElement('div')
4272
+ if (typeof recentList.setAttribute === 'function') {
4273
+ recentList.setAttribute(RECENT_LIST_ATTRIBUTE, '')
4274
+ recentList.setAttribute('role', 'listbox')
4275
+ recentList.setAttribute('aria-label', '最近使用的链接 / recent links')
4276
+ }
4277
+ bar.appendChild(recentList)
2615
4278
  /**
2616
4279
  * The free stretch the bar was last fitted into.
2617
4280
  *
@@ -2672,6 +4335,8 @@ window.__ModuleLoader__.load({
2672
4335
  * macOS: sit in the free stretch of the header row, on the power button's
2673
4336
  * own line.
2674
4337
  */
4338
+ /** The shape the last placement was measured from, so an unchanged header is not re-walked. */
4339
+ let lastShape
2675
4340
  const place = () => {
2676
4341
  if (macInHeader) {
2677
4342
  // The bar lives on the header row, on the same line as the power
@@ -2684,6 +4349,20 @@ window.__ModuleLoader__.load({
2684
4349
  const row = macHeaderRow(trigger)
2685
4350
  const rowRect = row?.getBoundingClientRect?.()
2686
4351
  const anchored = rect !== undefined && rect !== null && rect.width > 0
4352
+ // The observer fires for every mutation on the page, and a streaming answer produces
4353
+ // them constantly: walking the row again when nothing that shaped the last placement
4354
+ // moved is pure waste (the walk reads a box per leaf, i.e. forces layout). A screen
4355
+ // with no row to measure is not cached this way — its band is the page's own content,
4356
+ // which changes without moving the row.
4357
+ const shape = [
4358
+ rowRect?.top, rowRect?.left, rowRect?.width, rowRect?.height,
4359
+ rect?.top, rect?.left, rect?.width,
4360
+ Math.round(globalThis.innerWidth ?? 0),
4361
+ (row?.textContent ?? '').length,
4362
+ ].join('|')
4363
+ if (anchored && shape === lastShape && bar.style.display !== 'none') return
4364
+ if (anchored) lastShape = shape
4365
+ workCounts.placements += 1
2687
4366
  // The row is only walked when there is a button to measure from.
2688
4367
  const gap = anchored ? macFreeGap(macLeaves(row, 0), rect, rowRect) : null
2689
4368
  if (gap === null) {
@@ -2703,11 +4382,30 @@ window.__ModuleLoader__.load({
2703
4382
  bar.style.display = 'none'
2704
4383
  return
2705
4384
  }
2706
- const box = fitBar(lastPlacement.gap, lastPlacement.top)
2707
- const left = Math.max(MAC_BAR_GAP, Math.min(box.left, viewport - box.width - MAC_BAR_GAP))
4385
+ const remembered = fitBar(lastPlacement.gap, lastPlacement.top)
4386
+ // Yield to whatever this screen draws on that line: the bar moves into a free run
4387
+ // of its own instead of being drawn across a control (measured: it sat straight
4388
+ // across the automation task's detail tabs).
4389
+ const runs = []
4390
+ let cursor = 0
4391
+ for (const span of bandSpans(document, bar, remembered.top, MAC_BAR_HEIGHT)) {
4392
+ if (span.left > cursor) runs.push({ left: cursor, right: span.left })
4393
+ cursor = Math.max(cursor, span.right)
4394
+ }
4395
+ if (viewport > cursor) runs.push({ left: cursor, right: viewport })
4396
+ let best = null
4397
+ for (const run of runs) {
4398
+ if (run.right - run.left < MAC_BAR_MIN_WIDTH) continue
4399
+ if (best === null || run.right - run.left > best.right - best.left) best = run
4400
+ }
4401
+ if (best === null) {
4402
+ bar.style.display = 'none'
4403
+ return
4404
+ }
4405
+ const box = fitBar(best, remembered.top)
2708
4406
  bar.style.display = ''
2709
4407
  bar.style.top = `${box.top}px`
2710
- bar.style.left = `${left}px`
4408
+ bar.style.left = `${box.left}px`
2711
4409
  bar.style.right = 'auto'
2712
4410
  bar.style.width = `${box.width}px`
2713
4411
  return
@@ -2744,7 +4442,7 @@ window.__ModuleLoader__.load({
2744
4442
  let placeFrame
2745
4443
  const schedulePlace = () => {
2746
4444
  if (placeFrame !== undefined) return
2747
- placeFrame = attemptValue(() => globalThis.requestAnimationFrame?.(() => {
4445
+ placeFrame = attemptValue(() => window.requestAnimationFrame?.(() => {
2748
4446
  placeFrame = undefined
2749
4447
  place()
2750
4448
  }))
@@ -2769,6 +4467,11 @@ window.__ModuleLoader__.load({
2769
4467
  show(window.location?.hash)
2770
4468
  /** Whether the view has arrived where the link asked, judged by what is on screen. */
2771
4469
  const arrived = wanted => {
4470
+ // A command is not a place: nothing on screen can ever match it, so it counts as
4471
+ // arrived the moment it is sent — otherwise the bar would cry "打不开" over the
4472
+ // second or two the application takes to go away (measured 2026-10-29).
4473
+ const typed = parseRoute(String(wanted))
4474
+ if (typed?.kind === 'power' && powerActionOf(typed.id) !== undefined) return true
2772
4475
  const report = attemptValue(() => window.__dshUrlRouter?.report?.())
2773
4476
  const canonical = report?.canonical
2774
4477
  if (canonical === undefined || canonical === null) return false
@@ -2810,14 +4513,294 @@ window.__ModuleLoader__.load({
2810
4513
  input.value = why
2811
4514
  }, TITLEBAR_SETTLE_MS))
2812
4515
  }
4516
+ /**
4517
+ * The chip a remembered link is shown with: what kind of screen it names.
4518
+ * @param {object|undefined} route - the decoded route.
4519
+ * @returns {string} the chip's text.
4520
+ */
4521
+ const recentKind = route => {
4522
+ if (route === undefined || route.unknown !== undefined) return '链接'
4523
+ if (route.kind === 'session') return '会话'
4524
+ if (route.kind === 'workspace') return '工作区'
4525
+ if (route.kind === 'panel') return '面板'
4526
+ if (route.kind === 'settings') return '设置'
4527
+ return '链接'
4528
+ }
4529
+ /** Hide the remembered links. */
4530
+ const hideRecent = () => {
4531
+ if (typeof recentList.removeAttribute === 'function') recentList.removeAttribute('data-open')
4532
+ }
4533
+ /**
4534
+ * Whether the reader has edited the text since the bar was focused.
4535
+ *
4536
+ * The list is narrowed only by text the reader typed. The field always holds the
4537
+ * current link when it is merely focused, and filtering by that would hide every
4538
+ * remembered screen but the one on display.
4539
+ */
4540
+ let narrowed = false
4541
+ /** Empty the list before it is drawn again. */
4542
+ const emptyRecent = () => {
4543
+ while (recentList.firstChild !== null && recentList.firstChild !== undefined) {
4544
+ if (typeof recentList.removeChild !== 'function') break
4545
+ recentList.removeChild(recentList.firstChild)
4546
+ }
4547
+ }
4548
+ /**
4549
+ * The absolute URL a remembered link stands for.
4550
+ *
4551
+ * A copied link has to survive the trip out of this window, so it carries the path
4552
+ * and the launch query (`?token=…`) exactly the way a row anchor's own "copy link
4553
+ * address" does — the bare fragment only works inside an already-open page.
4554
+ * @param {string} hash - the fragment.
4555
+ * @returns {string} the URL to copy.
4556
+ */
4557
+ const absoluteLink = hash => {
4558
+ const location = window.location ?? {}
4559
+ return `${location.origin ?? ''}${location.pathname ?? ''}${location.search ?? ''}${hash}`
4560
+ }
4561
+ /** Set while the copy's throw-away field borrows focus, so the bar ignores the swap. */
4562
+ let quietFocus = false
4563
+ /**
4564
+ * Run something without the bar reading it as the reader leaving or entering the
4565
+ * field: the copy must neither close the list nor re-select the link.
4566
+ * @param {Function} run - what to run.
4567
+ * @returns {*} whatever it returned.
4568
+ */
4569
+ const quietly = run => {
4570
+ quietFocus = true
4571
+ try {
4572
+ return run()
4573
+ } finally {
4574
+ quietFocus = false
4575
+ }
4576
+ }
4577
+ /**
4578
+ * Put text on the clipboard the old way: select it in a throw-away field and let the
4579
+ * command copy it. Used when the window has no clipboard service, or refuses the
4580
+ * async call — and the reader is put back in the field afterwards, because the field
4581
+ * was only there to be copied from.
4582
+ * @param {string} text - what to copy.
4583
+ * @returns {boolean} whether the copy was accepted.
4584
+ */
4585
+ const copyBySelection = text => quietly(() => attemptValue(() => {
4586
+ const wasFocused = attemptValue(() => document.activeElement)
4587
+ const area = document.createElement('textarea')
4588
+ area.value = text
4589
+ area.setAttribute?.('readonly', '')
4590
+ if (area.style !== undefined) {
4591
+ area.style.position = 'fixed'
4592
+ area.style.top = '-1000px'
4593
+ }
4594
+ document.body?.appendChild?.(area)
4595
+ area.focus?.()
4596
+ area.select?.()
4597
+ const done = typeof document.execCommand === 'function' && document.execCommand('copy') === true
4598
+ area.remove?.()
4599
+ if (wasFocused === input) attemptValue(() => input.focus?.())
4600
+ return done
4601
+ }, false) ?? false)
4602
+ /**
4603
+ * Copy a remembered link.
4604
+ * @param {string} hash - the fragment to copy.
4605
+ * @returns {Promise<boolean>|boolean} whether the copy was accepted.
4606
+ */
4607
+ const copyLink = hash => {
4608
+ const text = absoluteLink(hash)
4609
+ const clipboard = attemptValue(() => globalThis.navigator?.clipboard)
4610
+ if (clipboard !== undefined && clipboard !== null && typeof clipboard.writeText === 'function') {
4611
+ let pending
4612
+ try {
4613
+ pending = Promise.resolve(clipboard.writeText(text))
4614
+ } catch {
4615
+ return copyBySelection(text)
4616
+ }
4617
+ // A window that refuses the async call still has the old path. (The fallback
4618
+ // is a function call inside the handler on purpose: evaluating it up front
4619
+ // would run — and focus a throw-away field — on every single copy.)
4620
+ return pending.then(() => true, () => copyBySelection(text))
4621
+ }
4622
+ return copyBySelection(text)
4623
+ }
4624
+ /** Timers that put a "copied" row back, so disposal can clear them. */
4625
+ const copiedTimers = new Set()
4626
+ /** The bar's own confirmation chip: it stays visible even if the list had to close. */
4627
+ const toast = document.createElement('span')
4628
+ toast.className = 'dshur-toast'
4629
+ if (typeof toast.setAttribute === 'function') {
4630
+ toast.setAttribute(TOAST_ATTRIBUTE, '')
4631
+ toast.setAttribute('aria-live', 'polite')
4632
+ }
4633
+ bar.appendChild(toast)
4634
+ let toastTimer
4635
+ /**
4636
+ * Show one short confirmation in the bar.
4637
+ * @param {string} text - what to say.
4638
+ */
4639
+ const flashToast = text => {
4640
+ toast.textContent = text
4641
+ toast.setAttribute?.('data-show', '')
4642
+ if (toastTimer !== undefined) attemptValue(() => window.clearTimeout(toastTimer))
4643
+ toastTimer = attemptValue(() => window.setTimeout(() => {
4644
+ toastTimer = undefined
4645
+ toast.removeAttribute?.('data-show')
4646
+ toast.textContent = ''
4647
+ }, 1200))
4648
+ }
4649
+ /**
4650
+ * Say so on the row that was copied, then put the link back.
4651
+ * @param {object} item - the remembered-link row.
4652
+ * @param {object} link - that row's link text.
4653
+ */
4654
+ const flashCopied = (item, link) => {
4655
+ const text = readableFragment(attemptValue(() => item.getAttribute(RECENT_ITEM_ATTRIBUTE)) ?? '')
4656
+ link.textContent = '已复制 ✓'
4657
+ item.setAttribute?.('data-copied', '')
4658
+ const timer = attemptValue(() => window.setTimeout(() => {
4659
+ copiedTimers.delete(timer)
4660
+ // A redraw may have replaced the row already; only a live one is restored.
4661
+ link.textContent = text
4662
+ item.removeAttribute?.('data-copied')
4663
+ }, 1200))
4664
+ if (timer !== undefined) copiedTimers.add(timer)
4665
+ }
4666
+ /**
4667
+ * Draw the remembered links: the screens this bar named and the links pasted into
4668
+ * it, newest first, narrowed by whatever is typed. Nothing to show means no list.
4669
+ */
4670
+ const drawRecent = () => {
4671
+ const typed = narrowed ? String(input.value ?? '').trim().toLowerCase() : ''
4672
+ // One scan of the sidebar for the whole list: asking per entry re-walked every row
4673
+ // once per item (and twice per item, since the filter asks too).
4674
+ const titles = sessionTitles()
4675
+ // `recentEntries()` already leaves out the screen on display, so a remembered
4676
+ // link is only narrowed here by what the reader typed.
4677
+ const shown = recentEntries().filter(entry => {
4678
+ if (typed === '') return true
4679
+ return `${entry.hash}\n${recentLabel(entry.hash, titles)}`.toLowerCase().includes(typed)
4680
+ })
4681
+ emptyRecent()
4682
+ for (const entry of shown) {
4683
+ const label = recentLabel(entry.hash, titles)
4684
+ const item = document.createElement('button')
4685
+ item.type = 'button'
4686
+ if (typeof item.setAttribute === 'function') {
4687
+ item.setAttribute(RECENT_ITEM_ATTRIBUTE, entry.hash)
4688
+ item.setAttribute('role', 'option')
4689
+ item.setAttribute('title', `${entry.source === 'paste' ? '粘贴的链接' : '用过的界面'}:${readableFragment(entry.hash)}(右键复制链接)`)
4690
+ }
4691
+ const kind = document.createElement('span')
4692
+ kind.className = 'dshur-kind'
4693
+ kind.textContent = recentKind(parseRoute(entry.hash))
4694
+ item.appendChild(kind)
4695
+ if (label !== '') {
4696
+ const title = document.createElement('span')
4697
+ title.className = 'dshur-title'
4698
+ title.textContent = label
4699
+ item.appendChild(title)
4700
+ }
4701
+ const link = document.createElement('span')
4702
+ link.className = 'dshur-link'
4703
+ link.textContent = readableFragment(entry.hash)
4704
+ item.appendChild(link)
4705
+ item.addEventListener('mousedown', event => {
4706
+ // The click belongs to this list: the input has to keep focus, or the
4707
+ // press would blur it first and the list would be gone before the click.
4708
+ attemptValue(() => event?.preventDefault?.())
4709
+ })
4710
+ item.addEventListener('click', () => {
4711
+ narrowed = false
4712
+ hideRecent()
4713
+ mark('pending', undefined)
4714
+ // The box says where it is going at once; the settle below corrects it to
4715
+ // what actually landed (or says why nothing did).
4716
+ input.value = readableFragment(entry.hash)
4717
+ driveTypedFragment(entry.hash)
4718
+ settle(entry.hash, false)
4719
+ })
4720
+ item.addEventListener('contextmenu', event => {
4721
+ // Right-click copies instead of opening a menu: this list is the one place
4722
+ // where "give me this link" is the action a reader wants, and the browser's
4723
+ // own menu would only offer the same thing one level deeper.
4724
+ attemptValue(() => event?.preventDefault?.())
4725
+ const confirm = accepted => {
4726
+ if (accepted !== true) return
4727
+ flashCopied(item, link)
4728
+ flashToast('已复制链接')
4729
+ }
4730
+ const copied = copyLink(entry.hash)
4731
+ if (typeof copied?.then === 'function') copied.then(confirm)
4732
+ else confirm(copied)
4733
+ })
4734
+ recentList.appendChild(item)
4735
+ }
4736
+ if (shown.length === 0) {
4737
+ hideRecent()
4738
+ return
4739
+ }
4740
+ if (typeof recentList.setAttribute === 'function') recentList.setAttribute('data-open', '')
4741
+ }
4742
+ /**
4743
+ * Select everything in the bar, the way a browser's address bar does on its first
4744
+ * click. Off means the reader keeps the caret an ordinary text field gives them.
4745
+ * @returns {boolean} whether the text was selected.
4746
+ */
4747
+ const selectAll = () => {
4748
+ if (!readSettings().selectAllOnFocus) return false
4749
+ return attemptValue(() => {
4750
+ if (typeof input.select !== 'function') return false
4751
+ input.select()
4752
+ return true
4753
+ }, false)
4754
+ }
4755
+ const onFocus = () => {
4756
+ // The copy's own field came and went: this is not the reader focusing the bar.
4757
+ if (quietFocus) return
4758
+ narrowed = false
4759
+ revealBar()
4760
+ selectAll()
4761
+ drawRecent()
4762
+ }
4763
+ const onMouseDown = event => {
4764
+ // Already focused: this press is placing a caret inside the link.
4765
+ if (document.activeElement === input) return
4766
+ if (!readSettings().selectAllOnFocus) return
4767
+ narrowed = false
4768
+ // The default of this very press would drop the selection made below.
4769
+ attemptValue(() => event?.preventDefault?.())
4770
+ attemptValue(() => input.focus?.())
4771
+ selectAll()
4772
+ drawRecent()
4773
+ }
4774
+ const onPaste = event => {
4775
+ const pasted = attemptValue(() => event?.clipboardData?.getData?.('text'))
4776
+ const fragment = attemptValue(() => titlebarFragmentOf(pasted))
4777
+ // Remembered as soon as it is a link this bar can open: a link that is already
4778
+ // on screen writes no route later, and would otherwise be forgotten entirely.
4779
+ if (typeof fragment === 'string' && fragment !== '') rememberRecent(fragment, 'paste')
4780
+ }
4781
+ const onInput = () => {
4782
+ narrowed = true
4783
+ drawRecent()
4784
+ }
4785
+ const onBlur = () => {
4786
+ if (quietFocus) return
4787
+ narrowed = false
4788
+ hideRecent()
4789
+ // The pointer may still be on the bar; concealBar() checks that and the list.
4790
+ attemptValue(() => window.setTimeout?.(concealBar, 0))
4791
+ }
2813
4792
  const onKeyDown = event => {
2814
4793
  if (event?.key === 'Escape') {
2815
4794
  stopSettle()
4795
+ narrowed = false
4796
+ hideRecent()
2816
4797
  mark('idle', undefined)
2817
4798
  input.value = readableFragment(window.location?.hash)
2818
4799
  return
2819
4800
  }
2820
4801
  if (event?.key !== 'Enter') return
4802
+ narrowed = false
4803
+ hideRecent()
2821
4804
  const fragment = titlebarFragmentOf(input.value)
2822
4805
  if (fragment === undefined) {
2823
4806
  // Nothing here names a route: leave the address bar saying what is on screen.
@@ -2833,7 +4816,47 @@ window.__ModuleLoader__.load({
2833
4816
  driveTypedFragment(fragment)
2834
4817
  settle(fragment, false)
2835
4818
  }
4819
+ /** Whether the bar is asked to stay out of sight until the pointer is on it. */
4820
+ const hoverMode = () => readSettings().hoverReveal === true
4821
+ /** Whether the drop-down is up (a reveal must outlive the pointer leaving). */
4822
+ const recentIsOpen = () => attemptValue(() => recentList.getAttribute?.('data-open') !== null, false)
4823
+ /** Whether the reader is typing in the bar. */
4824
+ const inputIsFocused = () => attemptValue(() => window.document?.activeElement === input, false)
4825
+ /** Show the bar; only meaningful in hover mode (the stylesheet does the rest). */
4826
+ const revealBar = () => {
4827
+ if (!hoverMode()) return
4828
+ attemptValue(() => bar.setAttribute?.(SHOWN_ATTRIBUTE, ''))
4829
+ }
4830
+ /** Hide it again — unless a focused field or an open list still needs it visible. */
4831
+ const concealBar = () => {
4832
+ if (!hoverMode()) return
4833
+ if (inputIsFocused() || recentIsOpen()) return
4834
+ attemptValue(() => bar.removeAttribute?.(SHOWN_ATTRIBUTE))
4835
+ }
4836
+ /**
4837
+ * Put the bar into the state the preference asks for. Called once at mount, again
4838
+ * whenever the preference changes, and never while a route is being written (the
4839
+ * bar's contents are independent of whether it is visible).
4840
+ */
4841
+ const applyHoverMode = () => {
4842
+ attemptValue(() => {
4843
+ if (hoverMode()) bar.setAttribute?.(HOVER_ATTRIBUTE, '')
4844
+ else {
4845
+ bar.removeAttribute?.(HOVER_ATTRIBUTE)
4846
+ bar.removeAttribute?.(SHOWN_ATTRIBUTE)
4847
+ }
4848
+ })
4849
+ }
4850
+ bar.addEventListener?.('mouseenter', revealBar)
4851
+ bar.addEventListener?.('mouseleave', concealBar)
2836
4852
  input.addEventListener('keydown', onKeyDown)
4853
+ input.addEventListener('focus', onFocus)
4854
+ input.addEventListener('mousedown', onMouseDown)
4855
+ input.addEventListener('input', onInput)
4856
+ input.addEventListener('paste', onPaste)
4857
+ input.addEventListener('blur', onBlur)
4858
+ applyHoverMode()
4859
+ const offHoverSettings = subscribeSettings(() => applyHoverMode())
2837
4860
  // Both platforms mount the bar in the body: Windows pins it into the
2838
4861
  // title-bar strip, macOS pins it over the resident header row.
2839
4862
  document.body.appendChild(bar)
@@ -2847,9 +4870,12 @@ window.__ModuleLoader__.load({
2847
4870
  * node, so what was typed into it survives.
2848
4871
  */
2849
4872
  let headerWatcher
2850
- if (macInHeader && typeof MutationObserver === 'function') {
4873
+ // The window owns both, so a page that replaces `document` (or a host with its own
4874
+ // observers) still gets the watcher; a global-only read silently found nothing.
4875
+ const Observer = attemptValue(() => window.MutationObserver)
4876
+ if (macInHeader && typeof Observer === 'function') {
2851
4877
  try {
2852
- headerWatcher = new MutationObserver(() => schedulePlace())
4878
+ headerWatcher = new Observer(() => schedulePlace())
2853
4879
  headerWatcher.observe(document.body, { childList: true, subtree: true })
2854
4880
  } catch {
2855
4881
  headerWatcher = undefined
@@ -2866,7 +4892,19 @@ window.__ModuleLoader__.load({
2866
4892
  return () => {
2867
4893
  offRoute?.()
2868
4894
  stopSettle()
4895
+ if (placeFrame !== undefined) attemptValue(() => window.cancelAnimationFrame?.(placeFrame))
2869
4896
  input.removeEventListener?.('keydown', onKeyDown)
4897
+ input.removeEventListener?.('focus', onFocus)
4898
+ input.removeEventListener?.('mousedown', onMouseDown)
4899
+ input.removeEventListener?.('input', onInput)
4900
+ input.removeEventListener?.('paste', onPaste)
4901
+ input.removeEventListener?.('blur', onBlur)
4902
+ bar.removeEventListener?.('mouseenter', revealBar)
4903
+ bar.removeEventListener?.('mouseleave', concealBar)
4904
+ offHoverSettings?.()
4905
+ for (const timer of copiedTimers) attemptValue(() => window.clearTimeout(timer))
4906
+ copiedTimers.clear()
4907
+ if (toastTimer !== undefined) attemptValue(() => window.clearTimeout(toastTimer))
2870
4908
  if (onResize !== undefined) window.removeEventListener?.('resize', onResize)
2871
4909
  headerWatcher?.disconnect?.()
2872
4910
  bar.remove?.()
@@ -2874,6 +4912,173 @@ window.__ModuleLoader__.load({
2874
4912
  }
2875
4913
  }
2876
4914
 
4915
+ // ---------------------------------------------------------------------
4916
+ // settings page: the plugin manager's row page is this plugin's settings surface
4917
+ //
4918
+ // The manager dispatches a row's configuration under `<package name>#<row id>` and
4919
+ // renders the entry twice: once as the row's one-line summary and once as the page
4920
+ // the row's configure control opens. Registering here is what turns
4921
+ // `#/panel/plugins/dsh-url-router/dsh-url-router` from a read-only overview into the
4922
+ // page these two preferences are set on — the same shape every other plugin in this
4923
+ // profile has, instead of a settings tab of its own.
4924
+ // ---------------------------------------------------------------------
4925
+ /** The card's stylesheet id, so a re-apply replaces its own rather than stacking copies. */
4926
+ const SETTINGS_STYLE_ID = 'dsh-url-router-settings-style'
4927
+ /** The card's own styles: the manager's class names are hashed, so it cannot borrow them. */
4928
+ const SETTINGS_STYLE_TEXT = [
4929
+ '.dshur{display:flex;flex-direction:column;gap:8px;color:var(--dsw-alias-label-primary,inherit);font-size:13px;line-height:20px}',
4930
+ '.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}',
4931
+ '.dshur-row{display:flex;align-items:center;gap:10px;justify-content:space-between}',
4932
+ '.dshur-label{display:flex;align-items:center;gap:8px;font-weight:500;cursor:pointer}',
4933
+ '.dshur-check{width:16px;height:16px;margin:0;accent-color:var(--dsw-alias-brand-primary,#4d6bfe)}',
4934
+ '.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}',
4935
+ '.dshur-number:focus{outline:none;border-color:var(--dsw-alias-brand-primary,#4d6bfe)}',
4936
+ '.dshur-state{color:var(--dsw-alias-label-secondary,#4b5872);font-size:12px}',
4937
+ '.dshur-hint{margin:0;color:var(--dsw-alias-label-tertiary,#81858c);font-size:12px;line-height:18px}',
4938
+ '.dshur-meta{display:flex;align-items:center;gap:10px;color:var(--dsw-alias-label-secondary,#4b5872);font-size:12px}',
4939
+ '.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}',
4940
+ '.dshur-clear:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.14))}',
4941
+ ].join('')
4942
+
4943
+ /**
4944
+ * The settings card on this plugin's row page.
4945
+ *
4946
+ * The manager calls it twice: with `view: 'summary'` for the row's one-liner (it must
4947
+ * stay cheap — it renders on every row paint and must not touch the disk) and with the
4948
+ * page's own view for the settings themselves.
4949
+ * @param {object} props - slot props, `view` names which of the two is wanted.
4950
+ * @returns {object|string} the element to draw, or the one-liner.
4951
+ */
4952
+ function RouterSettingsCard({ view }) {
4953
+ const h = react.createElement
4954
+ const [settings, setSettings] = react.useState(readSettings())
4955
+ const [draft, setDraft] = react.useState(String(readSettings().recentLimit))
4956
+ const [remembered, setRemembered] = react.useState(() => recentEntries().length)
4957
+ react.useEffect(() => subscribeSettings(next => {
4958
+ setSettings(next)
4959
+ setDraft(String(next.recentLimit))
4960
+ setRemembered(recentEntries().length)
4961
+ }), [])
4962
+ if (view === 'summary') {
4963
+ return `全选 ${settings.selectAllOnFocus ? '开' : '关'} · 悬浮显示 ${settings.hoverReveal ? '开' : '关'} · 记忆 ${settings.recentLimit} 条`
4964
+ }
4965
+ /**
4966
+ * Take a typed count: an empty field, a minus sign or anything else unusable is a
4967
+ * half-typed value and must not be committed as 0.
4968
+ * @param {string} text - what the field holds.
4969
+ */
4970
+ const commitCount = text => {
4971
+ setDraft(text)
4972
+ const number = Number(text)
4973
+ if (String(text).trim() === '' || !Number.isFinite(number) || number < 0) return
4974
+ setSettings(updateSettings({ recentLimit: Math.floor(number) }))
4975
+ }
4976
+ return h('div', { className: 'dshur' }, [
4977
+ h('div', { className: 'dshur-block', key: 'select' }, [
4978
+ h('div', { className: 'dshur-row' }, [
4979
+ h('label', { className: 'dshur-label' }, [
4980
+ h('input', {
4981
+ type: 'checkbox',
4982
+ className: 'dshur-check',
4983
+ checked: settings.selectAllOnFocus,
4984
+ onChange: event => setSettings(updateSettings({ selectAllOnFocus: event?.target?.checked === true })),
4985
+ }),
4986
+ h('span', null, '首次点击全选链接'),
4987
+ ]),
4988
+ h('span', { className: 'dshur-state' }, settings.selectAllOnFocus ? '开' : '关'),
4989
+ ]),
4990
+ h('p', { className: 'dshur-hint' }, '第一次点进去整条选中,直接输入就替换;已在框内再点,才放光标。'),
4991
+ ]),
4992
+ h('div', { className: 'dshur-block', key: 'hover' }, [
4993
+ h('div', { className: 'dshur-row' }, [
4994
+ h('label', { className: 'dshur-label' }, [
4995
+ h('input', {
4996
+ type: 'checkbox',
4997
+ className: 'dshur-check',
4998
+ checked: settings.hoverReveal,
4999
+ onChange: event => setSettings(updateSettings({ hoverReveal: event?.target?.checked === true })),
5000
+ }),
5001
+ h('span', null, '悬浮时才显示地址栏'),
5002
+ ]),
5003
+ h('span', { className: 'dshur-state' }, settings.hoverReveal ? '开' : '关'),
5004
+ ]),
5005
+ h('p', { className: 'dshur-hint' }, '平时隐去,鼠标移到地址栏的位置才出现。'),
5006
+ ]),
5007
+ h('div', { className: 'dshur-block', key: 'recent' }, [
5008
+ h('div', { className: 'dshur-row' }, [
5009
+ h('label', { className: 'dshur-label', htmlFor: 'dshur-recent-limit' }, '记住最近链接的条数'),
5010
+ h('input', {
5011
+ id: 'dshur-recent-limit',
5012
+ type: 'number',
5013
+ min: 0,
5014
+ step: 1,
5015
+ inputMode: 'numeric',
5016
+ className: 'dshur-number',
5017
+ value: draft,
5018
+ onChange: event => commitCount(event?.target?.value ?? ''),
5019
+ onBlur: () => setDraft(String(readSettings().recentLimit)),
5020
+ }),
5021
+ ]),
5022
+ h('p', { className: 'dshur-hint' }, '下拉框里能看到的条数,默认 8;0 = 不记忆、不弹列表;没有上限。当前界面不占名额。'),
5023
+ h('p', { className: 'dshur-meta' }, [
5024
+ h('span', { key: 'count' }, `已记住 ${remembered} 条`),
5025
+ h('button', {
5026
+ type: 'button',
5027
+ className: 'dshur-clear',
5028
+ key: 'clear',
5029
+ onClick: () => {
5030
+ clearRecent()
5031
+ setRemembered(0)
5032
+ },
5033
+ }, '清空记录'),
5034
+ ]),
5035
+ ]),
5036
+ ])
5037
+ }
5038
+
5039
+ /**
5040
+ * Register the settings card with the plugin manager.
5041
+ *
5042
+ * A composition without the manager, without the slot service, or without React has
5043
+ * no card and needs none: nothing else in the plugin depends on this.
5044
+ * @param {object} ctx - client context.
5045
+ * @returns {() => void} the disposer.
5046
+ */
5047
+ function startSettingsCard(ctx) {
5048
+ if (react === undefined || typeof window === 'undefined' || window.document === undefined) return () => {}
5049
+ const slots = ctx?.slots
5050
+ if (typeof slots?.inject !== 'function' || typeof slots?.register !== 'function') return () => {}
5051
+ const document = window.document
5052
+ let style
5053
+ try {
5054
+ style = document.createElement('style')
5055
+ style.id = SETTINGS_STYLE_ID
5056
+ style.textContent = SETTINGS_STYLE_TEXT
5057
+ const parent = document.head ?? document.body
5058
+ if (typeof parent?.appendChild === 'function') parent.appendChild(style)
5059
+ } catch {
5060
+ style = undefined
5061
+ }
5062
+ let off
5063
+ try {
5064
+ off = slots.inject('plugins.row.config', () => slots.register({
5065
+ name: 'plugins.row.config',
5066
+ key: ROW_CONFIG_KEY,
5067
+ }, RouterSettingsCard))
5068
+ } catch {
5069
+ // A page that never declares the slot is a page without a card, not a failure.
5070
+ off = undefined
5071
+ }
5072
+ return () => {
5073
+ try {
5074
+ if (typeof off === 'function') off()
5075
+ } catch {
5076
+ // A slot service that is already gone needs no undo.
5077
+ }
5078
+ style?.remove?.()
5079
+ }
5080
+ }
5081
+
2877
5082
  function apply(ctx) {
2878
5083
  // Fragment routing: the address bar names the view, and a link opens it.
2879
5084
  ctx.effect(() => {
@@ -2928,6 +5133,10 @@ window.__ModuleLoader__.load({
2928
5133
  if (typeof window === 'undefined' || window.document === undefined) return () => {}
2929
5134
  return startTitlebarAddress(window.document, subscribeRoute)
2930
5135
  }, 'url-router: desktop address bar')
5136
+ // Settings page: the plugin manager's row page carries the two preferences, so
5137
+ // there is no separate settings tab to find them in.
5138
+ ctx.effect(() => startSettingsCard(ctx), 'url-router: settings card')
5139
+
2931
5140
  }
2932
5141
 
2933
5142
  exports.apply = apply