dsh-url-router 0.4.2 → 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.
package/lib/client.js CHANGED
@@ -78,7 +78,69 @@ window.__ModuleLoader__.load({
78
78
  // another router's route may legitimately be pasted in.
79
79
  // ---------------------------------------------------------------------
80
80
  /** Route kind aliases accepted on input. */
81
- 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'
82
144
  /** Attribute the settings overlay puts on its root element. */
83
145
  const SETTINGS_SURFACE_ATTRIBUTE = 'data-dsh-surface'
84
146
  /**
@@ -93,6 +155,8 @@ window.__ModuleLoader__.load({
93
155
  const SETTINGS_SURFACE_VALUE = 'settings'
94
156
  /** Slot the settings entry is rendered into (its button opens the overlay). */
95
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'
96
160
  /** Canonical fragment shape, plus the bare `#/<kind>` form a modal surface uses. */
97
161
  const ROUTE_PATTERN = /^#\/?([a-z][a-z0-9-]*)(?:\/(.+))?$/
98
162
  /** How often an unsatisfied link is retried while it is authoritative. */
@@ -137,6 +201,48 @@ window.__ModuleLoader__.load({
137
201
  const TURN_SEGMENT = 'turn'
138
202
  /** Inner segment that names the right-hand pane in front. */
139
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
+ }
140
246
  /** Service the right-hand pane registers, whose snapshot names the active tab. */
141
247
  const SIDEBAR_RIGHT_SERVICE = 'sidebarRightTabs'
142
248
  /** Panel id of the official plugin manager, whose list rows and detail pages are routed. */
@@ -166,14 +272,14 @@ window.__ModuleLoader__.load({
166
272
  * The router's own version, for the report a human files. A test keeps it equal to the
167
273
  * package version, so a release cannot forget to bump it.
168
274
  */
169
- const ROUTER_VERSION = '0.4.2'
275
+ const ROUTER_VERSION = '0.4.3'
170
276
  /**
171
277
  * Hand-maintained build marker, reported alongside the version. A working copy that is
172
278
  * linked into a profile serves this file straight from disk, so the package version
173
279
  * cannot say *which* copy a live window loaded — this can, and `report().build` is how a
174
280
  * real-machine check answers that. Bump it whenever a live window must be told apart.
175
281
  */
176
- const ROUTER_BUILD = 'desktop-bar+settings-marker+modal-dismiss+mac-header-bar+bar-select-all+recent-links+copy-link+automation-links+page-over-pane+band-yield+provider-card+perf+compat+turn-jump.12'
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'
177
283
 
178
284
  const TAG = '[url-router]'
179
285
 
@@ -228,11 +334,22 @@ window.__ModuleLoader__.load({
228
334
  * has no power button. A couple of pixels is rounding, more is a resize.
229
335
  */
230
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'
231
346
  const MAC_STYLE_TEXT = [
232
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}`,
233
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}`,
234
349
  `#${TITLEBAR_BAR_ID}[data-dsh-url-router-platform="mac"] input:focus{border-color:var(--dsw-alias-brand-primary,rgba(79,124,255,.9))}`,
235
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}`,
236
353
  ].join('')
237
354
  /** Where the bar's last spot is kept, so a screen without a power button still gets it. */
238
355
  const MAC_PLACEMENT_KEY = 'dsh-url-router-placement'
@@ -424,6 +541,8 @@ window.__ModuleLoader__.load({
424
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}`,
425
542
  `#${TITLEBAR_BAR_ID} input:focus{border-color:var(--dsw-alias-brand-primary,rgba(79,124,255,.9))}`,
426
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}`,
427
546
  ].join('')
428
547
  /**
429
548
  * The recent list's own stylesheet: a browser-style drop-down under the bar.
@@ -492,7 +611,7 @@ window.__ModuleLoader__.load({
492
611
  /** Remembered links, newest first. */
493
612
  const RECENT_KEY = 'dsh-url-router-recent'
494
613
  /** What the bar does before the settings page has ever been opened. */
495
- const DEFAULT_SETTINGS = { selectAllOnFocus: true, recentLimit: 8 }
614
+ const DEFAULT_SETTINGS = { selectAllOnFocus: true, hoverReveal: false, recentLimit: 8 }
496
615
  /**
497
616
  * The key the plugin manager dispatches a row's configuration under:
498
617
  * `<package name>#<row id>`. This package declares one row whose package name and
@@ -516,13 +635,14 @@ window.__ModuleLoader__.load({
516
635
  /**
517
636
  * The settings record, with anything unusable replaced by its default.
518
637
  * @param {unknown} raw - the parsed record, or anything that was on disk.
519
- * @returns {{selectAllOnFocus: boolean, recentLimit: number}} the record.
638
+ * @returns {{selectAllOnFocus: boolean, hoverReveal: boolean, recentLimit: number}} the record.
520
639
  */
521
640
  function normaliseSettings(raw) {
522
641
  const source = raw !== null && typeof raw === 'object' ? raw : {}
523
642
  const limit = Number(source.recentLimit)
524
643
  return {
525
644
  selectAllOnFocus: typeof source.selectAllOnFocus === 'boolean' ? source.selectAllOnFocus : DEFAULT_SETTINGS.selectAllOnFocus,
645
+ hoverReveal: typeof source.hoverReveal === 'boolean' ? source.hoverReveal : DEFAULT_SETTINGS.hoverReveal,
526
646
  // A count of remembered links: whole, never negative, and with no upper bound
527
647
  // the reader did not ask for.
528
648
  recentLimit: Number.isFinite(limit) && limit >= 0 ? Math.floor(limit) : DEFAULT_SETTINGS.recentLimit,
@@ -531,7 +651,7 @@ window.__ModuleLoader__.load({
531
651
 
532
652
  /**
533
653
  * Read the settings, once.
534
- * @returns {{selectAllOnFocus: boolean, recentLimit: number}} the record in force.
654
+ * @returns {{selectAllOnFocus: boolean, hoverReveal: boolean, recentLimit: number}} the record in force.
535
655
  */
536
656
  function readSettings() {
537
657
  if (settingsCache !== undefined) return settingsCache
@@ -620,6 +740,8 @@ window.__ModuleLoader__.load({
620
740
  function recentIdentity(hash) {
621
741
  const route = parseRoute(String(hash ?? ''))
622
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 ''
623
745
  if (route.kind === 'workspace') {
624
746
  const inner = innerPath(route.inner)
625
747
  return inner.length >= 2 ? `${ROW_KEY_PREFIX}${inner[1]}` : `${route.kind}:${route.id}`
@@ -778,6 +900,30 @@ window.__ModuleLoader__.load({
778
900
  return ids.length === 0 ? undefined : ids
779
901
  }
780
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
+
781
927
  /**
782
928
  * Read a value, or undefined when reading it throws.
783
929
  * @param {Function} read - the reader.
@@ -1106,11 +1252,134 @@ window.__ModuleLoader__.load({
1106
1252
  * @param {string|Array<string>} inner - the inner path.
1107
1253
  * @returns {{tab: string|undefined, provider: string|undefined}} the parts.
1108
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
+
1109
1378
  function settingsPartsOf(inner) {
1110
1379
  const parts = innerPath(inner)
1111
1380
  const at = parts.indexOf(PROVIDER_SEGMENT)
1112
- if (at < 0) return { tab: parts[0], provider: undefined }
1113
- return { tab: at > 0 ? parts[0] : undefined, provider: parts[at + 1] }
1381
+ const levels = at < 0 ? parts : parts.slice(0, at)
1382
+ return { levels, provider: at < 0 ? undefined : parts[at + 1] }
1114
1383
  }
1115
1384
 
1116
1385
  /**
@@ -1269,7 +1538,7 @@ window.__ModuleLoader__.load({
1269
1538
  const kind = ROUTE_ALIASES[match[1]]
1270
1539
  if (kind === undefined) return { unknown: match[1] }
1271
1540
  // A modal surface needs no identity: `#/settings` is the whole route.
1272
- if (match[2] === undefined) return kind === 'settings' ? { kind, id: '' } : undefined
1541
+ if (match[2] === undefined) return kind === 'settings' || kind === 'power' ? { kind, id: '' } : undefined
1273
1542
  // A conversation can be named inside its workspace — the shape the sidebar itself shows,
1274
1543
  // and the only way a link says *where* a conversation lives.
1275
1544
  if (kind === 'workspace') {
@@ -1296,9 +1565,9 @@ window.__ModuleLoader__.load({
1296
1565
  }
1297
1566
  const segments = match[2].replace(/\/+$/, '').split('/')
1298
1567
  const head = segments.shift() ?? ''
1299
- if (head === '') return kind === 'settings' ? { kind, id: '' } : undefined
1568
+ if (head === '') return kind === 'settings' || kind === 'power' ? { kind, id: '' } : undefined
1300
1569
  const id = decodePart(head).trim()
1301
- if (id === '') return kind === 'settings' ? { kind, id: '' } : undefined
1570
+ if (id === '') return kind === 'settings' || kind === 'power' ? { kind, id: '' } : undefined
1302
1571
  // A settings section has no stable id in the DOM (measured: the nav item carries only
1303
1572
  // aria-current), so its identity is its *label* — the string the user actually sees and
1304
1573
  // clicks. Round-tripping the label keeps the link self-consistent; a locale change
@@ -1324,6 +1593,10 @@ window.__ModuleLoader__.load({
1324
1593
  function routeHash(kind, id, inner) {
1325
1594
  // A modal surface has no identity: `#/settings`, not `#/settings/`.
1326
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)}`
1327
1600
  if (kind === 'workspace') {
1328
1601
  const rest = innerPath(inner)
1329
1602
  const tail = rest.length === 0 ? '' : `/${rest.map(encodeURIComponent).join('/')}`
@@ -1493,6 +1766,10 @@ window.__ModuleLoader__.load({
1493
1766
  panelAttempts = 0
1494
1767
  innerAttempts = 0
1495
1768
  paneOpenedFor = undefined
1769
+ powerActionSent = false
1770
+ powerActionAttempts = 0
1771
+ linkedTyped = false
1772
+ attemptValue(() => host.resetSettingsDrive?.())
1496
1773
  dropTimer()
1497
1774
  }
1498
1775
  /**
@@ -1500,15 +1777,19 @@ window.__ModuleLoader__.load({
1500
1777
  * conversation behind it, because the panel is what fills the screen.
1501
1778
  */
1502
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: '' }
1503
1784
  // The settings overlay covers the main column, so while it is up it is what is on
1504
1785
  // screen; when it closes the ordinary surface takes the address bar back.
1505
1786
  if (settingsOverlayOf(window.document) !== undefined) {
1506
1787
  const section = host.activeSettingsSection() ?? ''
1507
- const tab = section === '' ? undefined : host.activeSettingsTab()
1788
+ const levels = section === '' ? [] : (attemptValue(() => host.activeSettingsLevels?.()) ?? [])
1508
1789
  // A model-provider card whose editor is open is a page of its own: its id rides
1509
1790
  // after the section (and after the tab, when the section has one).
1510
1791
  const provider = section === '' ? undefined : host.activeSettingsProvider()
1511
- const inner = [...(tab === undefined ? [] : [tab]), ...(provider === undefined ? [] : [PROVIDER_SEGMENT, provider])]
1792
+ const inner = [...levels, ...(provider === undefined ? [] : [PROVIDER_SEGMENT, provider])]
1512
1793
  return inner.length === 0 ? { kind: 'settings', id: section } : { kind: 'settings', id: section, inner }
1513
1794
  }
1514
1795
  const panelId = host.activePanelId()
@@ -1551,7 +1832,14 @@ window.__ModuleLoader__.load({
1551
1832
  // but never while a turn link is in flight: that link owns the address bar until it lands,
1552
1833
  // and naming the turn here too is what made it look "landed" before it ever drove.
1553
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)
1554
1841
  const position = [
1842
+ ...(subagentLabel === undefined || subagentLabel === '' ? [] : [SUBAGENT_SEGMENT, subagentLabel]),
1555
1843
  ...(rowAnchor !== undefined && rowAnchor.session === id ? (rowAnchor.turn === undefined || rowAnchor.turn === '' ? [ROW_SEGMENT, ...rowAnchor.parts] : [TURN_SEGMENT, rowAnchor.turn, ROW_SEGMENT, ...rowAnchor.parts]) : []),
1556
1844
  ...(anchoredTurn.length > 0 && rowAnchor !== undefined && rowAnchor.session === id ? [] : anchoredTurn),
1557
1845
  ...(view !== undefined && view.index !== 0 ? [VIEW_SEGMENT, view.id] : []),
@@ -1590,6 +1878,11 @@ window.__ModuleLoader__.load({
1590
1878
  if (route?.kind === 'session') return route.id
1591
1879
  return undefined
1592
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
+ }
1593
1886
  const viewIdOfRoute = route => {
1594
1887
  const parts = innerPath(route?.inner)
1595
1888
  const at = parts.indexOf(VIEW_SEGMENT)
@@ -1672,7 +1965,10 @@ window.__ModuleLoader__.load({
1672
1965
  if (current.id !== linked.id) return false
1673
1966
  const wanted = settingsPartsOf(linked.inner)
1674
1967
  const shown = settingsPartsOf(current.inner)
1675
- if (wanted.tab !== undefined && wanted.tab !== shown.tab) return false
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
1676
1972
  // A provider card is a page one level deeper than its section: a link that names one
1677
1973
  // has to see that one open (the section alone is satisfied by any of its cards).
1678
1974
  return wanted.provider === undefined || wanted.provider === shown.provider
@@ -1747,6 +2043,8 @@ window.__ModuleLoader__.load({
1747
2043
  // (and re-entry can drop the link), so every read below must use the snapshot: reading the
1748
2044
  // live binding again is how a drive turned into "Cannot read properties of undefined".
1749
2045
  const link = linked
2046
+ // The parent a subagent link names (decoration: the child alone is what opens).
2047
+ const linkParent = subagentParentOfRoute(link)
1750
2048
 
1751
2049
  if (sameRoute(current, link)) {
1752
2050
  dropRetry()
@@ -1807,7 +2105,7 @@ window.__ModuleLoader__.load({
1807
2105
  const shown = host.mainViewSessionId()
1808
2106
  if (shown !== rowSession && panelAttempts < INNER_ATTEMPT_LIMIT) {
1809
2107
  panelAttempts += 1
1810
- host.openSession(rowSession)
2108
+ host.openSession(rowSession, linkParent)
1811
2109
  } else if (shown === rowSession && innerAttempts < INNER_ATTEMPT_LIMIT) {
1812
2110
  innerAttempts += 1
1813
2111
  if (!host.revealPosition(rowParts.slice(rowAt)) && wantedTurn >= 0) host.revealPosition(rowParts.slice(wantedTurn, wantedTurn + 2), rowSession)
@@ -1823,7 +2121,7 @@ window.__ModuleLoader__.load({
1823
2121
  const shown = host.mainViewSessionId()
1824
2122
  if (shown !== wantedSession && panelAttempts < INNER_ATTEMPT_LIMIT) {
1825
2123
  panelAttempts += 1
1826
- host.openSession(wantedSession)
2124
+ host.openSession(wantedSession, linkParent)
1827
2125
  } else if (shown === wantedSession && innerAttempts < INNER_ATTEMPT_LIMIT) {
1828
2126
  innerAttempts += 1
1829
2127
  host.openConversationView(wantedView)
@@ -1838,7 +2136,88 @@ window.__ModuleLoader__.load({
1838
2136
  const shown = host.mainViewSessionId()
1839
2137
  if (shown !== target && source !== 'hash' && panelAttempts < INNER_ATTEMPT_LIMIT) {
1840
2138
  panelAttempts += 1
1841
- 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()
1842
2221
  }
1843
2222
  armRetry()
1844
2223
  return
@@ -1847,7 +2226,7 @@ window.__ModuleLoader__.load({
1847
2226
  if (source !== 'hash' && innerAttempts < INNER_ATTEMPT_LIMIT) {
1848
2227
  innerAttempts += 1
1849
2228
  const parts = settingsPartsOf(link.inner)
1850
- host.openSettings(link.id, parts.tab, parts.provider)
2229
+ host.openSettings(link.id, parts.levels, parts.provider)
1851
2230
  }
1852
2231
  armRetry()
1853
2232
  return
@@ -1891,7 +2270,7 @@ window.__ModuleLoader__.load({
1891
2270
  // idempotent on a conversation that is not shown yet (no seats, nothing to scroll to, and
1892
2271
  // the paging path is a no-op too), and waiting for the main-view counter to catch up is
1893
2272
  // what left a cold-opened turn link doing nothing at all.
1894
- host.openSession(wantedSession)
2273
+ host.openSession(wantedSession, linkParent)
1895
2274
  host.revealPosition(innerPath([TURN_SEGMENT, wantedTurn]), wantedSession)
1896
2275
  // A row may follow the turn (`turn/<n>/row/…`): reveal it after the turn.
1897
2276
  const rowAt = innerPath(link.inner).indexOf(ROW_SEGMENT)
@@ -1962,7 +2341,7 @@ window.__ModuleLoader__.load({
1962
2341
  }
1963
2342
  if ((!claimed || moved) && host.isOpenable(link.id)) {
1964
2343
  claimed = true
1965
- host.openSession(link.id)
2344
+ host.openSession(link.id, linkParent)
1966
2345
  }
1967
2346
  }
1968
2347
  /** Adopt the surface the address bar names, if it names one. */
@@ -1996,6 +2375,7 @@ window.__ModuleLoader__.load({
1996
2375
  return
1997
2376
  }
1998
2377
  if (linked === undefined || linked.kind !== parsed.kind || linked.id !== parsed.id) {
2378
+ linkedTyped = typedClaim
1999
2379
  linked = { kind: parsed.kind, id: parsed.id, inner: parsed.inner, deadline: host.now() + LINK_ENFORCE_WINDOW_MS }
2000
2380
  if (parsed.kind === 'panel' && parsed.inner !== undefined) panelScope = innerPath(parsed.inner)[0]
2001
2381
  claimed = false
@@ -2048,7 +2428,43 @@ window.__ModuleLoader__.load({
2048
2428
  retryTimer?.()
2049
2429
  retryTimer = undefined
2050
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
2051
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
+ }
2052
2468
  // The desktop address bar hands its fragment over here instead of relying on the
2053
2469
  // browser: a programmatic hash write fires `hashchange` in a browser, but a hash
2054
2470
  // equal to the current one fires nothing at all, and the desktop shell serves the
@@ -2056,15 +2472,34 @@ window.__ModuleLoader__.load({
2056
2472
  // listener uses makes the bar work in both cases. The hash is still written, so the
2057
2473
  // address bar, Back/Forward and copied links all agree with what is on screen.
2058
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
2059
2487
  attemptValue(() => {
2060
2488
  window.location.hash = fragment
2061
2489
  })
2062
2490
  safeReadLink()
2491
+ typedClaim = false
2063
2492
  }
2064
2493
  const offPanel = host.onPanelChange(() => safeReconcile('panel'))
2065
2494
  const offCatalog = host.onCatalogChange(() => safeReconcile('catalog'))
2066
2495
  const offDom = host.onDomChange(() => safeReconcile('dom'))
2067
- 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
+ })
2068
2503
  safeReadLink()
2069
2504
  // Pointing at a turn records a position, never a navigation: the listener is passive
2070
2505
  // and it refuses to act on any interactive element, so clicks keep working exactly as
@@ -2630,7 +3065,15 @@ window.__ModuleLoader__.load({
2630
3065
  faces: {
2631
3066
  catalog: list,
2632
3067
  archivedSessionIds: () => registry.getSnapshot().archivedSessionIds,
2633
- 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
+ },
2634
3077
  selectPanel: selectPanel === undefined ? undefined : id => selectPanel(id),
2635
3078
  beginNavigation: beginNavigation === undefined ? undefined : () => beginNavigation(),
2636
3079
  openRightbar: openRightbar === undefined ? undefined : track => openRightbar(track),
@@ -2661,6 +3104,14 @@ window.__ModuleLoader__.load({
2661
3104
  return fallback
2662
3105
  }
2663
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
2664
3115
  const archived = () => {
2665
3116
  const ids = attempt(() => faces.archivedSessionIds(), [])
2666
3117
  return Array.isArray(ids) ? ids : []
@@ -2794,9 +3245,20 @@ window.__ModuleLoader__.load({
2794
3245
  // A panel id nobody registered is refused by the layout by THROWING
2795
3246
  // (`selectPanel: main panel "x" is not registered`), so the claim is
2796
3247
  // swallowed here and reported as "the view did not move" at expiry.
2797
- openSession: id => {
2798
- 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))
2799
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),
2800
3262
  // A row's own identity, in the shape the trajectory views publish it. The value is a
2801
3263
  // percent-encoded NUL-separated triple, so the route can read as a plain path.
2802
3264
  rowIdentityOf: element => {
@@ -2943,18 +3405,39 @@ window.__ModuleLoader__.load({
2943
3405
  break
2944
3406
  }
2945
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.
2946
3423
  const ids = conversationViewIdsOf(faces)
2947
3424
  if (ids === undefined || ids.length !== tabs.length) return undefined
2948
3425
  return { id: ids[index], index, total: ids.length }
2949
3426
  },
2950
3427
  openConversationView: viewId => {
2951
3428
  const row = window.document?.querySelector?.(`[${CONVERSATION_TABS_ATTRIBUTE}]`)
2952
- const tabs = typeof row?.querySelectorAll === 'function' ? row.querySelectorAll('[role="tab"]') : undefined
2953
- 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)
2954
3438
  const ids = conversationViewIdsOf(faces)
2955
- if (ids === undefined) return
2956
- const index = ids.indexOf(viewId)
2957
- 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])
2958
3441
  if (typeof tab?.click === 'function') attempt(() => tab.click())
2959
3442
  },
2960
3443
  // The right-hand pane names the tab in front. The service is the source of truth;
@@ -3021,17 +3504,9 @@ window.__ModuleLoader__.load({
3021
3504
  // The model-provider card whose editor is open, when one is (see the helper).
3022
3505
  activeSettingsProvider: () => openSettingsProviderId(window.document),
3023
3506
  // The tab a settings section is showing: the same ARIA contract one level deeper.
3024
- activeSettingsTab: () => {
3025
- const overlay = settingsOverlayOf(window.document)
3026
- if (overlay === null || overlay === undefined || typeof overlay.querySelectorAll !== 'function') return undefined
3027
- for (const candidate of overlay.querySelectorAll('[role="tab"]')) {
3028
- if (typeof candidate.getAttribute !== 'function') continue
3029
- if (candidate.getAttribute('aria-selected') !== 'true') continue
3030
- const label = (candidate.textContent ?? '').trim()
3031
- if (label !== '') return label
3032
- }
3033
- return undefined
3034
- },
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)),
3035
3510
  // The overlay is a modal, and the platform's own dismiss path is a document-level
3036
3511
  // Escape: the shipped modal layer registers `document.addEventListener('keydown',
3037
3512
  // handler, true)` gated on `key === 'Escape'` and closes the topmost modal there.
@@ -3045,50 +3520,118 @@ window.__ModuleLoader__.load({
3045
3520
  window.document?.dispatchEvent?.(event)
3046
3521
  })
3047
3522
  },
3048
- openSettings: (section, tab, provider) => {
3049
- if (typeof section === 'string' && section !== '') {
3050
- // A section is named by the label the user reads, so the request is a label match.
3051
- const overlay = settingsOverlayOf(window.document)
3052
- const scope = overlay ?? window.document
3053
- if (typeof scope?.querySelectorAll === 'function') {
3054
- const candidates = [...scope.querySelectorAll('button')].filter(candidate => {
3055
- if (typeof candidate.closest === 'function' && candidate.closest('nav') === null) return false
3056
- return (candidate.textContent ?? '').trim() !== ''
3057
- })
3058
- const wanted = section.trim().toLowerCase()
3059
- const match = candidates.find(candidate => (candidate.textContent ?? '').trim().toLowerCase() === wanted)
3060
- if (match !== undefined && typeof match.click === 'function') {
3061
- // The section that is already showing is not pressed again: a section
3062
- // re-render can close a card editor the link asked for (its drive is
3063
- // retried every half second, so pressing twice would flap).
3064
- const current = candidates.find(candidate => attemptValue(() => candidate.getAttribute?.('aria-current')) === 'true')
3065
- if (current !== match) attempt(() => match.click())
3066
- // The section may own tabs: a named one is clicked once the section is up.
3067
- if (typeof tab === 'string' && tab !== '') {
3068
- const wantedTab = tab.trim().toLowerCase()
3069
- const tabs = [...scope.querySelectorAll('[role="tab"]')]
3070
- const matchTab = tabs.find(candidate => (candidate.textContent ?? '').trim().toLowerCase() === wantedTab)
3071
- 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
3072
3603
  }
3073
- // And one of its cards may be the page a link named.
3074
- if (typeof provider === 'string' && provider !== '') clickSettingsProvider(provider)
3075
- return
3076
3604
  }
3605
+ if (hit === undefined) break
3606
+ cursor = hit.index
3607
+ if (!controlIsOn(hit.row, hit.control)) attempt(() => hit.control.click())
3077
3608
  }
3609
+ // And one of its cards may be the page a link named.
3610
+ if (typeof provider === 'string' && provider !== '') clickSettingsProvider(provider)
3611
+ return
3078
3612
  }
3079
- const slot = window.document?.querySelector?.(`[data-slot="${SETTINGS_TRIGGER_SLOT}"]`)
3080
- const button =
3081
- (typeof slot?.querySelector === 'function' ? slot.querySelector('button') : null) ??
3082
- (typeof slot?.closest === 'function' ? slot.closest('button') : null)
3083
- if (typeof button?.click === 'function') {
3084
- 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)
3085
3624
  return
3086
3625
  }
3087
- // Fallback for a composition without that slot: the labelled entry.
3088
- const labelled = typeof window.document?.querySelectorAll === 'function'
3089
- ? [...window.document.querySelectorAll('button')].find(candidate => /^(settings|设置)$/i.test((candidate.textContent ?? '').trim()))
3090
- : undefined
3091
- 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
3092
3635
  },
3093
3636
  openPane: (kind, track, warn) => {
3094
3637
  if (typeof kind !== 'string' || kind === '') return
@@ -3304,6 +3847,231 @@ window.__ModuleLoader__.load({
3304
3847
  return undefined
3305
3848
  }
3306
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
+
3307
4075
  /**
3308
4076
  * The fragment a reader can read: percent escapes decoded, or the raw text when the
3309
4077
  * escapes are malformed. Display only — the router still writes the encoded form.
@@ -3343,6 +4111,34 @@ window.__ModuleLoader__.load({
3343
4111
  */
3344
4112
  function landedFragment(wanted, now) {
3345
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
+ }
3346
4142
  const target = targetSessionOf(wanted)
3347
4143
  return target !== undefined && target === targetSessionOf(now)
3348
4144
  }
@@ -3671,6 +4467,11 @@ window.__ModuleLoader__.load({
3671
4467
  show(window.location?.hash)
3672
4468
  /** Whether the view has arrived where the link asked, judged by what is on screen. */
3673
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
3674
4475
  const report = attemptValue(() => window.__dshUrlRouter?.report?.())
3675
4476
  const canonical = report?.canonical
3676
4477
  if (canonical === undefined || canonical === null) return false
@@ -3955,6 +4756,7 @@ window.__ModuleLoader__.load({
3955
4756
  // The copy's own field came and went: this is not the reader focusing the bar.
3956
4757
  if (quietFocus) return
3957
4758
  narrowed = false
4759
+ revealBar()
3958
4760
  selectAll()
3959
4761
  drawRecent()
3960
4762
  }
@@ -3984,6 +4786,8 @@ window.__ModuleLoader__.load({
3984
4786
  if (quietFocus) return
3985
4787
  narrowed = false
3986
4788
  hideRecent()
4789
+ // The pointer may still be on the bar; concealBar() checks that and the list.
4790
+ attemptValue(() => window.setTimeout?.(concealBar, 0))
3987
4791
  }
3988
4792
  const onKeyDown = event => {
3989
4793
  if (event?.key === 'Escape') {
@@ -4012,12 +4816,47 @@ window.__ModuleLoader__.load({
4012
4816
  driveTypedFragment(fragment)
4013
4817
  settle(fragment, false)
4014
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)
4015
4852
  input.addEventListener('keydown', onKeyDown)
4016
4853
  input.addEventListener('focus', onFocus)
4017
4854
  input.addEventListener('mousedown', onMouseDown)
4018
4855
  input.addEventListener('input', onInput)
4019
4856
  input.addEventListener('paste', onPaste)
4020
4857
  input.addEventListener('blur', onBlur)
4858
+ applyHoverMode()
4859
+ const offHoverSettings = subscribeSettings(() => applyHoverMode())
4021
4860
  // Both platforms mount the bar in the body: Windows pins it into the
4022
4861
  // title-bar strip, macOS pins it over the resident header row.
4023
4862
  document.body.appendChild(bar)
@@ -4060,6 +4899,9 @@ window.__ModuleLoader__.load({
4060
4899
  input.removeEventListener?.('input', onInput)
4061
4900
  input.removeEventListener?.('paste', onPaste)
4062
4901
  input.removeEventListener?.('blur', onBlur)
4902
+ bar.removeEventListener?.('mouseenter', revealBar)
4903
+ bar.removeEventListener?.('mouseleave', concealBar)
4904
+ offHoverSettings?.()
4063
4905
  for (const timer of copiedTimers) attemptValue(() => window.clearTimeout(timer))
4064
4906
  copiedTimers.clear()
4065
4907
  if (toastTimer !== undefined) attemptValue(() => window.clearTimeout(toastTimer))
@@ -4118,7 +4960,7 @@ window.__ModuleLoader__.load({
4118
4960
  setRemembered(recentEntries().length)
4119
4961
  }), [])
4120
4962
  if (view === 'summary') {
4121
- return `点击全选 ${settings.selectAllOnFocus ? '开' : '关'} · 记忆 ${settings.recentLimit} 条`
4963
+ return `全选 ${settings.selectAllOnFocus ? '开' : '关'} · 悬浮显示 ${settings.hoverReveal ? '开' : '关'} · 记忆 ${settings.recentLimit} 条`
4122
4964
  }
4123
4965
  /**
4124
4966
  * Take a typed count: an empty field, a minus sign or anything else unusable is a
@@ -4141,11 +4983,26 @@ window.__ModuleLoader__.load({
4141
4983
  checked: settings.selectAllOnFocus,
4142
4984
  onChange: event => setSettings(updateSettings({ selectAllOnFocus: event?.target?.checked === true })),
4143
4985
  }),
4144
- h('span', null, '首次点击地址栏时全选'),
4986
+ h('span', null, '首次点击全选链接'),
4145
4987
  ]),
4146
4988
  h('span', { className: 'dshur-state' }, settings.selectAllOnFocus ? '开' : '关'),
4147
4989
  ]),
4148
- h('p', { className: 'dshur-hint' }, '像浏览器地址栏:第一次点进去,框里原来的链接整条选中,直接输入就替换它;已经在框里时再点,才是放光标。'),
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' }, '平时隐去,鼠标移到地址栏的位置才出现。'),
4149
5006
  ]),
4150
5007
  h('div', { className: 'dshur-block', key: 'recent' }, [
4151
5008
  h('div', { className: 'dshur-row' }, [
@@ -4162,7 +5019,7 @@ window.__ModuleLoader__.load({
4162
5019
  onBlur: () => setDraft(String(readSettings().recentLimit)),
4163
5020
  }),
4164
5021
  ]),
4165
- h('p', { className: 'dshur-hint' }, '默认 8 条,最低 0 条(0 = 不记忆,地址栏也不再弹最近列表),没有上限。这个数就是点地址栏时下拉框里能看到的条数 —— 地址栏里正显示的那个界面不算(它已经在你眼前了,离开之后才会回到列表里)。'),
5022
+ h('p', { className: 'dshur-hint' }, '下拉框里能看到的条数,默认 8;0 = 不记忆、不弹列表;没有上限。当前界面不占名额。'),
4166
5023
  h('p', { className: 'dshur-meta' }, [
4167
5024
  h('span', { key: 'count' }, `已记住 ${remembered} 条`),
4168
5025
  h('button', {