sideby 0.16.0 → 0.17.0

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 (77) hide show
  1. package/README.md +5 -3
  2. package/dist/build.json +1 -1
  3. package/dist/src/cli/commands.d.ts +16 -0
  4. package/dist/src/cli/commands.js +460 -5
  5. package/dist/src/cli/json-schemas.js +115 -0
  6. package/dist/src/cli/main.js +93 -3
  7. package/dist/src/core/ask.d.ts +150 -0
  8. package/dist/src/core/ask.js +365 -0
  9. package/dist/src/core/attachments.d.ts +50 -0
  10. package/dist/src/core/attachments.js +442 -0
  11. package/dist/src/core/autostart.d.ts +88 -0
  12. package/dist/src/core/autostart.js +256 -0
  13. package/dist/src/core/config.d.ts +97 -0
  14. package/dist/src/core/config.js +278 -10
  15. package/dist/src/core/launch.d.ts +15 -4
  16. package/dist/src/core/launch.js +27 -23
  17. package/dist/src/core/local-build.d.ts +17 -0
  18. package/dist/src/core/local-build.js +39 -3
  19. package/dist/src/core/probe.d.ts +78 -0
  20. package/dist/src/core/probe.js +127 -0
  21. package/dist/src/core/same-login.d.ts +9 -4
  22. package/dist/src/core/same-login.js +9 -4
  23. package/dist/src/core/secret-file.d.ts +26 -0
  24. package/dist/src/core/secret-file.js +126 -7
  25. package/dist/src/core/terminal.d.ts +93 -0
  26. package/dist/src/core/terminal.js +371 -0
  27. package/dist/src/core/update.d.ts +17 -0
  28. package/dist/src/core/update.js +63 -1
  29. package/dist/src/families/claude/index.js +3 -0
  30. package/dist/src/families/claude/quota-cache.d.ts +24 -7
  31. package/dist/src/families/claude/quota-cache.js +72 -21
  32. package/dist/src/families/codex/index.js +7 -0
  33. package/dist/src/families/codex/login-key.d.ts +8 -0
  34. package/dist/src/families/codex/login-key.js +33 -0
  35. package/dist/src/families/grok/index.js +3 -0
  36. package/dist/src/families/login-keys.d.ts +6 -0
  37. package/dist/src/families/login-keys.js +6 -0
  38. package/dist/src/families/pi/index.js +4 -0
  39. package/dist/src/panel/background.js +3 -2
  40. package/dist/src/panel/handler.d.ts +29 -2
  41. package/dist/src/panel/handler.js +396 -9
  42. package/dist/src/panel/i18n.d.ts +175 -2
  43. package/dist/src/panel/i18n.js +381 -35
  44. package/dist/src/panel/page-logic.d.ts +214 -0
  45. package/dist/src/panel/page-logic.js +394 -0
  46. package/dist/src/panel/page-script.js +984 -134
  47. package/dist/src/panel/page-styles.js +69 -19
  48. package/dist/src/panel/page.d.ts +5 -0
  49. package/dist/src/panel/page.js +2 -0
  50. package/dist/src/panel/server.d.ts +16 -2
  51. package/dist/src/panel/server.js +41 -13
  52. package/dist/src/plugins/check.d.ts +47 -0
  53. package/dist/src/plugins/check.js +544 -0
  54. package/dist/src/plugins/scaffold.d.ts +21 -0
  55. package/dist/src/plugins/scaffold.js +123 -0
  56. package/dist/src/quota/statusline-tap.js +1 -1
  57. package/dist/src/runtime.d.ts +10 -0
  58. package/dist/src/runtime.js +47 -16
  59. package/dist/src/types.d.ts +19 -0
  60. package/package.json +2 -2
  61. package/schemas/ask.json +180 -0
  62. package/schemas/autostart.json +165 -0
  63. package/schemas/config-get.json +132 -0
  64. package/schemas/config-set.json +107 -0
  65. package/schemas/config.json +73 -6
  66. package/schemas/key.json +93 -0
  67. package/schemas/local.json +4 -0
  68. package/schemas/plugins-check.json +113 -0
  69. package/schemas/plugins-new.json +87 -0
  70. package/schemas/terminal.json +152 -0
  71. package/schemas/try.json +109 -0
  72. package/schemas/update.json +4 -0
  73. package/skills/sideby/SKILL.md +12 -6
  74. package/skills/sideby/references/accounts.md +1 -1
  75. package/skills/sideby/references/ai-tasks.md +116 -0
  76. package/skills/sideby/references/panel.md +14 -0
  77. package/skills/sideby/references/update.md +1 -1
@@ -1,32 +1,46 @@
1
1
  // The Panel page's inline script. Plain browser JavaScript in a string (no template literals inside, so String.raw
2
2
  // keeps it verbatim); the pure helpers from page-logic.ts are prepended so the tested code is the code that runs.
3
- import { aliasArgsOf, aliasIssue, attentionReasons, attentionWhy, cacheHitRate, fixPrompt, formatDuration, handoffChangeOf, handoffHintAccount, inDisplayOrder, loginClusters, loginKey, maskEmail, matchesQuery, mergeDaily, newAccountDir, nextReset, panelFixes, panelHint, panelLang, passedWindow, pinnedIn, quotaPressure, remoteControlSummary, remoteControlTodo, reorder, sortAccounts, suggestAlias, suggestName, updatedAge, windowState, withPinned, } from './page-logic.js';
3
+ import { aiTaskAccounts, aliasArgsOf, aliasIssue, askCopy, attentionReasons, attentionWhy, autostartNote, cacheHitRate, canLeaveLocal, fileSize, fixPrompt, formatDuration, handoffChangeOf, handoffHintAccount, handoffTaskFacts, inDisplayOrder, loginClusters, loginKey, maskEmail, matchesQuery, menuPlacement, mergeDaily, newAccountDir, nextReset, panelFixes, panelHint, panelLang, panelMigration, panelPrefs, passedWindow, pinnedIn, quotaAskable, quotaPressure, remoteControlSummary, remoteControlTodo, reorder, rowBadge, sortAccounts, stripSecrets, suggestAlias, suggestName, switchFailure, taskFindings, updatedAge, windowState, withPinned, } from './page-logic.js';
4
4
  const LOGIC = [
5
+ aiTaskAccounts,
6
+ askCopy,
7
+ quotaAskable,
8
+ stripSecrets,
9
+ taskFindings,
5
10
  aliasArgsOf,
6
11
  aliasIssue,
7
12
  attentionReasons,
8
13
  attentionWhy,
14
+ autostartNote,
9
15
  cacheHitRate,
16
+ fileSize,
10
17
  fixPrompt,
11
18
  formatDuration,
12
19
  handoffChangeOf,
13
20
  handoffHintAccount,
21
+ handoffTaskFacts,
14
22
  inDisplayOrder,
15
23
  loginClusters,
16
24
  loginKey,
17
25
  maskEmail,
18
26
  matchesQuery,
27
+ menuPlacement,
19
28
  mergeDaily,
20
29
  newAccountDir,
21
30
  nextReset,
31
+ canLeaveLocal,
32
+ switchFailure,
22
33
  panelFixes,
23
34
  panelHint,
24
35
  panelLang,
36
+ panelMigration,
37
+ panelPrefs,
25
38
  passedWindow,
26
39
  quotaPressure,
27
40
  remoteControlSummary,
28
41
  remoteControlTodo,
29
42
  reorder,
43
+ rowBadge,
30
44
  sortAccounts,
31
45
  suggestAlias,
32
46
  suggestName,
@@ -80,6 +94,7 @@ const MAIN = String.raw `
80
94
  if (raw.hideEmail === true) p.hideEmail = true
81
95
  if (typeof raw.ahHintAt === 'number') p.ahHintAt = raw.ahHintAt
82
96
  if (raw.rcHide === true) p.rcHide = true
97
+ if (raw.cfgDone === true) p.cfgDone = true
83
98
  if (typeof raw.family === 'string') p.family = raw.family
84
99
  if (raw.collapsed && typeof raw.collapsed === 'object')
85
100
  for (const k of Object.keys(raw.collapsed)) if (raw.collapsed[k] === true) p.collapsed[k] = true
@@ -90,7 +105,16 @@ const MAIN = String.raw `
90
105
  function savePrefs() { try { localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)) } catch {} }
91
106
  // A host lang other than auto wins over the saved choice (which stays saved) and hides the switch.
92
107
  const LANG_FIXED = BOOT.lang === 'en' || BOOT.lang === 'zh'
93
- let lang = panelLang(BOOT.lang, prefs.lang, navigator.language)
108
+ // Theme and language live in config panel.* (spec §3.29), so the CLI and an AI assistant can change them too. A
109
+ // choice this browser saved before is written there once (cfgDone), then config is the only source. A read-only
110
+ // Panel cannot write config and keeps the browser's choice.
111
+ const CFG = !BOOT.readOnly
112
+ let cfgPanel = Object.assign({}, BOOT.panel || {})
113
+ let cfgSaving = 0
114
+ const cfgMigrate = CFG && !prefs.cfgDone ? panelMigration(cfgPanel, { theme: prefs.theme, lang: prefs.lang }) : null
115
+ if (cfgMigrate) Object.assign(cfgPanel, cfgMigrate)
116
+ function viewPrefs() { return panelPrefs(cfgPanel, { theme: prefs.theme, lang: prefs.lang }, CFG) }
117
+ let lang = panelLang(BOOT.lang, viewPrefs().language, navigator.language)
94
118
 
95
119
  // ---------- text ----------
96
120
  function t(key, vars) {
@@ -276,6 +300,8 @@ const MAIN = String.raw `
276
300
  swap: 'M7 4 3 8l4 4M3 8h14M17 20l4-4-4-4M21 16H7',
277
301
  // Lucide "pin" (ISC license)
278
302
  pin: 'M12 17v5M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z',
303
+ // Lucide "link" (ISC license)
304
+ link: 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71',
279
305
  bot: 'M6 9h12a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-6a2 2 0 0 1 2-2zM12 5v4M9.5 13.5v1M14.5 13.5v1',
280
306
  grid: 'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z',
281
307
  list: 'M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01',
@@ -283,6 +309,8 @@ const MAIN = String.raw `
283
309
  clock: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2',
284
310
  mail: 'M3 6h18v12H3zM3 7l9 6 9-6',
285
311
  globe: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3 12h18M12 3c2.5 2.5 3.8 5.5 3.8 9s-1.3 6.5-3.8 9c-2.5-2.5-3.8-5.5-3.8-9S9.5 5.5 12 3z',
312
+ image: 'M3 5h18v14H3zM3 16l5-5 4 4 3-3 6 6M15 9h.01',
313
+ film: 'M3 5h18v14H3zM7 5v14M17 5v14M3 9h4M3 15h4M17 9h4M17 15h4',
286
314
  eye: 'M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z',
287
315
  eyeOff: 'M3 3l18 18M10.6 5.1A10.8 10.8 0 0 1 12 5c6.5 0 10 7 10 7a17.6 17.6 0 0 1-3.2 4.2M6.6 6.6A17.4 17.4 0 0 0 2 12s3.5 7 10 7a10.4 10.4 0 0 0 5.4-1.6M9.9 9.9a3 3 0 0 0 4.2 4.2',
288
316
  }
@@ -551,24 +579,39 @@ const MAIN = String.raw `
551
579
 
552
580
  // ---------- floating panels: menus and popovers ----------
553
581
  // One shared base for everything that floats next to a button: it is placed under (or above) its anchor, kept
554
- // inside the window, closed by a click outside, Escape, a resize or a scroll, and only one is open at a time.
582
+ // inside the window, closed by a click outside, Escape or a resize, and only one is open at a time. One taller than
583
+ // the room it gets scrolls inside itself (menuPlacement), and that scroll never closes it; a scroll of the page
584
+ // moves it along with its anchor and closes it only when the anchor leaves the window.
555
585
  // openMenu() fills it with menu items; openPopover() with any content that render() returns (re-run by refreshPopover()).
556
- const menu = { el: null, anchor: null, kind: null, render: null }
586
+ // returnTo: where focus goes back on close when that is not the anchor (a right-click menu's point).
587
+ const menu = { el: null, anchor: null, kind: null, render: null, align: 'end', returnTo: null }
588
+ function placeFloat() {
589
+ const el = menu.el
590
+ const r = menu.anchor.getBoundingClientRect()
591
+ if (r.bottom < 0 || r.top > window.innerHeight) { closeMenu(); return }
592
+ // Its full height, not the capped one: a menu that got more room grows back.
593
+ const height = el.scrollHeight + el.offsetHeight - el.clientHeight
594
+ const p = menuPlacement(r, { width: el.offsetWidth, height }, { width: window.innerWidth, height: window.innerHeight }, menu.align)
595
+ el.style.setProperty('left', p.left + 'px')
596
+ el.style.setProperty('top', p.top + 'px')
597
+ el.style.setProperty('max-height', p.maxHeight + 'px')
598
+ if (menu.kind === 'settings') settingsFit()
599
+ }
557
600
  function openFloat(anchor, el, kind, align) {
558
601
  closeMenu()
559
602
  document.body.append(el)
560
- const r = anchor.getBoundingClientRect()
561
- const w = el.offsetWidth
562
- const hgt = el.offsetHeight
563
- const x = align === 'start' ? r.left : r.right - w
564
- const left = Math.max(8, Math.min(window.innerWidth - w - 8, x))
565
- const top = r.bottom + 4 + hgt > window.innerHeight - 8 && r.top - hgt - 4 > 8 ? r.top - hgt - 4 : r.bottom + 4
566
- el.style.setProperty('left', left + 'px')
567
- el.style.setProperty('top', top + 'px')
568
603
  anchor.setAttribute('aria-expanded', 'true')
569
604
  menu.el = el
570
605
  menu.anchor = anchor
571
606
  menu.kind = kind
607
+ menu.align = align
608
+ placeFloat()
609
+ // The wheel over it never scrolls the page behind: overscroll-behavior covers a menu that scrolls, this one that
610
+ // does not or has reached its end.
611
+ el.addEventListener('wheel', (e) => {
612
+ const end = e.deltaY < 0 ? el.scrollTop <= 0 : el.scrollTop + el.clientHeight >= el.scrollHeight - 1
613
+ if (end) e.preventDefault()
614
+ }, { passive: false })
572
615
  }
573
616
  function openMenu(anchor, items, label) {
574
617
  const el = h('div', { class: 'menu', role: 'menu', 'aria-label': label },
@@ -584,7 +627,7 @@ const MAIN = String.raw `
584
627
  // A popover: a small panel of text and buttons, lighter than a dialog. A second click on its anchor closes it.
585
628
  function openPopover(anchor, kind, label, render, align = 'start') {
586
629
  if (menu.anchor === anchor) { closeMenu(true); return }
587
- const el = h('div', { class: 'menu popover', role: 'dialog', 'aria-label': label })
630
+ const el = h('div', { class: 'menu popover pop-' + kind, role: 'dialog', 'aria-label': label })
588
631
  el.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); closeMenu(true) } })
589
632
  setKids(el, render())
590
633
  openFloat(anchor, el, kind, align)
@@ -593,25 +636,71 @@ const MAIN = String.raw `
593
636
  if (first) first.focus()
594
637
  }
595
638
  function refreshPopover(kind) {
596
- if (menu.el && menu.kind === kind && menu.render) setKids(menu.el, menu.render())
639
+ if (menu.el && menu.kind === kind && menu.render) { setKids(menu.el, menu.render()); placeFloat() }
597
640
  }
598
641
  function closeMenu(restore) {
599
642
  if (!menu.el) return
600
643
  menu.el.remove()
601
644
  menu.anchor.setAttribute('aria-expanded', 'false')
602
- if (restore && menu.anchor.isConnected) menu.anchor.focus()
645
+ const back = menu.returnTo || menu.anchor
646
+ if (restore && back.isConnected) back.focus()
647
+ if (menu.anchor.classList.contains('ctx-anchor')) menu.anchor.remove()
603
648
  menu.el = null
604
649
  menu.anchor = null
605
650
  menu.kind = null
606
651
  menu.render = null
652
+ menu.returnTo = null
653
+ }
654
+ // Right-click on an account row or card (spec §3.27) opens the row's "…" menu at the pointer; the ContextMenu key
655
+ // and Shift+F10 open it at the focused element in the row. Inputs, links, buttons and selected text keep the
656
+ // browser's own menu. Each row carries its opener as el.sidebyMenu(anchor).
657
+ let kbMenuAt = 0
658
+ function menuTarget(node) {
659
+ let el = node && node.closest ? node.closest('.row, .card, .login-dir') : null
660
+ while (el && !el.sidebyMenu) el = el.parentElement ? el.parentElement.closest('.row, .card, .login-dir') : null
661
+ return el && $('accounts').contains(el) ? el : null
662
+ }
663
+ function openContextMenu(target, x, y, back) {
664
+ const pt = h('span', { class: 'ctx-anchor', 'aria-hidden': 'true', css: { left: x + 'px', top: y + 'px' } })
665
+ document.body.append(pt)
666
+ target.sidebyMenu(pt)
667
+ if (menu.anchor !== pt) { pt.remove(); return }
668
+ // Opens to the right of the point, or to its left where the right edge is too near, as system menus do.
669
+ menu.align = x + menu.el.offsetWidth + 8 > window.innerWidth ? 'end' : 'start'
670
+ menu.returnTo = back && back !== document.body && target.contains(back) ? back : target.querySelector('.more')
671
+ placeFloat()
672
+ }
673
+ function onContextMenu(e) {
674
+ if (Date.now() - kbMenuAt < 600) { e.preventDefault(); return }
675
+ if (ui.arrange || !e.target.closest || e.target.closest('input, textarea, select, a, button, [contenteditable], .tip')) return
676
+ if (window.getSelection && String(window.getSelection())) return
677
+ const target = menuTarget(e.target)
678
+ if (!target) return
679
+ e.preventDefault()
680
+ openContextMenu(target, e.clientX, e.clientY, document.activeElement)
681
+ }
682
+ function onMenuKey(e) {
683
+ if (e.key !== 'ContextMenu' && !(e.key === 'F10' && e.shiftKey)) return
684
+ if (menu.el || (dlg.el && dlg.el.open) || ui.arrange) return
685
+ const at = document.activeElement
686
+ // Text fields and links keep the browser's own menu, as with the mouse; the row's own buttons open the row menu.
687
+ if (at && at.closest && at.closest('input, textarea, select, a, [contenteditable]')) return
688
+ const target = menuTarget(at)
689
+ if (!target) return
690
+ e.preventDefault()
691
+ kbMenuAt = Date.now()
692
+ const r = (at && at !== document.body ? at : target).getBoundingClientRect()
693
+ openContextMenu(target, r.left, r.bottom, at)
607
694
  }
608
695
  function menuKeys(e) {
609
696
  const items = Array.from(menu.el.querySelectorAll('.menu-item:not(:disabled)'))
610
697
  const i = items.indexOf(document.activeElement)
611
- if (e.key === 'ArrowDown') items[(i + 1) % items.length].focus()
612
- else if (e.key === 'ArrowUp') items[(i - 1 + items.length) % items.length].focus()
613
- else if (e.key === 'Home') items[0].focus()
614
- else if (e.key === 'End') items[items.length - 1].focus()
698
+ // A menu that scrolls inside keeps the focused item in sight; focus() alone does not always scroll it there.
699
+ const go = (it) => { it.focus({ preventScroll: true }); it.scrollIntoView({ block: 'nearest' }) }
700
+ if (e.key === 'ArrowDown') go(items[(i + 1) % items.length])
701
+ else if (e.key === 'ArrowUp') go(items[(i - 1 + items.length) % items.length])
702
+ else if (e.key === 'Home') go(items[0])
703
+ else if (e.key === 'End') go(items[items.length - 1])
615
704
  else if (e.key === 'Escape') closeMenu(true)
616
705
  else if (e.key === 'Tab') { closeMenu(true); return }
617
706
  else return
@@ -619,7 +708,8 @@ const MAIN = String.raw `
619
708
  }
620
709
  document.addEventListener('pointerdown', (e) => { if (menu.el && !menu.el.contains(e.target) && !menu.anchor.contains(e.target)) closeMenu() })
621
710
  window.addEventListener('resize', () => closeMenu())
622
- window.addEventListener('scroll', () => closeMenu(), { passive: true })
711
+ // Capture also sees the scroll of an inner scroller (a dialog, a list); the menu's own scroll is left alone.
712
+ window.addEventListener('scroll', (e) => { if (menu.el && !menu.el.contains(e.target)) placeFloat() }, { passive: true, capture: true })
623
713
 
624
714
  // What one directory does: start it, its short commands, its desktop app, its folder, model, tuning, Health.
625
715
  function dirCopies(a) {
@@ -645,20 +735,22 @@ const MAIN = String.raw `
645
735
  function loginItems(a) {
646
736
  return [
647
737
  browserFor(a) ? { icon: 'globe', label: t('br.open'), disabled: Boolean(ui.browserRef), run: () => openBrowserDialog(a) } : null,
738
+ a.kind !== 'api' && !BOOT.readOnly ? { icon: 'terminal', label: t('menu.signIn'), run: () => signIn(a) } : null,
648
739
  a.kind !== 'api' ? { icon: 'key', label: t('menu.copyLogin'), hint: 'sideby login ' + a.ref, run: () => copyText('sideby login ' + a.ref) } : null,
740
+ keyable(a) ? { icon: 'key', label: t('menu.enterKey'), run: () => openKey(a) } : null,
649
741
  a.identity && a.identity.email ? { icon: 'mail', label: t('menu.copyEmail'), hint: emailText(a.identity.email), run: () => copyText(a.identity.email) } : null,
650
742
  ]
651
743
  }
652
744
  function likeItem(a) { return BOOT.readOnly ? null : { icon: 'userPlus', label: t('menu.like'), run: () => openCreate({ family: a.family, api: a.kind === 'api' }) } }
653
745
  // Grouped by what the user wants to do: open, copy, manage.
654
746
  function accountMenu(a, anchor) {
655
- const [browser, login, email] = loginItems(a)
747
+ const [browser, signInItem, login, key, email] = loginItems(a)
656
748
  const opens = [deskItem(a), browser].filter(Boolean)
657
749
  const copies = dirCopies(a)
658
750
  const items = [pinItem(a),
659
751
  opens.length ? '-' : null, opens.length ? { header: t('menu.groupOpen') } : null, ...opens,
660
752
  '-', { header: t('menu.groupCopy') }, ...copies, login, email,
661
- '-', { header: t('menu.groupManage') }, ...dirTools(a), likeItem(a)]
753
+ '-', { header: t('menu.groupManage') }, signInItem, key, ...dirTools(a), likeItem(a)]
662
754
  openMenu(anchor, items, t('menu.label', { ref: a.ref }))
663
755
  }
664
756
  // A Same Login row (ADR-0026): pin first, then a section per directory, then what the login shares.
@@ -680,15 +772,19 @@ const MAIN = String.raw `
680
772
  const text = t('row.copyRun', { cmd })
681
773
  return h('button', { class: 'icon-btn row-run', type: 'button', 'aria-label': text, title: text, onclick: (e) => { e.stopPropagation(); copyText(cmd) } }, icon('terminal'))
682
774
  }
683
- // The pin at the end of a row (spec §3.25): one click pins or unpins. A pinned row always shows it, filled; the
684
- // others show it on hover or focus. A read-only Panel shows the mark of a pinned row and no button.
685
- function pinButton(a, label) {
775
+ // A pinned row (spec §3.25, §3.27) says so with a small muted pin right before its name, and nothing at its end;
776
+ // pinning and unpinning are in the row's menu and in the arrange mode.
777
+ function pinMark(a) {
778
+ return isPinned(a) ? h('span', { class: 'pin-mark', role: 'img', title: t('pin.mark'), 'aria-label': t('pin.mark') }, icon('pin')) : null
779
+ }
780
+ // Before the name (spec §3.27, 2026-10-18): a pinned row always shows its pin, and a click unpins it; any other
781
+ // row shows a fainter pin in the same place on hover or keyboard focus, and a click pins it. The slot keeps its
782
+ // width when empty, so the name never moves. A read-only Panel shows only the mark.
783
+ function pinSlot(a) {
784
+ if (BOOT.readOnly) return pinMark(a)
686
785
  const on = isPinned(a)
687
- if (BOOT.readOnly) return on ? h('span', { class: 'icon-btn pin-btn row-pin is-on', role: 'img', title: t('pin.mark'), 'aria-label': t('pin.mark') }, icon('pin')) : null
688
- const full = !on && pinnedUnits(a.family) >= PIN_MAX
689
- const text = full ? t('pin.full', { n: PIN_MAX }) : t(on ? 'pin.unpinName' : 'pin.pinName', { name: label, title: family(a.family).title })
690
- return h('button', { class: 'icon-btn pin-btn row-pin' + (on ? ' is-on' : ''), type: 'button', 'aria-pressed': String(on), 'aria-label': text, title: text, 'aria-disabled': full ? 'true' : null,
691
- onclick: (e) => { e.stopPropagation(); if (full) toast(t('pin.full', { n: PIN_MAX })); else togglePin(a) } }, icon('pin'))
786
+ const label = t(on ? 'pin.unpin' : 'pin.pin')
787
+ return h('button', { class: 'pin-slot' + (on ? ' is-on' : ''), type: 'button', 'aria-label': label, title: label, onclick: (e) => { e.stopPropagation(); togglePin(a) } }, icon('pin'))
692
788
  }
693
789
  function pinItem(a) {
694
790
  if (BOOT.readOnly) return null
@@ -763,12 +859,14 @@ const MAIN = String.raw `
763
859
  document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en'
764
860
  for (const el of document.querySelectorAll('[data-i18n]')) el.textContent = t(el.getAttribute('data-i18n'))
765
861
  $('refresh').setAttribute('aria-label', t('top.refresh'))
862
+ $('ask-ai').setAttribute('aria-label', t('ask.button'))
766
863
  renderSettingsButton()
767
864
  }
768
865
  function applyTheme() {
769
866
  if (BOOT.colorScheme !== 'auto') return
770
- if (prefs.theme === 'system') document.documentElement.removeAttribute('data-theme')
771
- else document.documentElement.setAttribute('data-theme', prefs.theme)
867
+ const theme = viewPrefs().theme
868
+ if (theme === 'system') document.documentElement.removeAttribute('data-theme')
869
+ else document.documentElement.setAttribute('data-theme', theme)
772
870
  }
773
871
  function stat(label, value, sub, extra, opts) {
774
872
  const kids = [h('div', { class: 'stat-text' }, h('div', { class: 'stat-label' }, label), h('div', { class: 'stat-value' }, value), h('div', { class: 'stat-sub' }, sub)), extra]
@@ -953,7 +1051,7 @@ const MAIN = String.raw `
953
1051
  const setup = setupFor(a.family)
954
1052
  // One setup turns quota on for every account of the Family, so the switch lives in the group header only.
955
1053
  // A check that asks the Host (Grok) and is turned off, or failed (spec §3.26): say why and what to do.
956
- if (q.reason === 'fetch-failed') return h('div', { class: 'q-note q-off' }, h('span', { class: 'muted' }, t('quota.fetchFailed')), tipRaw(t('quota.fetchFailed'), q.detail || ''))
1054
+ if (q.reason === 'fetch-failed') return h('div', { class: 'q-note q-off' }, h('span', { class: 'muted' }, t('quota.fetchFailed')), tipRaw(t('quota.fetchFailed'), q.detail || ''), askWhy(a, q))
957
1055
  if (q.reason === 'not-enabled' && checkOf(a.family)) return h('div', { class: 'q-note q-off' }, h('span', { class: 'muted' }, t('quota.checkOff')), tipRaw(t('quota.checkOff'), q.detail || t('quota.checkOffHint')))
958
1056
  if (q.reason === 'not-enabled') {
959
1057
  const host = famTitle(a)
@@ -964,7 +1062,12 @@ const MAIN = String.raw `
964
1062
  }
965
1063
  if (q.reason === 'api-account') return h('div', { class: 'q-note' }, t('quota.api'), tip('api'))
966
1064
  const text = { 'no-session': 'quota.noSession', 'no-source': 'quota.noSource', unrecognized: 'quota.unrecognized' }[q.reason]
967
- return h('div', { class: 'q-note', title: q.reason === 'no-session' ? t('quota.noSessionHint') : q.detail || null }, text ? t(text) : t('quota.unavailable', { reason: q.reason }))
1065
+ return h('div', { class: 'q-note', title: q.reason === 'no-session' ? t('quota.noSessionHint') : q.detail || null }, text ? t(text) : t('quota.unavailable', { reason: q.reason }), askWhy(a, q))
1066
+ }
1067
+ // "Ask AI why" in a quota cell with no data or an error (spec §3.28).
1068
+ function askWhy(a, q) {
1069
+ if (!quotaAskable(q.reason)) return null
1070
+ return h('button', { class: 'link-btn small q-ask', type: 'button', onclick: (e) => { e.stopPropagation(); openAsk({ task: 'quota', context: { account: a.ref, family: a.family, quotaReason: q.reason + (q.detail ? ': ' + q.detail : ''), version: BOOT.version } }) } }, t('ask.quotaWhy'))
968
1071
  }
969
1072
  function quotaBlock(a, now, cols) {
970
1073
  const q = a.quota
@@ -1018,53 +1121,82 @@ const MAIN = String.raw `
1018
1121
  return h('button', { class: 'chip chip-btn health' + (lvl ? ' chip-' + lvl : ''), type: 'button', title: t('health.filterHint'), 'aria-label': t('health.chipLabel', { ref: a.ref, status: text }), onclick: () => filterHealth(a.ref) },
1019
1122
  h('span', { class: 'dot' + (lvl ? ' dot-' + lvl : ''), 'aria-hidden': 'true' }), text)
1020
1123
  }
1021
- // A dot after the name when the account needs a look that no tag shows: a fuller quota window or a Health
1022
- // finding. Its reason is on hover; the details say more.
1023
- // Inside a login group the quota warning is the first row's, so another row's dot is only about the row itself.
1024
- function attentionDot(a) {
1124
+ // Who the attention words speak about. Inside a login group the quota warning is the first row's, so another
1125
+ // row's word is only about the row itself.
1126
+ function attentionSubject(a) {
1025
1127
  const lead = loginLead(a)
1026
- const subject = !lead ? a
1128
+ return !lead ? a
1027
1129
  : lead !== a.ref ? Object.assign({}, a, { quota: undefined })
1028
1130
  : Object.assign({}, a, { quota: a.quota && Object.assign({}, a.quota, { from: undefined }) })
1029
- const r = reasonsOf(subject, Date.now())
1030
- const lvl = r.includes('health-fail') ? 'fail' : r.includes('quota-high') || r.includes('health-warn') ? 'warn' : ''
1031
- if (!lvl) return null
1131
+ }
1132
+ // A word, not only a colour (spec §3.8): what kind of look the account needs; the reason is on hover.
1133
+ function attentionWord(subject, reason) {
1134
+ const lvl = reason === 'health-fail' ? 'fail' : 'warn'
1032
1135
  const why = whyOf(subject, Date.now())
1033
- // A word, not only a colour (spec §3.8): what kind of look the account needs; the reason is on hover.
1034
- const word = r.includes('health-fail') ? t('att.fix') : r.includes('quota-high') ? t('att.quota') : t('att.check')
1136
+ const word = reason === 'health-fail' ? t('att.fix') : reason === 'quota-high' ? t('att.quota') : t('att.check')
1035
1137
  return h('span', { class: 'tag tag-' + lvl + ' att-tag', title: why, 'aria-label': why }, h('span', { class: 'dot dot-' + lvl, 'aria-hidden': 'true' }), word)
1036
1138
  }
1139
+ // The word a directory inside a Same Login's details shows when it needs a fix or a look.
1140
+ function attentionDot(a) {
1141
+ const subject = attentionSubject(a)
1142
+ const r = reasonsOf(subject, Date.now())
1143
+ const reason = ['health-fail', 'quota-high', 'health-warn'].find((x) => r.includes(x))
1144
+ return reason ? attentionWord(subject, reason) : null
1145
+ }
1146
+ // The one status badge after the name (spec §3.27): needs a fix, running low, the other attention words, then
1147
+ // recommended, then API. The rest is in the details or on hover.
1148
+ function statusBadge(a, members) {
1149
+ const subject = attentionSubject(a)
1150
+ const next = members ? members.some(isNext) : isNext(a)
1151
+ const k = rowBadge(reasonsOf(subject, Date.now()), { next, api: a.kind === 'api' })
1152
+ if (k === 'health-fail' || k === 'quota-high' || k === 'health-warn') return attentionWord(subject, k)
1153
+ if (k === 'signed-out')
1154
+ return h('button', { class: 'tag tag-fail tag-btn', type: 'button', title: t('acct.signInHint'), 'aria-label': t('acct.signedOutLabel', { ref: a.ref }), onclick: (e) => { e.stopPropagation(); signIn(a) } }, h('span', { class: 'dot dot-fail', 'aria-hidden': 'true' }), t('acct.signedOut'))
1155
+ if (k === 'not-installed') return h('span', { class: 'tag tag-warn' }, t(family(a.family).brokenBin ? 'acct.broken' : 'acct.notInstalled'))
1156
+ if (k === 'next') return nextTag(a)
1157
+ if (k === 'api') return h('span', { class: 'tag tag-warn', title: t('tip.api.body') }, t('acct.api'))
1158
+ return null
1159
+ }
1160
+ // The short command, as quiet text after the name; every alias and its arguments are on hover.
1161
+ function aliasText(a) {
1162
+ const all = a.aliases || []
1163
+ if (!all.length) return null
1164
+ const lines = all.map((x) => { const args = aliasArgsOf(a, x); return x + ' · ' + (args ? t('acct.aliasArgs', { args: args.join(' ') }) : t('acct.alias')) })
1165
+ return h('span', { class: 'alias', title: lines.join('\n') }, all[0])
1166
+ }
1167
+ // A Same Login row (spec §3.21) gets a link mark after the name; what it means is on hover.
1168
+ function loginMark(members) {
1169
+ if (!members || members.length < 2) return null
1170
+ const text = t('login.label', { names: members.map((m) => m.name).join(', ') }) + '\n' + t('login.explain')
1171
+ return h('span', { class: 'login-mark', role: 'img', title: text, 'aria-label': text }, icon('link'))
1172
+ }
1037
1173
  // members: the directories of a Same Login shown as this one row (spec §3.21).
1038
1174
  function nameLine(a, members) {
1039
- const next = members ? members.some(isNext) : isNext(a)
1040
1175
  return h('div', { class: 'ident-name' },
1176
+ pinSlot(a),
1041
1177
  h('span', { class: 'name', title: a.name }, a.name),
1042
- attentionDot(a),
1043
- (a.aliases || []).slice(0, 3).map((x) => {
1044
- const args = aliasArgsOf(a, x)
1045
- return h('span', { class: 'tag tag-alias', title: args ? t('acct.aliasArgs', { args: args.join(' ') }) : t('acct.alias') }, x)
1046
- }),
1047
- members ? h('span', { class: 'tag tag-login', title: t('login.explain') }, t('login.dirs', { n: members.length })) : null,
1048
- a.kind === 'api' ? h('span', { class: 'tag tag-warn', title: t('tip.api.body') }, t('acct.api')) : null,
1049
- next
1050
- ? nextTag(a)
1051
- : null,
1052
- a.login === 'logged-out' && a.kind !== 'api'
1053
- ? h('button', { class: 'tag tag-fail tag-btn', type: 'button', title: t('acct.signInHint'), 'aria-label': t('acct.signedOutLabel', { ref: a.ref }), onclick: () => copyText('sideby login ' + a.ref) }, h('span', { class: 'dot dot-fail', 'aria-hidden': 'true' }), t('acct.signedOut'))
1054
- : null,
1055
- a.hostInstalled === false ? h('span', { class: 'tag tag-warn' }, t(family(a.family).brokenBin ? 'acct.broken' : 'acct.notInstalled')) : null)
1178
+ loginMark(members),
1179
+ aliasText(a),
1180
+ statusBadge(a, members))
1056
1181
  }
1057
1182
  // An organization name as the vendor wrote it, without a trailing comma or full stop it sometimes carries.
1058
1183
  function orgText(org) { return org ? String(org).replace(/[\s,,;;、。]+$/u, '') : '' }
1059
1184
  // The name part of a ref (claude:001 gives 001), as rows show other Accounts of the same Family.
1060
1185
  function refName(ref) { return String(ref).slice(String(ref).indexOf(':') + 1) }
1061
- // Who the account is signed in as, under its name; masked while "Hide emails" is on.
1186
+ // Who the account is signed in as, under its name, in full unless the cell is too narrow; masked while "Hide
1187
+ // emails" is on. The Browser Sign-in is in the details (spec §3.27).
1062
1188
  function emailLine(a) {
1063
1189
  const id = a.identity
1064
- if (!id || (!id.email && !browserButton(a))) return null
1065
- const email = id.email ? emailText(id.email) : ''
1066
- return h('div', { class: 'ident-email', title: [email, orgText(id.org)].filter(Boolean).join(' · ') },
1067
- dots([email ? h('button', { class: 'email email-btn', type: 'button', title: t('menu.copyEmail'), 'aria-label': t('menu.copyEmail') + ' ' + email, onclick: (e) => { e.stopPropagation(); copyText(id.email) } }, email) : null, browserButton(a)]))
1190
+ if (!id || !id.email) return null
1191
+ const email = emailText(id.email)
1192
+ const full = [email, orgText(id.org)].filter(Boolean).join(' · ')
1193
+ return h('div', { class: 'ident-email', title: full },
1194
+ h('button', { class: 'email email-btn', type: 'button', title: full + '\n' + t('menu.copyEmail'), 'aria-label': t('menu.copyEmail') + ' ' + email, onclick: (e) => { e.stopPropagation(); copyText(id.email) } }, email))
1195
+ }
1196
+ // The Browser Sign-in as a line of the details: which Chrome browser user signed in to the account's website.
1197
+ function browserItem(a) {
1198
+ const b = browserButton(a)
1199
+ return b ? h('div', { class: 'rd-item rd-browser' }, h('span', { class: 'rd-label' }, t('row.browser')), b) : null
1068
1200
  }
1069
1201
  function subLine(a) {
1070
1202
  const plan = a.quota && a.quota.status === 'ok' && a.quota.plan
@@ -1346,14 +1478,16 @@ const MAIN = String.raw `
1346
1478
  if (ui.arrange) return arrangeRow(a, [a])
1347
1479
  const open = isOpen(a)
1348
1480
  const email = emailLine(a)
1349
- return h('div', { class: 'row fold-hit' + (open ? ' row-open' : '') + (a.error ? ' row-error' : ''), role: 'listitem', 'data-ref': a.ref, 'data-key': a.ref, 'data-sig': rowSig(a), onclick: foldClick(a) },
1481
+ return withMenu(h('div', { class: 'row fold-hit' + (open ? ' row-open' : '') + (a.error ? ' row-error' : ''), role: 'listitem', 'data-ref': a.ref, 'data-key': a.ref, 'data-sig': rowSig(a), onclick: foldClick(a) },
1350
1482
  h('div', { class: 'ident' }, rowToggle(a), badge(a.family, 'sm'), h('div', { class: 'ident-text' }, nameLine(a), email || subLine(a))),
1351
1483
  quotaBlock(a, now, cols),
1352
1484
  lastUsed(a),
1353
- h('div', { class: 'row-end' }, runButton(a), pinButton(a, a.name), moreButton(a)),
1485
+ h('div', { class: 'row-end' }, runButton(a), moreButton(a)),
1354
1486
  open ? rowDetail(a, Boolean(email)) : null,
1355
- notes(a))
1487
+ notes(a)), (anchor) => accountMenu(a, anchor))
1356
1488
  }
1489
+ // What a right-click on el opens (see openContextMenu).
1490
+ function withMenu(el, open) { el.sidebyMenu = open; return el }
1357
1491
  // Directories signed in as one login (spec §3.21, ADR-0026) are one row: the vendor counts one quota, so the row
1358
1492
  // shows it once, with the email and the newest use; opening it lists each directory with its own short command,
1359
1493
  // last use, tokens, Health, a button that copies its start command and its own menu.
@@ -1383,20 +1517,21 @@ const MAIN = String.raw `
1383
1517
  if (ui.arrange) return arrangeRow(a, ms)
1384
1518
  const open = isOpen(a)
1385
1519
  const email = emailLine(a)
1386
- return h('div', { class: 'row row-login fold-hit' + (open ? ' row-open' : '') + (a.error ? ' row-error' : ''), role: 'listitem', 'aria-label': t('login.label', { names: ms.map((m) => m.name).join(', ') }), 'data-ref': a.ref, 'data-key': loginKey(ms.map((m) => m.ref)), 'data-sig': sig(ms.map(rowSig)), onclick: foldClick(a) },
1520
+ return withMenu(h('div', { class: 'row row-login fold-hit' + (open ? ' row-open' : '') + (a.error ? ' row-error' : ''), role: 'listitem', 'aria-label': t('login.label', { names: ms.map((m) => m.name).join(', ') }), 'data-ref': a.ref, 'data-key': loginKey(ms.map((m) => m.ref)), 'data-sig': sig(ms.map(rowSig)), onclick: foldClick(a) },
1387
1521
  h('div', { class: 'ident' }, rowToggle(a), badge(a.family, 'sm'), h('div', { class: 'ident-text' }, nameLine(a, ms), email || subLine(a))),
1388
1522
  quotaBlock(a, now, cols),
1389
1523
  lastUsed(newestOf(ms)),
1390
- h('div', { class: 'row-end' }, runButton(a), pinButton(a, ms.map((m) => m.name).join(', ')), loginMoreButton(ms)),
1524
+ h('div', { class: 'row-end' }, runButton(a), loginMoreButton(ms)),
1391
1525
  open ? loginDetail(ms) : null,
1392
- notes(a))
1526
+ notes(a)), (anchor) => loginMenu(ms, anchor))
1393
1527
  }
1394
1528
  function loginDetail(ms) {
1395
1529
  return h('div', { class: 'row-detail login-detail' },
1396
1530
  h('p', { class: 'rd-note' }, t('login.explain')),
1531
+ browserItem(ms[0]),
1397
1532
  h('div', { class: 'login-dirs', role: 'list' }, ms.map((m) => {
1398
1533
  const run = 'sideby run ' + m.ref
1399
- return h('div', { class: 'login-dir', role: 'listitem', 'data-ref': m.ref },
1534
+ return withMenu(h('div', { class: 'login-dir', role: 'listitem', 'data-ref': m.ref },
1400
1535
  h('div', { class: 'ld-name' },
1401
1536
  h('span', { class: 'name' }, m.name),
1402
1537
  m.ref !== ms[0].ref ? attentionDot(m) : null,
@@ -1407,7 +1542,7 @@ const MAIN = String.raw `
1407
1542
  usageBlock(m),
1408
1543
  healthChip(m),
1409
1544
  h('button', { class: 'btn btn-sm', type: 'button', title: run, onclick: () => copyText(run) }, icon('terminal'), t('login.copyRun')),
1410
- moreButton(m))
1545
+ moreButton(m)), (anchor) => accountMenu(m, anchor))
1411
1546
  })))
1412
1547
  }
1413
1548
  // What a click on a row opens: how it runs (ref, model and effort, a shared login), its tokens and its Health.
@@ -1420,19 +1555,21 @@ const MAIN = String.raw `
1420
1555
  const same = sameLoginNote(a)
1421
1556
  return h('div', { class: 'row-detail' },
1422
1557
  withSub || same ? h('div', { class: 'rd-item rd-run' }, h('span', { class: 'rd-label' }, t('row.runs')), withSub ? subLine(a) : null, same) : null,
1558
+ browserItem(a),
1423
1559
  h('div', { class: 'rd-item rd-usage' }, h('span', { class: 'rd-label' }, t('col.tokens', { n: BOOT.usageDays })), usageBlock(a)),
1424
1560
  h('div', { class: 'rd-item rd-health' }, h('span', { class: 'rd-label' }, t('col.health')), healthChip(a)))
1425
1561
  }
1426
1562
  // A card shows everything at once; it has room for it.
1427
1563
  function card(a, now, cols) {
1428
- return h('article', { class: 'card' + (a.error ? ' card-error' : ''), 'data-ref': a.ref, 'data-key': a.ref, 'data-sig': rowSig(a), 'aria-label': a.ref },
1564
+ return withMenu(h('article', { class: 'card' + (a.error ? ' card-error' : ''), 'data-ref': a.ref, 'data-key': a.ref, 'data-sig': rowSig(a), 'aria-label': a.ref },
1429
1565
  h('header', { class: 'card-head' },
1430
1566
  badge(a.family, 'sm'),
1431
1567
  h('div', { class: 'ident-text' }, nameLine(a), emailLine(a), subLine(a), sameLoginNote(a)),
1432
- h('div', { class: 'row-end' }, pinButton(a, a.name), moreButton(a))),
1568
+ h('div', { class: 'row-end' }, moreButton(a))),
1433
1569
  notes(a),
1570
+ browserItem(a),
1434
1571
  quotaBlock(a, now, cols),
1435
- h('footer', { class: 'card-foot' }, usageBlock(a), h('div', { class: 'card-side' }, lastUsed(a), healthChip(a))))
1572
+ h('footer', { class: 'card-foot' }, usageBlock(a), h('div', { class: 'card-side' }, lastUsed(a), healthChip(a)))), (anchor) => accountMenu(a, anchor))
1436
1573
  }
1437
1574
  // The list's column titles, once per group under its header. Screen readers get each cell's own label instead.
1438
1575
  function colHead(cols, list) {
@@ -1658,7 +1795,7 @@ const MAIN = String.raw `
1658
1795
  fams.length ? h('ul', { class: 'host-list' }, fams.map((f) => h('li', null, badge(f.id, 'sm'), h('span', null, f.title),
1659
1796
  f.installed ? h('span', { class: 'muted small' }, tx('empty.runOnce', { bin: h('code', null, f.bin) })) : f.installCommand ? codeCopy(f.installCommand) : link(t('empty.install'), f.installUrl, 'btn btn-sm')))) : h('p', { class: 'muted small' }, t('empty.noFamilies')),
1660
1797
  BOOT.readOnly ? null : h('button', { class: 'btn btn-primary', type: 'button', onclick: () => openCreate(null) }, icon('plus'), t('empty.create')),
1661
- BOOT.readOnly ? null : h('p', { class: 'muted small' }, t('ai.emptyHint'), ' ', h('button', { class: 'link-btn', type: 'button', onclick: openAi }, t('ai.how'))))
1798
+ BOOT.readOnly ? null : h('p', { class: 'muted small' }, t('ai.emptyHint'), ' ', h('button', { class: 'link-btn', type: 'button', onclick: () => openAsk({ task: 'account', context: { version: BOOT.version } }) }, t('ask.title.account'))))
1662
1799
  }
1663
1800
  function clearFilters() {
1664
1801
  ui.query = ''
@@ -1787,7 +1924,7 @@ const MAIN = String.raw `
1787
1924
  }
1788
1925
  function arrangeBar() {
1789
1926
  return h('div', { class: 'arrange-bar', role: 'region', 'aria-label': t('sort.arrange'), 'data-key': 'arrange-bar', 'data-sig': sig([ordered(), lang]) },
1790
- h('span', { class: 'arrange-text' }, icon('grip'), h('span', null, t('order.bar')), h('button', { class: 'link-btn small', type: 'button', onclick: openAi }, t('ai.orderHint'))),
1927
+ h('span', { class: 'arrange-text' }, icon('grip'), h('span', null, t('order.bar')), h('button', { class: 'link-btn small', type: 'button', onclick: () => openAsk() }, t('ai.orderHint'))),
1791
1928
  h('div', { class: 'arrange-actions' },
1792
1929
  h('button', { class: 'link-btn', type: 'button', title: t('order.resetHint'), disabled: ordered() ? undefined : true, onclick: resetOrder }, t('order.resetButton')),
1793
1930
  h('button', { class: 'btn btn-primary btn-sm', type: 'button', onclick: endArrange }, t('order.done'))))
@@ -1806,13 +1943,15 @@ const MAIN = String.raw `
1806
1943
  const pinLabel = full ? t('pin.full', { n: PIN_MAX }) : t(pinned ? 'pin.unpinName' : 'pin.pinName', { name: label, title: family(fam).title })
1807
1944
  return h('div', { class: 'row row-arrange' + (pinned ? ' is-pinned' : ''), role: 'listitem', 'data-ref': a.ref, 'data-key': 'arr:' + loginKey(ms.map((m) => m.ref)), 'data-sig': sig([ms.map(rowSig), at, lo, hi, full]) },
1808
1945
  h('div', { class: 'ident' }, dragHandle('account', fam, a.name, label), badge(fam, 'sm'), h('div', { class: 'ident-text' }, h('div', { class: 'ident-name' },
1946
+ pinMark(a),
1809
1947
  h('span', { class: 'name', title: a.name }, a.name),
1810
- (a.aliases || []).slice(0, 3).map((x) => h('span', { class: 'tag tag-alias' }, x)),
1811
- ms.length > 1 ? h('span', { class: 'tag tag-login', title: t('login.explain') }, t('login.dirs', { n: ms.length })) : null))),
1948
+ loginMark(ms),
1949
+ aliasText(a)))),
1812
1950
  h('div', { class: 'arrange-tools' },
1813
1951
  h('button', { class: 'icon-btn', type: 'button', 'aria-label': t('order.up', { name: label }), title: t('order.up', { name: label }), disabled: at <= lo ? true : undefined, onclick: () => moveUnit('account', fam, at, at - 1, { focus: a.name, label, button: 'up' }) }, icon('arrowUp')),
1814
1952
  h('button', { class: 'icon-btn', type: 'button', 'aria-label': t('order.down', { name: label }), title: t('order.down', { name: label }), disabled: at >= hi ? true : undefined, onclick: () => moveUnit('account', fam, at, at + 1, { focus: a.name, label, button: 'down' }) }, icon('arrowDown')),
1815
- h('button', { class: 'icon-btn pin-btn', type: 'button', 'aria-pressed': String(pinned), 'aria-label': pinLabel, title: pinLabel, 'aria-disabled': full ? 'true' : null, onclick: () => { if (full) toast(t('pin.full', { n: PIN_MAX })); else togglePin(a) } }, icon('pin'))))
1953
+ // Words, not a lone pin icon at the end of the row: the arrange mode is where the order and the pins are set.
1954
+ h('button', { class: 'btn btn-sm pin-btn', type: 'button', 'aria-pressed': String(pinned), 'aria-label': pinLabel, title: pinLabel, 'aria-disabled': full ? 'true' : null, onclick: () => { if (full) toast(t('pin.full', { n: PIN_MAX })); else togglePin(a) } }, t(pinned ? 'pin.unpin' : 'pin.pin'))))
1816
1955
  }
1817
1956
  // A grip to drag a Family group or an account (a Same Login moves as one) into a new place, or to move it with
1818
1957
  // the arrow keys. Only in the arrange mode.
@@ -2116,10 +2255,11 @@ const MAIN = String.raw `
2116
2255
  h('div', null,
2117
2256
  h('h2', { id: 'h-checkup' }, t('health.title')),
2118
2257
  h('p', { class: 'muted small' }, status, h('span', { class: 'sep', 'aria-hidden': 'true' }, '·'),
2119
- tx('health.lead', { shared: h('span', { class: 'label-tip' }, t('health.sharedTerm'), tip('shared')), drift: h('span', { class: 'label-tip' }, t('health.driftTerm'), tip('drift')) }))),
2258
+ tx('health.lead', { shared: h('span', { class: 'label-tip' }, t('health.sharedTerm'), tip('shared')) }))),
2120
2259
  h('div', { class: 'actions' },
2121
2260
  h('button', { class: 'btn', type: 'button', disabled: busy || !S.state, 'aria-busy': S.checking ? 'true' : null, title: !S.state && !busy ? t('health.waiting') : null, onclick: runCheck }, S.checking ? spinner() : null, S.checking ? t('health.running') : t('health.run')),
2122
- prompt ? h('button', { class: 'btn', type: 'button', title: t('fix.copyAiTitle'), onclick: () => copyText(prompt, t('fix.promptShown')) }, icon('copy'), t('fix.copyAi')) : null,
2261
+ prompt && BOOT.readOnly ? h('button', { class: 'btn', type: 'button', title: t('fix.copyAiTitle'), onclick: () => copyText(prompt, t('fix.promptShown')) }, icon('copy'), t('fix.copyAi')) : null,
2262
+ prompt && !BOOT.readOnly ? h('button', { class: 'btn', type: 'button', title: t('ask.fixTitle'), onclick: () => openAsk({ task: 'fix', context: { findings: taskFindings(forAi(findings)), version: BOOT.version } }) }, icon('bot'), t('ask.title.fix')) : null,
2123
2263
  BOOT.readOnly ? null : h('button', { class: 'btn btn-primary', type: 'button', disabled: busy || !fixable, 'aria-busy': S.fixing ? 'true' : null, title: fixWhy, onclick: runFix },
2124
2264
  S.fixing ? spinner() : null, S.fixing ? t('health.fixing') : t('fix.all') + (fixable ? ' (' + fixable + ')' : ''))))]
2125
2265
  if (ref) out.push(h('div', { class: 'filter-bar' },
@@ -2559,7 +2699,9 @@ const MAIN = String.raw `
2559
2699
  })
2560
2700
  const body = h('form', { id: 'nf-form', class: 'form', novalidate: true, onsubmit: (ev) => { ev.preventDefault(); create() } },
2561
2701
  h('p', { class: 'muted small' }, t('nf.intro')),
2562
- h('fieldset', { class: 'field fam-field' }, h('legend', null, t('nf.tool')), nf.fams.length ? h('div', { class: 'fam-grid' }, cards) : h('p', { class: 'muted small' }, t('empty.noFamilies'))),
2702
+ h('fieldset', { class: 'field fam-field' }, h('legend', null, t('nf.tool')), h('div', { class: 'fam-grid' }, cards,
2703
+ h('button', { class: 'fam-card fam-other', type: 'button', onclick: () => openAsk({ task: 'host', context: { version: BOOT.version } }) }, h('span', { class: 'badge badge-sm badge-logo', 'aria-hidden': 'true' }, icon('plus')),
2704
+ h('span', { class: 'fam-text' }, h('span', { class: 'fam-title' }, t('ask.otherTool')), h('span', { class: 'fam-sub' }, t('ask.otherToolSub')))))),
2563
2705
  h('div', { class: 'field' }, h('label', { class: 'field', for: 'nf-name' }, h('span', null, t('nf.name')), e.name), e.suggest),
2564
2706
  // The first screen asks only for the tool and a name; the rest waits behind "More options".
2565
2707
  h('details', { class: 'more-opts', open: nf.api || Boolean(nf.alias) || Boolean(nf.error) || undefined },
@@ -2568,7 +2710,8 @@ const MAIN = String.raw `
2568
2710
  h('div', { class: 'check-row' }, h('label', { class: 'check' }, e.api, h('span', null, h('b', null, t('nf.api')), h('span', { class: 'muted small' }, t('nf.apiHint')))), tip('api')),
2569
2711
  e.preview),
2570
2712
  e.error,
2571
- e.errNote = nf.error ? errorNote(nf.error, create, nf.error.code === 'create-refused' && !nf.api ? t('nf.refusedApi') : null) : null)
2713
+ e.errNote = nf.error ? errorNote(nf.error, create, nf.error.code === 'create-refused' && !nf.api ? t('nf.refusedApi') : null) : null,
2714
+ h('p', { class: 'small ask-setup' }, h('button', { class: 'link-btn', type: 'button', onclick: () => openAsk({ task: 'account', context: nf.family ? { family: nf.family, version: BOOT.version } : { version: BOOT.version } }) }, icon('bot'), t('ask.setUp'))))
2572
2715
  setKids(el, dialogFrame(t('nf.title'), null, body, [
2573
2716
  h('button', { class: 'btn', type: 'button', onclick: () => el.close() }, t('btn.cancel')),
2574
2717
  e.submit,
@@ -2676,8 +2819,11 @@ const MAIN = String.raw `
2676
2819
  : h('div', { class: 'note note-warn' }, h('span', { class: 'small' }, f.message))),
2677
2820
  h('p', { class: 'muted small' }, t('nf.next')),
2678
2821
  h('ol', { class: 'steps' }, r.nextSteps.map(stepRow)))
2822
+ const made = S.state && S.state.accounts.find((x) => x.ref === r.account.ref)
2823
+ const keyNow = r.account.kind === 'api' || (made && keyable(made)) ? { ref: r.account.ref, family: r.account.family, kind: r.account.kind } : null
2679
2824
  return dialogFrame(t('nf.title'), r.account.family, body, [
2680
2825
  h('button', { class: 'btn', type: 'button', onclick: () => openCreate({ family: r.account.family, api: nf.api }) }, t('nf.another')),
2826
+ keyNow ? h('button', { class: 'btn', type: 'button', onclick: () => openKey(keyNow) }, icon('key'), t('menu.enterKey')) : null,
2681
2827
  h('button', { class: 'btn btn-primary', type: 'button', onclick: () => dlg.el.close() }, t('btn.done')),
2682
2828
  ])
2683
2829
  }
@@ -2780,6 +2926,13 @@ const MAIN = String.raw `
2780
2926
  }
2781
2927
  return { auto: s.auto, sameFamily: s.sameFamily, prepareAt: s.prepareAt, threshold: s.threshold, wait: s.waitIfResetWithinMinutes, countdown: s.countdownSeconds, orders }
2782
2928
  }
2929
+ // "Let AI tune it" (spec §3.29): the AI gets the facts of sideby handoff status --json, as far as the page has them.
2930
+ function askHandoff() {
2931
+ const st = ahState()
2932
+ if (!st) return
2933
+ const status = ah && ah.status ? ah.status : null
2934
+ openAsk({ task: 'handoff', context: { handoff: handoffTaskFacts(st.settings, st.starters, S.state.accounts, Date.now(), status), version: BOOT.version } })
2935
+ }
2783
2936
  async function openHandoff() {
2784
2937
  if (!ahState()) return
2785
2938
  ah = { form: handoffForm(), loading: true }
@@ -2868,7 +3021,8 @@ const MAIN = String.raw `
2868
3021
  const st = ahState()
2869
3022
  if (!st || !ah) return
2870
3023
  const f = ah.form
2871
- const body = [h('p', { class: 'muted small' }, t('ah.intro'))]
3024
+ const body = [h('p', { class: 'muted small' }, t('ah.intro'), ' ',
3025
+ h('button', { class: 'link-btn', type: 'button', onclick: askHandoff }, icon('bot'), t('ask.title.handoff')))]
2872
3026
  if (BOOT.readOnly) body.push(h('p', { class: 'note' }, t('banner.readOnly')))
2873
3027
  body.push(ahCheck('auto', t('ah.auto'), null))
2874
3028
  body.push(ahCheck('sameFamily', t('ah.sameFamily'), t('ah.sameFamilyWarn')))
@@ -3185,6 +3339,7 @@ const MAIN = String.raw `
3185
3339
  const kids = [h('div', { class: 'section-head' },
3186
3340
  h('h2', { id: 'h-handoff' }, t('hp.title')),
3187
3341
  h('div', { class: 'row-actions' },
3342
+ h('button', { class: 'btn btn-sm', type: 'button', onclick: askHandoff }, icon('bot'), t('ask.title.handoff')),
3188
3343
  h('button', { class: 'btn btn-sm', type: 'button', onclick: openHandoff }, t('hp.advanced')),
3189
3344
  h('button', { class: 'btn btn-sm', type: 'button', disabled: hp.loading, onclick: loadHandoffPage }, hp.loading ? spinner() : icon('refresh'), t('top.refresh'))))]
3190
3345
  const on = st.settings.auto
@@ -3329,6 +3484,7 @@ const MAIN = String.raw `
3329
3484
  const out = []
3330
3485
  if (!LANG_FIXED) out.push('lang')
3331
3486
  if (BOOT.colorScheme === 'auto') out.push('theme')
3487
+ out.push('startup')
3332
3488
  if (S.state && S.state.accounts.length && !BOOT.readOnly) out.push('order')
3333
3489
  out.push('ai')
3334
3490
  if (S.state && rcSummary()) out.push('rc')
@@ -3343,59 +3499,208 @@ const MAIN = String.raw `
3343
3499
  const warn = secs.includes('rc') && rcSummary() === 'on'
3344
3500
  setKids(b, [icon('gear'), h('span', { class: 'hide-sm' }, t('set.button')), warn ? h('span', { class: 'dot set-dot', 'aria-hidden': 'true' }) : null])
3345
3501
  b.setAttribute('aria-label', t('set.button') + (warn ? ': ' + t('set.rcOn') : ''))
3346
- refreshPopover('settings')
3502
+ refreshSettings()
3347
3503
  if (dlg.kind === 'rc') renderRc()
3348
3504
  }
3349
- function settingsPop() {
3505
+ // The popover and the full settings dialog show the same groups (spec §3.29), top to bottom: Appearance, Startup,
3506
+ // Accounts and quota, Let AI help. leave() closes whichever holds them before a link goes elsewhere; a link that
3507
+ // opens a dialog keeps the dialog element open (opens: true), since one dialog element is shared.
3508
+ function settingsGroups(leave) {
3350
3509
  const secs = settingsSections()
3351
- const out = []
3352
3510
  const seg = (label, opts, cur, pick) => h('div', { class: 'set-row' }, h('span', { class: 'set-label' }, label),
3353
3511
  h('div', { class: 'seg', role: 'group', 'aria-label': label }, opts.map(([v, text]) =>
3354
3512
  h('button', { class: 'seg-btn', type: 'button', 'aria-pressed': String(v === cur), onclick: () => pick(v) }, text))))
3355
- const link = (label, state, cls, run) => h('button', { class: 'menu-item set-link', type: 'button', onclick: () => { closeMenu(); run() } },
3356
- h('span', { class: 'menu-label' }, label), h('span', { class: 'chip chip-sm ' + cls }, state), icon('chevron'))
3357
- if (secs.includes('lang')) out.push(seg(t('set.lang'), [['en', 'English'], ['zh', '中文']], lang, setLang))
3358
- if (secs.includes('theme')) out.push(seg(t('set.theme'), CHOICES.theme.map((v) => [v, t('set.theme.' + v)]), prefs.theme, setTheme))
3359
- if (out.length && (secs.includes('order') || secs.includes('rc') || secs.includes('ah'))) out.push(h('div', { class: 'menu-sep', role: 'separator' }))
3360
- // Everything here can also be asked of an AI assistant through the sideby skill (spec §3.8).
3361
- if (secs.includes('ai')) out.push(link(t('ai.title'), t(skillInstalled() ? 'ai.installed' : 'ai.notInstalled'), skillInstalled() ? 'chip-ok' : '', openAi))
3513
+ const link = (label, state, cls, run, opens) => h('button', { class: 'menu-item set-link', type: 'button', onclick: () => { leave(opens); run() } },
3514
+ h('span', { class: 'menu-label' }, label), state ? h('span', { class: 'chip chip-sm ' + cls }, state) : null, icon('chevron'))
3515
+ const look = []
3516
+ if (secs.includes('lang')) look.push(seg(t('set.lang'), [['en', 'English'], ['zh', '中文']], lang, setLang))
3517
+ if (secs.includes('theme')) look.push(seg(t('set.theme'), CHOICES.theme.map((v) => [v, t('set.theme.' + v)]), viewPrefs().theme, setTheme))
3518
+ const start = [autostartRow()]
3519
+ if (!BOOT.readOnly) start.push(link(t('term.title'), '', '', openTerminals, true))
3520
+ const acct = []
3362
3521
  // The arrange mode (spec §3.25) has a place here too, where people look for "how do I change this".
3363
3522
  if (secs.includes('order')) {
3364
3523
  const pins = Object.values(displayOf().pinned || {}).some((x) => x.length)
3365
- out.push(link(t('set.order'), t(ordered() || pins ? 'set.orderMine' : 'set.orderDefault'), '', () => {
3524
+ acct.push(link(t('set.order'), t(ordered() || pins ? 'set.orderMine' : 'set.orderDefault'), '', () => {
3366
3525
  setPage('accounts')
3367
3526
  startArrange()
3368
3527
  const bar = document.querySelector('#accounts .arrange-bar')
3369
3528
  if (bar) bar.scrollIntoView({ behavior: REDUCED ? 'auto' : 'smooth', block: 'center' })
3370
3529
  }))
3371
3530
  }
3372
- if (secs.includes('rc')) {
3373
- const sum = rcSummary()
3374
- out.push(link(t('rc.button'), t('rc.state.' + sum), sum === 'on' ? 'chip-warn' : sum === 'off' ? 'chip-ok' : '', openRc))
3375
- }
3376
- if (secs.includes('ah')) {
3377
- const on = ahState().settings.auto
3378
- out.push(link(t('ah.button'), on ? t('ah.on') : t('ah.off'), on ? 'chip-ok' : '', () => setPage('handoff')))
3379
- }
3380
3531
  // Quota checks that ask the Host (spec §3.26): one switch per Family, with what it does in plain words.
3381
3532
  if (secs.includes('checks')) {
3382
- if (out.length) out.push(h('div', { class: 'menu-sep', role: 'separator' }))
3383
3533
  for (const c of S.state.quotaChecks) {
3384
3534
  const label = t('set.quotaCheck', { host: c.title })
3385
- out.push(h('div', { class: 'set-row set-check' },
3535
+ acct.push(h('div', { class: 'set-row set-check' },
3386
3536
  h('div', { class: 'set-text' }, h('span', { class: 'set-label' }, label), h('span', { class: 'set-hint' }, c.blocked ? c.blocked : t('set.quotaCheckHint', { host: c.title, n: c.everyMinutes }))),
3387
3537
  BOOT.readOnly ? h('span', { class: 'chip chip-sm' + (c.on ? ' chip-ok' : '') }, c.on ? t('ah.on') : t('ah.off'))
3388
3538
  : h('div', { class: 'seg', role: 'group', 'aria-label': label }, [[true, t('ah.on')], [false, t('ah.off')]].map(([v, text]) =>
3389
- h('button', { class: 'seg-btn', type: 'button', 'aria-pressed': String(v === c.on), onclick: () => { if (v !== c.on) { closeMenu(); setCheck(c.family, v) } } }, text)))))
3539
+ h('button', { class: 'seg-btn', type: 'button', 'aria-pressed': String(v === c.on), onclick: () => { if (v !== c.on) { leave(false); setCheck(c.family, v) } } }, text)))))
3390
3540
  }
3391
3541
  }
3542
+ if (secs.includes('rc')) {
3543
+ const sum = rcSummary()
3544
+ acct.push(link(t('rc.button'), t('rc.state.' + sum), sum === 'on' ? 'chip-warn' : sum === 'off' ? 'chip-ok' : '', openRc, true))
3545
+ }
3546
+ if (secs.includes('ah')) {
3547
+ const on = ahState().settings.auto
3548
+ acct.push(link(t('ah.button'), on ? t('ah.on') : t('ah.off'), on ? 'chip-ok' : '', () => setPage('handoff')))
3549
+ }
3550
+ // Everything here can also be asked of an AI assistant through the sideby skill (spec §3.8).
3551
+ const ai = [
3552
+ link(t('ai.title'), t(skillInstalled() ? 'ai.installed' : 'ai.notInstalled'), skillInstalled() ? 'chip-ok' : '', () => openAsk(), true),
3553
+ link(t('ask.plugin'), '', '', () => openAsk({ task: 'plugin', context: { version: BOOT.version } }), true),
3554
+ ]
3555
+ return [
3556
+ { title: t('set.g.look'), items: look },
3557
+ { title: t('set.g.start'), items: start },
3558
+ { title: t('set.g.accounts'), items: acct },
3559
+ { title: t('set.g.ai'), items: ai },
3560
+ ].filter((g) => g.items.length)
3561
+ }
3562
+ function settingsBody(leave) {
3563
+ const out = []
3564
+ settingsGroups(leave).forEach((g, i) => {
3565
+ if (i) out.push(h('div', { class: 'menu-sep set-sep', role: 'separator' }))
3566
+ out.push(h('div', { class: 'set-group', role: 'group', 'aria-label': g.title }, h('div', { class: 'set-group-title', 'aria-hidden': 'true' }, g.title), g.items))
3567
+ })
3392
3568
  return out
3393
3569
  }
3570
+ // Taller than the window, the popover scrolls inside and ends with "More settings…", the same groups in a dialog.
3571
+ function settingsPop() {
3572
+ if (!as.status && !as.loading && !as.error) loadAutostart()
3573
+ return [...settingsBody(() => closeMenu()),
3574
+ h('button', { class: 'menu-item set-more', type: 'button', hidden: true, onclick: () => { closeMenu(); openSettingsDialog() } }, h('span', { class: 'menu-label' }, t('set.more')), icon('chevron'))]
3575
+ }
3576
+ function settingsFit() {
3577
+ const el = menu.el
3578
+ const more = el && el.querySelector('.set-more')
3579
+ if (!more) return
3580
+ more.hidden = true
3581
+ more.hidden = !(el.scrollHeight > el.clientHeight + 1)
3582
+ }
3583
+ function openSettingsDialog() {
3584
+ if (!as.status && !as.loading) loadAutostart()
3585
+ showDialog('settings')
3586
+ renderSettingsDialog()
3587
+ }
3588
+ function renderSettingsDialog() {
3589
+ const leave = (opens) => { if (!opens && dlg.kind === 'settings') dlg.el.close() }
3590
+ setKids(dlg.el, dialogFrame(t('set.title'), null, h('div', { class: 'set-dialog' }, settingsBody(leave)),
3591
+ [h('button', { class: 'btn', type: 'button', onclick: () => dlg.el.close() }, t('btn.close'))]))
3592
+ }
3593
+ function refreshSettings() {
3594
+ refreshPopover('settings')
3595
+ if (dlg.kind === 'settings') renderSettingsDialog()
3596
+ }
3597
+
3598
+ // ---------- start at login (spec §3.29, ADR-0029) ----------
3599
+ // The switch reads its state when the settings open; moving it shows the file and what will run, and only the
3600
+ // confirmation writes. A read-only Panel shows the state only.
3601
+ const as = { status: null, loading: false, error: null, want: null, plan: null, planning: false, planError: null, busy: false }
3602
+ async function loadAutostart() {
3603
+ as.loading = true
3604
+ try { as.status = await api('/api/autostart'); as.error = null } catch (e) { as.error = e }
3605
+ as.loading = false
3606
+ refreshSettings()
3607
+ }
3608
+ function autostartRow() {
3609
+ const s = as.status
3610
+ const n = autostartNote(s, BOOT.readOnly)
3611
+ const label = t('as.label')
3612
+ const hint = [as.error ? t('as.failed', { msg: as.error.message }) : t(n.key)]
3613
+ if (n.action === 'fix') hint.push(' ', h('button', { class: 'link-btn small', type: 'button', onclick: () => autostartAsk(true) }, t('as.fix')))
3614
+ if (s && s.state === 'foreign') hint.push(' ', h('button', { class: 'link-btn small', type: 'button', title: s.path, onclick: () => copyText(s.path) }, t('as.copyPath')))
3615
+ let ctl = null
3616
+ if (!s) ctl = as.loading ? spinner() : null
3617
+ else if (n.toggle) ctl = h('div', { class: 'seg', role: 'group', 'aria-label': label }, [[true, t('ah.on')], [false, t('ah.off')]].map(([v, text]) =>
3618
+ h('button', { class: 'seg-btn', type: 'button', 'aria-pressed': String(v === n.on), onclick: () => { if (v !== n.on) autostartAsk(v) } }, text)))
3619
+ else if (s.supported && s.state !== 'foreign') ctl = h('span', { class: 'chip chip-sm' + (n.on ? ' chip-ok' : '') }, n.on ? t('ah.on') : t('ah.off'))
3620
+ return h('div', { class: 'set-row set-check' },
3621
+ h('div', { class: 'set-text' }, h('span', { class: 'set-label' }, label), h('span', { class: 'set-hint' }, hint)), ctl)
3622
+ }
3623
+ async function autostartAsk(on) {
3624
+ if (menu.kind === 'settings') closeMenu()
3625
+ as.want = on
3626
+ as.plan = null
3627
+ as.planError = null
3628
+ as.planning = true
3629
+ showDialog('autostart')
3630
+ renderAutostart()
3631
+ try { as.plan = (await api('/api/autostart', { on })).plan } catch (e) { as.planError = e }
3632
+ as.planning = false
3633
+ if (dlg.kind === 'autostart') renderAutostart()
3634
+ }
3635
+ function renderAutostart() {
3636
+ const p = as.plan
3637
+ const on = as.want
3638
+ const body = []
3639
+ if (as.planning) body.push(h('p', { class: 'muted' }, spinner(), ' ', t('as.planning')))
3640
+ else if (as.planError) body.push(errorNote(as.planError, () => autostartAsk(on)))
3641
+ else if (p && p.blocked) {
3642
+ const own = 'as.block.' + p.blocked.code
3643
+ body.push(h('p', { class: 'note note-fail' }, Object.hasOwn(MESSAGES.en, own) ? t(own) : p.blocked.message))
3644
+ if (['npx-cache', 'temp-dir', 'unsafe-path'].includes(p.blocked.code)) body.push(codeCopy('npm i -g sideby'))
3645
+ if (p.blocked.code === 'foreign') body.push(codeCopy(p.path))
3646
+ } else if (p && !p.changed) body.push(h('p', null, t(on ? 'as.alreadyOn' : 'as.alreadyOff')))
3647
+ else if (p && on) {
3648
+ body.push(h('p', null, t('as.writes')), h('code', { class: 'as-code' }, p.path))
3649
+ body.push(h('p', null, t('as.runs')), h('code', { class: 'as-code' }, (p.programArguments || []).join(' ')))
3650
+ body.push(h('p', { class: 'muted small' }, t('as.noWindow')))
3651
+ } else if (p) {
3652
+ body.push(h('p', null, t('as.removes')), h('code', { class: 'as-code' }, p.path))
3653
+ body.push(h('p', { class: 'muted small' }, t('as.offNote')))
3654
+ }
3655
+ const foot = [h('button', { class: 'btn', type: 'button', disabled: as.busy, onclick: () => dlg.el.close() }, t(p && p.changed && !p.blocked ? 'btn.cancel' : 'btn.close'))]
3656
+ if (p && p.changed && !p.blocked)
3657
+ foot.push(h('button', { class: 'btn btn-primary', type: 'button', disabled: as.busy, 'aria-busy': as.busy ? 'true' : null, onclick: autostartApply }, as.busy ? spinner() : null, t(on ? 'as.turnOn' : 'as.turnOff')))
3658
+ setKids(dlg.el, dialogFrame(t(on ? 'as.onTitle' : 'as.offTitle'), null, body, foot))
3659
+ }
3660
+ async function autostartApply() {
3661
+ const on = as.want
3662
+ as.busy = true
3663
+ dlg.busy = true
3664
+ renderAutostart()
3665
+ let r = null
3666
+ try { r = await api('/api/autostart', { on, confirm: true, programArguments: as.plan.programArguments || [] }) } catch (e) { as.planError = e }
3667
+ as.busy = false
3668
+ dlg.busy = false
3669
+ if (r && r.applied) {
3670
+ as.status = r.status
3671
+ if (dlg.kind === 'autostart') dlg.el.close()
3672
+ toast(t(on ? 'as.doneOn' : 'as.doneOff') + (on && !r.loaded ? ' ' + t('as.nextLogin') : ''))
3673
+ refreshSettings()
3674
+ return
3675
+ }
3676
+ if (r) { as.plan = r.plan; if (r.changedSince) toast(t('as.changedSince')) }
3677
+ if (dlg.kind === 'autostart') renderAutostart()
3678
+ }
3394
3679
  function setLang(v) {
3395
3680
  if (LANG_FIXED || v === lang) return
3681
+ if (CFG) { cfgPanel.language = v; saveCfg({ language: v }) } else { prefs.lang = v; savePrefs() }
3682
+ showLang(v)
3683
+ }
3684
+ // Writes config panel.* (spec §3.29). A failure says so; the next state read then shows what config holds.
3685
+ async function saveCfg(patch, quiet) {
3686
+ cfgSaving++
3687
+ try { await api('/api/panel/settings', patch); return true }
3688
+ catch (e) { if (!quiet) toast(t('set.saveFailed', { msg: e.message }), { kind: 'fail' }); return false }
3689
+ finally { cfgSaving-- }
3690
+ }
3691
+ // A theme or language changed elsewhere (the CLI, an AI assistant) shows at the next state read.
3692
+ function adoptPanel(next) {
3693
+ if (!CFG || cfgSaving) return
3694
+ next = next || {}
3695
+ if (next.theme === cfgPanel.theme && next.language === cfgPanel.language) return
3696
+ cfgPanel = Object.assign({}, next)
3697
+ applyTheme()
3698
+ const l = panelLang(BOOT.lang, viewPrefs().language, navigator.language)
3699
+ if (l !== lang) showLang(l)
3700
+ else refreshSettings()
3701
+ }
3702
+ function showLang(v) {
3396
3703
  lang = v
3397
- prefs.lang = lang
3398
- savePrefs()
3399
3704
  renderStatic()
3400
3705
  buildToolbar()
3401
3706
  renderAll()
@@ -3406,32 +3711,23 @@ const MAIN = String.raw `
3406
3711
  else if (dlg.kind === 'browser') renderBrowser()
3407
3712
  else if (dlg.kind === 'update' && up) renderUpdate()
3408
3713
  else if (dlg.kind === 'rc') renderRc()
3714
+ else if (dlg.kind === 'ask' && ak) { if (ak.els.text) ak.text = ak.els.text.value; renderAsk() }
3715
+ else if (dlg.kind === 'key' && ky && ky.step !== 'enter') renderKey()
3716
+ else if (dlg.kind === 'terminal' && tm) renderTerminals()
3717
+ else if (dlg.kind === 'autostart') renderAutostart()
3409
3718
  }
3410
3719
  function setTheme(v) {
3411
- prefs.theme = v
3412
- savePrefs()
3720
+ if (CFG) { cfgPanel.theme = v; saveCfg({ theme: v }) } else { prefs.theme = v; savePrefs() }
3413
3721
  applyTheme()
3414
- refreshPopover('settings')
3722
+ refreshSettings()
3415
3723
  }
3416
3724
  // The sideby Agent Skill: what to say to an AI assistant instead of clicking, and how to install it when needed.
3417
3725
  function skillInstalled() {
3418
3726
  const u = S.state && S.state.update
3419
3727
  return Boolean(u && u.skillUpdate && u.skillUpdate.installed && u.skillUpdate.installed.length)
3420
3728
  }
3421
- const AI_ASKS = ['ai.ask.quota', 'ai.ask.new', 'ai.ask.pin', 'ai.ask.fix', 'ai.ask.handoff', 'ai.ask.rc']
3422
- function openAi() {
3423
- showDialog('ai')
3424
- const asks = AI_ASKS.map((k) => {
3425
- const text = t(k)
3426
- return h('li', null, h('button', { class: 'ai-ask', type: 'button', title: t('ai.copy'), onclick: () => copyText(text) }, icon('copy'), h('span', null, text)))
3427
- })
3428
- setKids(dlg.el, dialogFrame(t('ai.title'), null, h('div', { class: 'form' },
3429
- h('p', null, t('ai.intro')),
3430
- skillInstalled() ? h('p', { class: 'note' }, t('ai.ready')) : h('div', { class: 'field' }, h('p', { class: 'small' }, t('ai.install')), codeCopy(BOOT.skillInstall)),
3431
- h('p', { class: 'small muted' }, t('ai.examples')),
3432
- h('ul', { class: 'ai-asks' }, asks)),
3433
- [h('button', { class: 'btn', type: 'button', onclick: () => dlg.el.close() }, t('btn.close'))]))
3434
- }
3729
+ // Example phrases: a click puts one in the Let AI do it box (spec §3.28).
3730
+ const AI_ASKS = ['ai.ask.quota', 'ai.ask.new', 'ai.ask.api', 'ai.ask.pin', 'ai.ask.fix', 'ai.ask.handoff', 'ai.ask.rc', 'ai.ask.tool']
3435
3731
  function openRc() {
3436
3732
  if (!S.state || !rcSummary()) return
3437
3733
  rc.target = null
@@ -3467,6 +3763,524 @@ const MAIN = String.raw `
3467
3763
  setKids(dlg.el, dialogFrame(t('rc.dialogTitle'), null, body, foot))
3468
3764
  }
3469
3765
 
3766
+ // ---------- AI Tasks (spec §3.28) ----------
3767
+ // One dialog for every "let AI do it" entry: what to do, then the account and the terminal it will use, then one
3768
+ // button. When nothing can run it, or the panel is read-only, it copies the prompt instead.
3769
+ let ak = null
3770
+ const TERM_NAMES = { terminal: 'Terminal', iterm2: 'iTerm2', ghostty: 'Ghostty', orca: 'Orca', wezterm: 'WezTerm', kitty: 'kitty', alacritty: 'Alacritty', custom: 'Custom' }
3771
+ function termName(id) {
3772
+ const list = ak && ak.terms ? ak.terms.terminals : []
3773
+ const f = list.find((x) => x.id === id)
3774
+ return f ? f.name : TERM_NAMES[id] || id
3775
+ }
3776
+ function askBody() {
3777
+ const body = { task: ak.task, lang }
3778
+ const text = ak.els && ak.els.text ? ak.els.text.value.trim() : ak.text
3779
+ if (text) body.text = text
3780
+ if (ak.context) body.context = ak.context
3781
+ if (ak.account && ak.accountChanged) body.account = ak.account
3782
+ if (ak.terminal && ak.terminalChanged) body.terminal = ak.terminal
3783
+ const ids = ak.files.filter((f) => f.status === 'ready').map((f) => f.id)
3784
+ if (ids.length) body.attachments = ids
3785
+ return body
3786
+ }
3787
+ function openAsk(opts) {
3788
+ opts = opts || {}
3789
+ closeMenu()
3790
+ ak = { task: opts.task || 'free', text: opts.text || '', context: opts.context || null, account: null, accountChanged: false, terminal: null, terminalChanged: false, pick: null, reason: null, terms: null, loading: true, busy: false, result: null, error: null, removed: false, files: [], els: {} }
3791
+ showDialog('ask')
3792
+ hookAskDrops()
3793
+ const mine = ak
3794
+ // Uploads still running when the dialog closes are stopped; the server drops staged files it never received.
3795
+ dlg.onClose = () => { mine.files.forEach((f) => { if (f.xhr) f.xhr.abort() }) }
3796
+ renderAsk()
3797
+ if (ak.els.text) ak.els.text.focus()
3798
+ askLoad()
3799
+ }
3800
+ async function askLoad() {
3801
+ const mine = ak
3802
+ const body = askBody()
3803
+ delete body.account
3804
+ try {
3805
+ const [pick, terms] = await Promise.all([
3806
+ api('/api/ask/prompt', body),
3807
+ BOOT.readOnly ? Promise.resolve(null) : api('/api/terminals').catch(() => null),
3808
+ ])
3809
+ if (ak !== mine) return
3810
+ ak.pick = pick.account
3811
+ ak.reason = pick.reason || null
3812
+ if (!ak.accountChanged) ak.account = pick.account
3813
+ ak.terms = terms
3814
+ if (terms && !ak.terminalChanged) ak.terminal = terms.guess.id
3815
+ } catch (e) { if (ak === mine) ak.error = e }
3816
+ if (ak !== mine) return
3817
+ ak.loading = false
3818
+ if (dlg.kind === 'ask') renderAsk()
3819
+ }
3820
+ // Title, placeholder and examples of the dialog for its task (spec §3.28 "每个入口的提示").
3821
+ function askStrings() { return askCopy(ak.task, (k) => Object.hasOwn(MESSAGES.en, k), AI_ASKS) }
3822
+ function askChoices() { return aiTaskAccounts((S.state && S.state.accounts) || []) }
3823
+ function askTaskLine() {
3824
+ if (ak.task === 'free') return null
3825
+ const ctx = ak.context || {}
3826
+ return h('p', { class: 'note' }, t('ask.task.' + ak.task, { n: (ctx.findings || []).length, ref: ctx.account || '' }))
3827
+ }
3828
+ function askTextField() {
3829
+ const e = ak.els
3830
+ const copy = askStrings()
3831
+ e.text = h('textarea', { id: 'ak-text', class: 'ask-text', rows: ak.task === 'free' ? '3' : '2', maxlength: '8000', placeholder: t(copy.placeholder), 'aria-describedby': 'ak-warn' })
3832
+ e.text.value = ak.text
3833
+ e.warn = h('p', { class: 'field-error field-note', id: 'ak-warn', role: 'status' })
3834
+ const warnNow = () => { e.warn.textContent = stripSecrets(e.text.value).removed ? t('ask.keyWarn') : '' }
3835
+ e.text.addEventListener('input', () => { ak.text = e.text.value; warnNow() })
3836
+ warnNow()
3837
+ const label = h('label', { class: 'field', for: 'ak-text' }, h('span', null, t(ak.task === 'free' ? 'ask.what' : 'ask.whatMore')), e.text)
3838
+ const chips = h('div', { class: 'field' }, h('p', { class: 'small muted' }, t('ask.examples')),
3839
+ h('div', { class: 'ask-chips' }, copy.chips.map((k) => h('button', { class: 'chip chip-btn', type: 'button', onclick: () => { e.text.value = t(k); ak.text = e.text.value; warnNow(); e.text.focus() } }, t(k)))))
3840
+ return [h('div', { class: 'field' }, label, e.warn), askAttachField(), chips]
3841
+ }
3842
+
3843
+ // Attachments (spec §3.28 "附件"): pasted, dropped or picked files upload at once, one request each, so a wrong
3844
+ // type or a file that is too big is named right away. The page shows image thumbnails from the file itself as a
3845
+ // data: URL (the CSP allows no blob:); the server decides the type from the first bytes.
3846
+ const ATTACH_MAX = 5
3847
+ const ATTACH_ACCEPT = 'image/png,image/jpeg,image/gif,image/webp,video/mp4,video/quicktime,video/webm'
3848
+ let askHooked = false
3849
+ function hookAskDrops() {
3850
+ if (askHooked) return
3851
+ askHooked = true
3852
+ const el = dlg.el
3853
+ const active = () => dlg.kind === 'ask' && ak && !ak.result && !BOOT.readOnly
3854
+ const hasFiles = (e) => e.dataTransfer && Array.prototype.indexOf.call(e.dataTransfer.types || [], 'Files') >= 0
3855
+ el.addEventListener('paste', (e) => {
3856
+ const files = e.clipboardData ? Array.from(e.clipboardData.files || []) : []
3857
+ if (!active() || !files.length) return
3858
+ // A file copied in Finder also carries its name as text; only the file is wanted.
3859
+ e.preventDefault()
3860
+ askAddFiles(files)
3861
+ })
3862
+ el.addEventListener('dragover', (e) => {
3863
+ if (!active() || !hasFiles(e)) return
3864
+ e.preventDefault()
3865
+ e.dataTransfer.dropEffect = 'copy'
3866
+ el.classList.add('drop-on')
3867
+ })
3868
+ el.addEventListener('dragleave', (e) => { if (e.target === el || !el.contains(e.relatedTarget)) el.classList.remove('drop-on') })
3869
+ el.addEventListener('drop', (e) => {
3870
+ el.classList.remove('drop-on')
3871
+ if (!active() || !hasFiles(e)) return
3872
+ e.preventDefault()
3873
+ askAddFiles(Array.from(e.dataTransfer.files || []))
3874
+ })
3875
+ }
3876
+ function askAttachField() {
3877
+ if (BOOT.readOnly) return null
3878
+ const input = h('input', { type: 'file', multiple: true, accept: ATTACH_ACCEPT, class: 'sr-only', tabindex: '-1', 'aria-hidden': 'true' })
3879
+ input.addEventListener('change', () => { askAddFiles(Array.from(input.files || [])); input.value = '' })
3880
+ ak.els.files = h('div', { class: 'ask-files-wrap' })
3881
+ renderAskFiles()
3882
+ return h('div', { class: 'field ask-attach' },
3883
+ h('div', { class: 'ask-attach-row' },
3884
+ h('button', { class: 'btn btn-sm', type: 'button', onclick: () => input.click() }, icon('image'), t('ask.attach')),
3885
+ h('span', { class: 'small muted' }, t('ask.attachHint')), input),
3886
+ ak.els.files)
3887
+ }
3888
+ function renderAskFiles() {
3889
+ const box = ak && ak.els.files
3890
+ if (!box) return
3891
+ const files = ak.files
3892
+ const tiles = files.map((f) => {
3893
+ const remove = h('button', { class: 'icon-btn ask-file-x', type: 'button', 'aria-label': t('ask.attachRemove', { name: f.name }), onclick: () => askRemoveFile(f) }, icon('x'))
3894
+ let face
3895
+ if (f.status === 'uploading') {
3896
+ f.pctEl = h('span', { class: 'small' }, t('ask.attachUploading', { pct: Math.round(f.progress * 100) }))
3897
+ face = h('div', { class: 'ask-file-face' }, spinner(), f.pctEl)
3898
+ } else if (f.status === 'error') face = h('div', { class: 'ask-file-face' }, icon('x'))
3899
+ else if (f.thumb) face = h('img', { class: 'ask-file-img', src: f.thumb, alt: f.name })
3900
+ else face = h('div', { class: 'ask-file-face' }, icon(f.kind === 'video' ? 'film' : 'image'), f.kind === 'video' ? h('span', { class: 'small' }, t('ask.attachVideo')) : null)
3901
+ return h('li', { class: 'ask-file' + (f.status === 'error' ? ' is-error' : '') },
3902
+ face,
3903
+ h('span', { class: 'ask-file-name small', title: f.name }, f.name),
3904
+ h('span', { class: 'small muted' }, fileSize(f.bytes || f.size)),
3905
+ remove)
3906
+ })
3907
+ const errors = files.filter((f) => f.status === 'error').map((f) => h('p', { class: 'field-error' }, f.error))
3908
+ const live = files.some((f) => f.status !== 'error')
3909
+ setKids(box, [
3910
+ tiles.length ? h('ul', { class: 'ask-files', 'aria-label': t('ask.attach') }, tiles) : null,
3911
+ errors,
3912
+ ak.attachFull ? h('p', { class: 'field-error' }, t('ask.attachMax')) : null,
3913
+ live ? h('p', { class: 'note note-warn small' }, t('ask.attachWarn')) : null,
3914
+ ])
3915
+ }
3916
+ function askAddFiles(list) {
3917
+ if (!ak || !list.length) return
3918
+ ak.attachFull = false
3919
+ for (const file of list) {
3920
+ if (ak.files.filter((f) => f.status !== 'error').length >= ATTACH_MAX) { ak.attachFull = true; break }
3921
+ const f = { key: Math.random(), name: file.name || 'file', size: file.size, bytes: 0, kind: '', status: 'uploading', progress: 0, id: null, thumb: null, error: '', xhr: null, done: null }
3922
+ ak.files.push(f)
3923
+ // Over the largest limit there is no point uploading: the server would refuse it.
3924
+ if (file.size > 200 * 1024 * 1024) { f.status = 'error'; f.error = t('ask.attachTooBig', { name: f.name }); continue }
3925
+ f.done = askUpload(f, file, ak)
3926
+ }
3927
+ renderAskFiles()
3928
+ }
3929
+ function askRemoveFile(f) {
3930
+ if (f.xhr) f.xhr.abort()
3931
+ // Free the staged copy now; if this fails the server clears it after a day anyway.
3932
+ if (f.id) api('/api/ask/attach/remove', { ids: [f.id] }).catch(() => {})
3933
+ ak.files = ak.files.filter((x) => x !== f)
3934
+ ak.attachFull = false
3935
+ renderAskFiles()
3936
+ }
3937
+ function askPost(file, onProgress, xhrOut) {
3938
+ return new Promise((resolve) => {
3939
+ const xhr = new XMLHttpRequest()
3940
+ xhrOut(xhr)
3941
+ xhr.open('POST', BOOT.basePath + '/api/ask/attach')
3942
+ xhr.setRequestHeader('x-sideby-token', token)
3943
+ xhr.setRequestHeader('content-type', file.type || 'application/octet-stream')
3944
+ xhr.upload.onprogress = (e) => { if (e.lengthComputable) onProgress(e.loaded / e.total) }
3945
+ xhr.onload = () => { let data = null; try { data = JSON.parse(xhr.responseText) } catch {} resolve({ status: xhr.status, data }) }
3946
+ xhr.onerror = () => resolve({ status: 0, data: null })
3947
+ xhr.onabort = () => resolve({ status: -1, data: null })
3948
+ xhr.send(file)
3949
+ })
3950
+ }
3951
+ async function askUpload(f, file, mine) {
3952
+ const progress = (p) => { f.progress = p; if (f.pctEl) f.pctEl.textContent = t('ask.attachUploading', { pct: Math.round(p * 100) }) }
3953
+ let r = await askPost(file, progress, (x) => { f.xhr = x })
3954
+ if (r.status === 403 && r.data && r.data.code === 'bad-token') {
3955
+ try { await refreshToken(); r = await askPost(file, progress, (x) => { f.xhr = x }) } catch {}
3956
+ }
3957
+ f.xhr = null
3958
+ if (r.status === -1 || ak !== mine) return
3959
+ if (r.status === 200 && r.data && r.data.id) {
3960
+ f.id = r.data.id
3961
+ f.kind = r.data.kind
3962
+ f.bytes = r.data.bytes
3963
+ if (f.kind === 'image') f.thumb = await thumbnail(file)
3964
+ f.status = 'ready'
3965
+ } else {
3966
+ f.status = 'error'
3967
+ f.error = t(r.status === 413 ? 'ask.attachTooBig' : r.status === 415 ? 'ask.attachType' : r.status === 507 ? 'ask.attachFull' : 'ask.attachFailed', { name: f.name })
3968
+ }
3969
+ if (ak === mine && dlg.kind === 'ask') renderAskFiles()
3970
+ }
3971
+ // A small JPEG data: URL of an image, drawn on a canvas so a 20 MB screenshot is not kept in the page.
3972
+ function thumbnail(file) {
3973
+ return new Promise((resolve) => {
3974
+ const reader = new FileReader()
3975
+ reader.onerror = () => resolve(null)
3976
+ reader.onload = () => {
3977
+ const img = new Image()
3978
+ img.onerror = () => resolve(null)
3979
+ img.onload = () => {
3980
+ try {
3981
+ const scale = Math.min(1, 192 / Math.max(img.width, img.height))
3982
+ const c = document.createElement('canvas')
3983
+ c.width = Math.max(1, Math.round(img.width * scale))
3984
+ c.height = Math.max(1, Math.round(img.height * scale))
3985
+ c.getContext('2d').drawImage(img, 0, 0, c.width, c.height)
3986
+ resolve(c.toDataURL('image/jpeg', 0.8))
3987
+ } catch { resolve(null) }
3988
+ }
3989
+ img.src = String(reader.result)
3990
+ }
3991
+ reader.readAsDataURL(file)
3992
+ })
3993
+ }
3994
+ // Open and Copy prompt wait for uploads still running, so the task carries every file the person added.
3995
+ async function askUploadsDone() {
3996
+ const pending = ak.files.filter((f) => f.status === 'uploading' && f.done).map((f) => f.done)
3997
+ if (pending.length) await Promise.all(pending)
3998
+ }
3999
+ function askConfirm() {
4000
+ if (BOOT.readOnly) return h('p', { class: 'note' }, t('ask.readOnly'))
4001
+ if (ak.loading) return h('p', { class: 'muted small' }, spinner(), ' ', t('ask.loading'))
4002
+ if (ak.error) return errorNote(ak.error, () => { ak.error = null; ak.loading = true; renderAsk(); askLoad() })
4003
+ const choices = askChoices()
4004
+ const out = []
4005
+ if (!ak.account) {
4006
+ out.push(h('div', { class: 'note note-warn' }, h('span', null, t('ask.noAccount.' + (ak.reason || 'no-accounts'))), h('span', { class: 'note-line' }, t('ask.noAccountHint'))))
4007
+ const main = ak.reason === 'none-signed-in' && S.state ? S.state.accounts.find((a) => a.isMain && a.kind !== 'api') : null
4008
+ if (main) out.push(h('div', null, h('button', { class: 'btn btn-sm', type: 'button', onclick: () => signIn(main) }, icon('key'), t('ask.signInMain', { ref: main.ref }))))
4009
+ }
4010
+ const rows = []
4011
+ if (choices.length) {
4012
+ const sel = h('select', { id: 'ak-account', 'aria-label': t('ask.account') },
4013
+ ak.account ? null : h('option', { value: '' }, t('ask.chooseAccount')),
4014
+ choices.map((a) => h('option', { value: a.ref }, a.ref + (a.kind === 'api' ? ' · API' : ''))))
4015
+ sel.value = ak.account || ''
4016
+ sel.addEventListener('change', () => { ak.account = sel.value || null; ak.accountChanged = Boolean(sel.value); renderAsk() })
4017
+ rows.push(h('label', { class: 'field', for: 'ak-account' }, h('span', null, t('ask.account')), sel))
4018
+ }
4019
+ const installed = ak.terms ? ak.terms.installed : []
4020
+ if (installed.length && ak.account) {
4021
+ const sel = h('select', { id: 'ak-term', 'aria-label': t('ask.terminal') }, installed.map((id) => h('option', { value: id }, termName(id))))
4022
+ sel.value = ak.terminal || installed[0]
4023
+ sel.addEventListener('change', () => { ak.terminal = sel.value; ak.terminalChanged = true; renderAsk() })
4024
+ rows.push(h('label', { class: 'field', for: 'ak-term' }, h('span', null, t('ask.terminal')), sel))
4025
+ }
4026
+ if (rows.length) out.push(h('div', { class: 'ask-pick' }, rows))
4027
+ if (ak.account) out.push(h('p', { class: 'small' }, t('ask.confirm', { terminal: termName(ak.terminal || 'terminal'), ref: ak.account })))
4028
+ return out
4029
+ }
4030
+ function askResult() {
4031
+ const r = ak.result
4032
+ const term = termName(r.terminal || ak.terminal || 'terminal')
4033
+ return [
4034
+ h('div', { class: 'note note-warn' }, h('b', null, t('ask.failed', { terminal: term })), h('span', { class: 'note-line' }, t('ask.paste'))),
4035
+ codeCopy(r.command),
4036
+ r.automation ? h('p', { class: 'small' }, t('ask.automation', { terminal: term })) : null,
4037
+ r.reason === 'not-installed' ? h('p', { class: 'small muted' }, t('ask.notInstalled')) : null,
4038
+ !r.automation && r.reason !== 'not-installed' && r.detail ? h('p', { class: 'small muted' }, r.detail) : null,
4039
+ ]
4040
+ }
4041
+ function askYourself() {
4042
+ return h('details', { class: 'more-opts' }, h('summary', null, t('ask.yourself')),
4043
+ h('p', { class: 'small' }, t('ai.intro')),
4044
+ skillInstalled() ? h('p', { class: 'note' }, t('ai.ready')) : h('div', { class: 'field' }, h('p', { class: 'small' }, t('ai.install')), codeCopy(BOOT.skillInstall)))
4045
+ }
4046
+ function renderAsk() {
4047
+ if (!ak) return
4048
+ const el = dlg.el
4049
+ const body = [h('p', { class: 'muted small' }, t('ask.intro')), askTaskLine()]
4050
+ const foot = [h('button', { class: 'btn', type: 'button', disabled: ak.busy, onclick: () => el.close() }, t(ak.result ? 'btn.close' : 'btn.cancel'))]
4051
+ if (ak.result) {
4052
+ body.push(askResult())
4053
+ if (ak.result.removedSecrets) body.push(h('p', { class: 'small muted' }, t('ask.keyRemoved')))
4054
+ } else {
4055
+ body.push(askTextField(), askConfirm(), askYourself())
4056
+ const canOpen = !BOOT.readOnly && !ak.loading && !ak.error && ak.account
4057
+ if (canOpen) {
4058
+ const term = termName(ak.terminal || 'terminal')
4059
+ foot.push(h('button', { class: 'btn btn-primary', type: 'button', disabled: ak.busy, 'aria-busy': ak.busy ? 'true' : null, onclick: askOpen }, ak.busy ? spinner() : icon('terminal'), ak.busy ? t(ak.files.some((f) => f.status === 'uploading') ? 'ask.attachWaiting' : 'ask.opening') : t('ask.open', { terminal: term })))
4060
+ } else if (!ak.loading || BOOT.readOnly) {
4061
+ foot.push(h('button', { class: 'btn btn-primary', type: 'button', disabled: ak.busy, onclick: askCopyPrompt }, icon('copy'), t('ask.copyPrompt')))
4062
+ }
4063
+ }
4064
+ setKids(el, dialogFrame(t(askStrings().title), null, body, foot))
4065
+ }
4066
+ async function askCopyPrompt() {
4067
+ const mine = ak
4068
+ ak.busy = true
4069
+ renderAsk()
4070
+ try {
4071
+ await askUploadsDone()
4072
+ const r = await api('/api/ask/prompt', askBody())
4073
+ await copyText(r.prompt, t('ask.promptShown'))
4074
+ if (r.removedSecrets) toast(t('ask.keyRemoved'), { kind: 'info' })
4075
+ } catch (e) { toast(e.message, { kind: 'fail' }) }
4076
+ if (ak !== mine) return
4077
+ ak.busy = false
4078
+ if (dlg.kind === 'ask') renderAsk()
4079
+ }
4080
+ async function askOpen() {
4081
+ const mine = ak
4082
+ ak.busy = true
4083
+ renderAsk()
4084
+ try {
4085
+ await askUploadsDone()
4086
+ if (ak !== mine) return
4087
+ renderAsk()
4088
+ const r = await api('/api/ask', askBody())
4089
+ if (ak !== mine) return
4090
+ ak.busy = false
4091
+ if (r.opened) {
4092
+ toast(t('ask.opened', { terminal: termName(r.terminal), ref: r.account }))
4093
+ if (r.removedSecrets) toast(t('ask.keyRemoved'), { kind: 'info' })
4094
+ dlg.el.close()
4095
+ return
4096
+ }
4097
+ if (!r.command) {
4098
+ // No account can run it after all: the dialog turns into Copy prompt.
4099
+ ak.account = null
4100
+ ak.reason = r.reason || null
4101
+ renderAsk()
4102
+ return
4103
+ }
4104
+ ak.result = r
4105
+ renderAsk()
4106
+ copyText(r.command, t('ask.copiedCmd'))
4107
+ } catch (e) {
4108
+ if (ak !== mine) return
4109
+ ak.busy = false
4110
+ toast(e.message, { kind: 'fail' })
4111
+ renderAsk()
4112
+ }
4113
+ }
4114
+
4115
+ // Sign in: "sideby login <ref>" in the person's terminal, no AI involved; the command is copied when it cannot open.
4116
+ async function signIn(a) {
4117
+ const cmd = 'sideby login ' + a.ref
4118
+ if (BOOT.readOnly) { copyText(cmd); return }
4119
+ try {
4120
+ const r = await api('/api/login', { account: a.ref })
4121
+ if (r.opened) { toast(t('login.opened', { terminal: TERM_NAMES[r.terminal] || r.terminal, ref: a.ref })); return }
4122
+ await copyText(cmd, cmd)
4123
+ toast(r.automation ? t('login.automation', { terminal: TERM_NAMES[r.terminal] || r.terminal }) : t('login.failed'), { kind: 'info' })
4124
+ } catch (e) {
4125
+ await copyText(cmd, cmd)
4126
+ toast(e.message, { kind: 'fail' })
4127
+ }
4128
+ }
4129
+ function keyable(a) { return !BOOT.readOnly && (a.kind === 'api' || Boolean(family(a.family).keyAlways)) }
4130
+
4131
+ // Enter key (spec §3.28): a password field, saved into the account's proxy.env; then an optional "Try it" that says
4132
+ // what it runs and that it spends a few tokens before it runs.
4133
+ let ky = null
4134
+ function openKey(a) {
4135
+ if (!keyable(a)) return
4136
+ const fam = family(a.family)
4137
+ ky = { ref: a.ref, family: a.family, var: fam.keyVar || '', step: 'enter', busy: false, error: null, saved: null, tried: null, els: {} }
4138
+ showDialog('key')
4139
+ renderKey()
4140
+ if (ky.els.value) ky.els.value.focus()
4141
+ }
4142
+ function renderKey() {
4143
+ if (!ky) return
4144
+ const el = dlg.el
4145
+ const fam = family(ky.family)
4146
+ const body = []
4147
+ const foot = [h('button', { class: 'btn', type: 'button', disabled: ky.busy, onclick: () => el.close() }, t(ky.step === 'enter' ? 'btn.cancel' : 'btn.close'))]
4148
+ if (ky.step === 'enter') {
4149
+ const e = ky.els
4150
+ e.value = h('input', { id: 'ky-value', type: 'password', autocomplete: 'off', spellcheck: 'false', 'aria-describedby': 'ky-err' })
4151
+ e.varInput = h('input', { id: 'ky-var', type: 'text', autocomplete: 'off', autocapitalize: 'characters', spellcheck: 'false', maxlength: '64' })
4152
+ e.varInput.value = ky.var
4153
+ e.err = h('p', { class: 'field-error', id: 'ky-err', role: 'alert' }, ky.error ? ky.error.message : '')
4154
+ e.varInput.addEventListener('input', () => { ky.var = e.varInput.value.trim(); e.label.textContent = t('key.label', { var: ky.var || '?' }) })
4155
+ e.label = h('span', null, t('key.label', { var: ky.var || '?' }))
4156
+ body.push(h('p', { class: 'muted small' }, t('key.intro')),
4157
+ h('form', { id: 'ky-form', class: 'form', novalidate: true, onsubmit: (ev) => { ev.preventDefault(); keySave() } },
4158
+ h('label', { class: 'field', for: 'ky-value' }, e.label, e.value),
4159
+ fam.keyVar ? h('details', { class: 'more-opts' }, h('summary', null, t('key.otherVar')),
4160
+ h('label', { class: 'field', for: 'ky-var' }, h('span', null, t('key.varLabel')), e.varInput), h('p', { class: 'field-hint' }, t('key.varHint')))
4161
+ : h('div', { class: 'field' }, h('p', { class: 'field-hint' }, t('key.noVar')), h('label', { class: 'field', for: 'ky-var' }, h('span', null, t('key.varLabel')), e.varInput)),
4162
+ e.err))
4163
+ foot.push(h('button', { class: 'btn btn-primary', type: 'submit', form: 'ky-form', disabled: ky.busy, 'aria-busy': ky.busy ? 'true' : null }, ky.busy ? spinner() : null, ky.busy ? t('key.saving') : t('key.save')))
4164
+ } else {
4165
+ body.push(h('div', { class: 'created-head', role: 'status' }, h('span', { class: 'check-icon', 'aria-hidden': 'true' }, '✓'), h('span', null, t('key.saved', { var: ky.saved.var, file: ky.saved.path }))))
4166
+ const cmd = fam.tryCommand
4167
+ if (!cmd) body.push(h('p', { class: 'small muted' }, tx('key.cannotTry', { cmd: h('code', null, 'sideby run ' + ky.ref) })))
4168
+ else if (ky.step === 'saved') {
4169
+ body.push(h('p', { class: 'small' }, t('key.tryLead')))
4170
+ foot.push(h('button', { class: 'btn btn-primary', type: 'button', onclick: () => { ky.step = 'confirm'; renderKey() } }, t('key.try')))
4171
+ } else if (ky.step === 'confirm' || ky.step === 'trying') {
4172
+ body.push(h('p', { class: 'small' }, t('key.tryCost')), h('pre', { class: 'diff' }, cmd.map((v) => /^[A-Za-z0-9_./:=-]+$/.test(v) ? v : JSON.stringify(v)).join(' ')))
4173
+ if (ky.step === 'trying') body.push(h('p', { class: 'muted small' }, spinner(), ' ', t('key.trying')))
4174
+ foot.push(h('button', { class: 'btn btn-primary', type: 'button', disabled: ky.step === 'trying', 'aria-busy': ky.step === 'trying' ? 'true' : null, onclick: keyTry }, ky.step === 'trying' ? spinner() : null, t('key.tryRun')))
4175
+ } else if (ky.step === 'tried') {
4176
+ const r = ky.tried
4177
+ if (r.ok) body.push(h('div', { class: 'note' }, t('key.tryOk', { ref: ky.ref })))
4178
+ else {
4179
+ body.push(h('div', { class: 'note note-fail' }, t(r.timedOut ? 'key.tryTimeout' : 'key.tryFail')),
4180
+ h('p', { class: 'small muted' }, t('key.output')),
4181
+ h('pre', { class: 'diff' }, r.output || t('key.noOutput')))
4182
+ foot.push(h('button', { class: 'btn btn-primary', type: 'button', onclick: () => { const ref = ky.ref; const fam = ky.family; openAsk({ task: 'account', context: { account: ref, family: fam }, text: t('key.explainText', { ref, output: r.output || t('key.noOutput') }) }) } }, icon('bot'), t('key.explain')))
4183
+ }
4184
+ }
4185
+ if (ky.error) body.push(errorNote(ky.error, null))
4186
+ }
4187
+ setKids(el, dialogFrame(t('key.title', { ref: ky.ref }), ky.family, body, foot))
4188
+ }
4189
+ async function keySave() {
4190
+ const value = ky.els.value.value
4191
+ const name = ky.var.trim()
4192
+ if (!value.trim()) { ky.els.err.textContent = t('key.empty'); ky.els.value.focus(); return }
4193
+ if (!/^[A-Z][A-Z0-9_]*$/.test(name)) { ky.els.err.textContent = t('key.badVar'); return }
4194
+ const mine = ky
4195
+ ky.busy = true
4196
+ dlg.busy = true
4197
+ ky.error = null
4198
+ renderKey()
4199
+ try {
4200
+ ky.saved = await api('/api/key', { account: ky.ref, var: name, value })
4201
+ ky.step = 'saved'
4202
+ } catch (e) { ky.error = e }
4203
+ dlg.busy = false
4204
+ if (ky !== mine) return
4205
+ ky.busy = false
4206
+ renderKey()
4207
+ if (ky.step === 'enter' && ky.els.value) ky.els.value.focus()
4208
+ }
4209
+ async function keyTry() {
4210
+ const mine = ky
4211
+ ky.step = 'trying'
4212
+ ky.error = null
4213
+ renderKey()
4214
+ try {
4215
+ ky.tried = await api('/api/try', { account: ky.ref })
4216
+ ky.step = 'tried'
4217
+ } catch (e) { ky.error = e; ky.step = 'confirm' }
4218
+ if (ky !== mine || dlg.kind !== 'key') return
4219
+ renderKey()
4220
+ }
4221
+
4222
+ // Preferred terminal and the custom command (spec §3.28): the installed list, "Guess for me" with where the guess
4223
+ // comes from, and a template for any other terminal.
4224
+ let tm = null
4225
+ function openTerminals() {
4226
+ closeMenu()
4227
+ tm = { data: null, busy: false, error: null, draft: null, customOpen: false }
4228
+ showDialog('terminal')
4229
+ renderTerminals()
4230
+ api('/api/terminals').then((d) => { if (tm) { tm.data = d; renderTerminals() } }, (e) => { if (tm) { tm.error = e; renderTerminals() } })
4231
+ }
4232
+ async function termSave(body) {
4233
+ tm.busy = true
4234
+ tm.error = null
4235
+ renderTerminals()
4236
+ try { tm.data = await api('/api/terminal', body); tm.draft = null; toast(t('term.saved')) } catch (e) { tm.error = e }
4237
+ tm.busy = false
4238
+ if (dlg.kind === 'terminal') renderTerminals()
4239
+ }
4240
+ // "Not sure how?": AI writes the custom command, starting from what is typed or saved and what is installed.
4241
+ function termAskAi(d) {
4242
+ const name = (id) => { const f = d.terminals.find((x) => x.id === id); return f ? f.name : id }
4243
+ const typed = tm.draft !== null ? tm.draft.trim() : ''
4244
+ const custom = typed || d.custom || ''
4245
+ const context = { terminals: d.installed.filter((id) => id !== 'custom').map(name), version: BOOT.version }
4246
+ if (custom) context.terminalCustom = custom
4247
+ openAsk({ task: 'terminal', context })
4248
+ }
4249
+ function renderTerminals() {
4250
+ if (!tm) return
4251
+ const body = [h('p', { class: 'muted small' }, t('term.intro'))]
4252
+ const d = tm.data
4253
+ if (!d && !tm.error) body.push(h('p', { class: 'muted small' }, spinner(), ' ', t('term.loading')))
4254
+ if (d) {
4255
+ const name = (id) => { const f = d.terminals.find((x) => x.id === id); return f ? f.name : id }
4256
+ if (!d.installed.length) body.push(h('p', { class: 'note' }, t('term.none')))
4257
+ else {
4258
+ const sel = h('select', { id: 'tm-pick', disabled: tm.busy, 'aria-label': t('term.pick') },
4259
+ h('option', { value: '' }, t('term.auto')),
4260
+ d.installed.map((id) => h('option', { value: id }, name(id))))
4261
+ sel.value = d.preferred && d.installed.includes(d.preferred) ? d.preferred : ''
4262
+ sel.addEventListener('change', () => termSave(sel.value ? { id: sel.value } : {}))
4263
+ body.push(h('label', { class: 'field', for: 'tm-pick' }, h('span', null, t('term.pick')), sel),
4264
+ h('p', { class: 'field-hint' }, t('term.guess.' + d.guess.source, { name: name(d.guess.id) })))
4265
+ }
4266
+ const input = h('input', { id: 'tm-custom', type: 'text', autocomplete: 'off', spellcheck: 'false', placeholder: 'myterm --cwd {cwd} -e {cmd}', disabled: tm.busy })
4267
+ // What was typed stays after a refusal, and so does the open section.
4268
+ input.value = tm.draft !== null ? tm.draft : d.custom || ''
4269
+ input.addEventListener('input', () => { tm.draft = input.value })
4270
+ const more = h('details', { class: 'more-opts', open: tm.customOpen || d.custom ? true : undefined }, h('summary', null, t('term.custom')),
4271
+ h('label', { class: 'field', for: 'tm-custom' }, h('span', null, t('term.custom')), input),
4272
+ h('p', { class: 'field-hint' }, t('term.customHint')),
4273
+ BOOT.readOnly ? null : h('p', { class: 'small' }, h('button', { class: 'link-btn', type: 'button', onclick: () => termAskAi(d) }, icon('bot'), t('term.askAi'))),
4274
+ h('div', { class: 'actions' },
4275
+ h('button', { class: 'btn btn-sm', type: 'button', disabled: tm.busy, onclick: () => termSave({ custom: input.value.trim() }) }, t('term.customSave')),
4276
+ d.custom ? h('button', { class: 'btn btn-sm', type: 'button', disabled: tm.busy, onclick: () => termSave({ custom: '' }) }, t('term.customClear')) : null))
4277
+ more.addEventListener('toggle', () => { tm.customOpen = more.open })
4278
+ body.push(more)
4279
+ }
4280
+ if (tm.error) body.push(errorNote(tm.error, null))
4281
+ setKids(dlg.el, dialogFrame(t('term.title'), null, body, [h('button', { class: 'btn', type: 'button', onclick: () => dlg.el.close() }, t('btn.close'))]))
4282
+ }
4283
+
3470
4284
  // ---------- banners ----------
3471
4285
  function renderBanners() {
3472
4286
  const out = []
@@ -3527,6 +4341,7 @@ const MAIN = String.raw `
3527
4341
  }
3528
4342
  // A read-only Panel never records checks, so keep the result of its own last check over the disk.
3529
4343
  if (!BOOT.readOnly || !S.history) S.history = state.history || null
4344
+ adoptPanel(state.panel)
3530
4345
  } else if (!error.network) S.loadError = error // a lost connection has its own notice
3531
4346
  }
3532
4347
  S.loading = false
@@ -3571,6 +4386,8 @@ const MAIN = String.raw `
3571
4386
  function updState() { return BOOT.updates && S.state ? S.state.update || null : null }
3572
4387
  // The skill agents read is part of the version (spec §3.23): behind a current CLI it is the one thing to update.
3573
4388
  const isLocal = (u) => u.method.kind === 'local'
4389
+ // A Local Build the Panel can leave for npm itself (ADR-0024): then that switch is the update, not a command.
4390
+ const leavesLocal = (u) => canLeaveLocal(u, BOOT.readOnly)
3574
4391
  const skillOld = (u) => !!(u && !u.available && !isLocal(u) && u.skillUpdate && u.skillUpdate.outdated)
3575
4392
  const skillDue = (u) => skillOld(u) && u.skillUpdate.canRun
3576
4393
  function skillFor(u) {
@@ -3690,7 +4507,8 @@ const MAIN = String.raw `
3690
4507
  if (u && u.available) {
3691
4508
  kids.push(h('p', { class: 'small' }, t('upd.available', { v: u.latest }), ' ',
3692
4509
  h('a', { href: u.releaseNotes, target: '_blank', rel: 'noopener noreferrer' }, t('upd.notes'))))
3693
- kids.push(h('div', { class: 'popover-actions' }, h('button', { class: 'btn btn-sm btn-primary', type: 'button', onclick: toDialog }, t('upd.open'))))
4510
+ if (!(isLocal(u) && leavesLocal(u)))
4511
+ kids.push(h('div', { class: 'popover-actions' }, h('button', { class: 'btn btn-sm btn-primary', type: 'button', onclick: toDialog }, t('upd.open'))))
3694
4512
  } else if (skillOld(u)) {
3695
4513
  kids.push(h('p', { class: 'small' }, t('upd.skillBehind', { s: skillFor(u) })))
3696
4514
  kids.push(h('div', { class: 'popover-actions' }, h('button', { class: 'btn btn-sm' + (skillDue(u) ? ' btn-primary' : ''), type: 'button', onclick: toDialog }, t('upd.open'))))
@@ -3722,30 +4540,42 @@ const MAIN = String.raw `
3722
4540
  kids.push(h('p', { class: 'small' }, t('loc.stuck')))
3723
4541
  kids.push(h('div', { class: 'popover-actions' }, h('button', { class: 'btn btn-sm btn-primary', type: 'button', onclick: () => location.reload() }, icon('refresh'), t('loc.reload'))))
3724
4542
  } else {
3725
- kids.push(h('div', { class: 'popover-actions' }, h('button', { class: 'btn btn-sm', type: 'button', disabled: vs.busy, 'aria-busy': vs.busy ? 'true' : null, onclick: () => localSwitchTo(toNpm ? 'npm' : 'local') },
3726
- vs.busy ? spinner() : icon('swap'), vs.busy ? t('loc.switching') : t(toNpm ? 'loc.toNpm' : 'loc.toLocal'))))
4543
+ const newer = toNpm && u.available && u.latest
4544
+ kids.push(h('div', { class: 'popover-actions' }, h('button', { class: 'btn btn-sm' + (newer ? ' btn-primary' : ''), type: 'button', disabled: vs.busy, 'aria-busy': vs.busy ? 'true' : null, onclick: () => localSwitchTo(toNpm ? 'npm' : 'local') },
4545
+ vs.busy ? spinner() : icon('swap'), vs.busy ? t('loc.switching') : newer ? t('loc.toNpmV', { v: u.latest }) : t(toNpm ? 'loc.toNpm' : 'loc.toLocal'))))
3727
4546
  }
3728
4547
  if (vs.error) kids.push(errorNote(vs.error, null))
3729
4548
  return h('div', { class: 'popover-foot' }, kids)
3730
4549
  }
4550
+ function renderSwitch() {
4551
+ renderVerPop()
4552
+ if (dlg.kind === 'update' && up) renderUpdate()
4553
+ }
3731
4554
  async function localSwitchTo(to) {
3732
4555
  vs.busy = true
3733
4556
  vs.to = to
3734
4557
  vs.error = null
3735
4558
  vs.stuck = false
3736
- renderVerPop()
4559
+ renderSwitch()
3737
4560
  try {
3738
4561
  await api('/api/local', { to, confirm: true })
3739
4562
  } catch (e) {
3740
- // A background Panel restarting from the new code may drop this answer: wait for the build all the same.
3741
- if (!e.network) { vs.busy = false; vs.error = e; renderVerPop(); return }
4563
+ // A background Panel restarting from the new code may drop this answer, and a Portal moving to it may answer
4564
+ // 5xx (spec §3.23): the state then says whether the switch happened. Only a switch that did not is an error.
4565
+ const kind = switchFailure(e)
4566
+ if (kind === 'fail') { vs.busy = false; vs.error = e; renderSwitch(); return }
4567
+ if (kind === 'check') {
4568
+ await load({ silent: true })
4569
+ const ls = updState() && updState().localSwitch
4570
+ if (!ls || ls.running !== to) { vs.busy = false; vs.error = e; renderSwitch(); return }
4571
+ }
3742
4572
  }
3743
4573
  vs.waitUntil = Date.now() + 20000
3744
4574
  localWait()
3745
4575
  }
3746
4576
  function localWait() {
3747
4577
  if (S.newBuild) { location.reload(); return }
3748
- if (Date.now() > vs.waitUntil) { vs.busy = false; vs.stuck = true; renderVerPop(); return }
4578
+ if (Date.now() > vs.waitUntil) { vs.busy = false; vs.stuck = true; renderSwitch(); return }
3749
4579
  setTimeout(() => { void load({ silent: true }).then(localWait, localWait) }, 1500)
3750
4580
  }
3751
4581
  async function verPopCheck() {
@@ -3834,6 +4664,14 @@ const MAIN = String.raw `
3834
4664
  else body.push(...updSkill(u))
3835
4665
  } else if (up.skillDone && !skillOld(u)) {
3836
4666
  body.push(h('p', { class: 'note' }, t('upd.skillDoneOnly')))
4667
+ } else if (u.available && isLocal(u) && (leavesLocal(u) || vs.busy || vs.stuck)) {
4668
+ // A Local Build the Panel can leave (ADR-0024): switching to the release is the update, and brings the skill.
4669
+ body.push(h('p', null, t('upd.localSwitch', { v: u.latest }), ' ',
4670
+ h('a', { href: u.releaseNotes, target: '_blank', rel: 'noopener noreferrer' }, t('upd.notes'))))
4671
+ if (vs.busy) body.push(h('p', { class: 'muted small' }, spinner(), ' ', t('upd.reloading')))
4672
+ if (vs.stuck) body.push(h('p', { class: 'note' }, t('loc.stuck')))
4673
+ if (vs.error) body.push(errorNote(vs.error, null))
4674
+ body.push(updManual([h('p', { class: 'small' }, t('upd.localManual')), codeCopy(u.command)]))
3837
4675
  } else if (u.available && isLocal(u)) {
3838
4676
  // A Local Build (ADR-0024): one way back to npm, which brings the skill along.
3839
4677
  body.push(h('p', null, t('upd.localAvail', { v: u.latest }), ' ',
@@ -3888,7 +4726,11 @@ const MAIN = String.raw `
3888
4726
  if (up.error) body.push(errorNote(up.error, null))
3889
4727
  const foot = [h('button', { class: 'btn', type: 'button', disabled: up.busy, onclick: () => dlg.el.close() }, t('btn.close'))]
3890
4728
  const primary = (label, run) => h('button', { class: 'btn btn-primary', type: 'button', disabled: up.busy, 'aria-busy': up.busy ? 'true' : null, onclick: run }, up.busy ? spinner() : null, up.busy ? t('upd.updating') : label)
3891
- if (u.available && u.canRun && !BOOT.readOnly && !up.done) foot.push(primary(t('upd.now', { v: u.latest }), updRun))
4729
+ if (u.available && isLocal(u) && vs.stuck) foot.push(h('button', { class: 'btn btn-primary', type: 'button', onclick: () => location.reload() }, icon('refresh'), t('loc.reload')))
4730
+ else if (u.available && isLocal(u) && (leavesLocal(u) || vs.busy))
4731
+ foot.push(h('button', { class: 'btn btn-primary', type: 'button', disabled: vs.busy, 'aria-busy': vs.busy ? 'true' : null, onclick: () => localSwitchTo('npm') },
4732
+ vs.busy ? spinner() : icon('swap'), vs.busy ? t('loc.switching') : t('loc.toNpmV', { v: u.latest })))
4733
+ else if (u.available && u.canRun && !BOOT.readOnly && !up.done) foot.push(primary(t('upd.now', { v: u.latest }), updRun))
3892
4734
  else if (skillDue(u) && !BOOT.readOnly && !up.done) foot.push(primary(t('upd.skillNow'), updSkillRun))
3893
4735
  setKids(dlg.el, dialogFrame(t('upd.title'), null, body, foot))
3894
4736
  }
@@ -3900,6 +4742,7 @@ const MAIN = String.raw `
3900
4742
  $('update').addEventListener('click', openUpdate)
3901
4743
  $('ver-top').addEventListener('click', () => openVerPop($('ver-top')))
3902
4744
  $('refresh').addEventListener('click', refreshAll)
4745
+ $('ask-ai').addEventListener('click', () => openAsk())
3903
4746
  $('accounts').addEventListener('dragover', onDragOver)
3904
4747
  $('accounts').addEventListener('drop', onDrop)
3905
4748
  // Escape leaves the arrange mode, as "Done" does, unless a menu or dialog takes it first.
@@ -3908,6 +4751,13 @@ const MAIN = String.raw `
3908
4751
  endArrange()
3909
4752
  })
3910
4753
  $('settings').addEventListener('click', () => openPopover($('settings'), 'settings', t('set.button'), settingsPop, 'end'))
4754
+ document.addEventListener('contextmenu', onContextMenu)
4755
+ document.addEventListener('keydown', onMenuKey)
4756
+ // The first load of this version writes this browser's earlier theme and language into config, once (spec §3.29).
4757
+ if (CFG && !prefs.cfgDone) {
4758
+ if (!cfgMigrate) { prefs.cfgDone = true; savePrefs() }
4759
+ else saveCfg(cfgMigrate, true).then((ok) => { if (ok) { prefs.cfgDone = true; savePrefs() } })
4760
+ }
3911
4761
  applyTheme()
3912
4762
  renderStatic()
3913
4763
  buildToolbar()