sideby 0.13.1 → 0.15.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 (85) hide show
  1. package/README.md +2 -1
  2. package/dist/build.json +1 -1
  3. package/dist/src/cli/commands.d.ts +4 -2
  4. package/dist/src/cli/commands.js +194 -11
  5. package/dist/src/cli/format.js +7 -3
  6. package/dist/src/cli/json-schemas.js +104 -3
  7. package/dist/src/cli/main.js +27 -7
  8. package/dist/src/core/config.d.ts +28 -0
  9. package/dist/src/core/config.js +54 -1
  10. package/dist/src/core/handoff-enable.js +10 -5
  11. package/dist/src/core/launch.d.ts +5 -0
  12. package/dist/src/core/launch.js +14 -3
  13. package/dist/src/core/quota-fetch.d.ts +22 -0
  14. package/dist/src/core/quota-fetch.js +98 -0
  15. package/dist/src/core/quota-levels.d.ts +3 -2
  16. package/dist/src/core/quota-levels.js +8 -4
  17. package/dist/src/core/quota-store.d.ts +19 -0
  18. package/dist/src/core/quota-store.js +62 -0
  19. package/dist/src/core/skill-check.d.ts +16 -0
  20. package/dist/src/core/skill-check.js +47 -3
  21. package/dist/src/core/update.d.ts +67 -1
  22. package/dist/src/core/update.js +147 -37
  23. package/dist/src/families/grok/handoff-hook.d.ts +6 -0
  24. package/dist/src/families/grok/handoff-hook.js +45 -2
  25. package/dist/src/families/grok/handoff.d.ts +6 -1
  26. package/dist/src/families/grok/handoff.js +30 -13
  27. package/dist/src/families/grok/index.js +5 -0
  28. package/dist/src/families/grok/quota-log.d.ts +30 -0
  29. package/dist/src/families/grok/quota-log.js +140 -0
  30. package/dist/src/families/grok/quota.d.ts +5 -0
  31. package/dist/src/families/grok/quota.js +112 -0
  32. package/dist/src/panel/handler.d.ts +13 -3
  33. package/dist/src/panel/handler.js +81 -8
  34. package/dist/src/panel/i18n.d.ts +138 -8
  35. package/dist/src/panel/i18n.js +301 -41
  36. package/dist/src/panel/page-logic.d.ts +59 -2
  37. package/dist/src/panel/page-logic.js +99 -6
  38. package/dist/src/panel/page-script.js +769 -175
  39. package/dist/src/panel/page-styles.js +83 -15
  40. package/dist/src/panel/page.js +3 -0
  41. package/dist/src/runtime.d.ts +32 -2
  42. package/dist/src/runtime.js +64 -21
  43. package/dist/src/types.d.ts +49 -1
  44. package/package.json +3 -2
  45. package/schemas/alias.json +1 -1
  46. package/schemas/app-install.json +1 -1
  47. package/schemas/app-uninstall.json +1 -1
  48. package/schemas/browser-open.json +1 -1
  49. package/schemas/browser-switch.json +1 -1
  50. package/schemas/browser.json +1 -1
  51. package/schemas/config.json +60 -0
  52. package/schemas/desktop-remove.json +1 -1
  53. package/schemas/desktop.json +1 -1
  54. package/schemas/doctor.json +1 -1
  55. package/schemas/error.json +1 -1
  56. package/schemas/families.json +1 -1
  57. package/schemas/handoff-disable.json +1 -1
  58. package/schemas/handoff-enable.json +1 -1
  59. package/schemas/handoff-ready.json +1 -1
  60. package/schemas/handoff-setup.json +1 -1
  61. package/schemas/handoff-status.json +282 -1
  62. package/schemas/handoff-teardown.json +1 -1
  63. package/schemas/list.json +6 -1
  64. package/schemas/model.json +1 -1
  65. package/schemas/new.json +1 -1
  66. package/schemas/next.json +1 -1
  67. package/schemas/order.json +148 -0
  68. package/schemas/pin.json +126 -0
  69. package/schemas/plugins.json +1 -1
  70. package/schemas/quota-check.json +88 -0
  71. package/schemas/quota-setup.json +1 -1
  72. package/schemas/quota-teardown.json +1 -1
  73. package/schemas/quota.json +15 -2
  74. package/schemas/remote-control.json +1 -1
  75. package/schemas/tune-change.json +1 -1
  76. package/schemas/tune.json +1 -1
  77. package/schemas/unpin.json +126 -0
  78. package/schemas/update-auto.json +84 -0
  79. package/schemas/update.json +79 -1
  80. package/skills/sideby/SKILL.md +7 -3
  81. package/skills/sideby/references/diagnose.md +2 -1
  82. package/skills/sideby/references/handoff.md +9 -1
  83. package/skills/sideby/references/panel.md +28 -0
  84. package/skills/sideby/references/quota.md +11 -1
  85. package/skills/sideby/references/update.md +6 -2
@@ -1,15 +1,17 @@
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, formatDuration, handoffChangeOf, handoffHintAccount, loginClusters, loginKey, maskEmail, matchesQuery, mergeDaily, newAccountDir, nextReset, panelHint, panelLang, passedWindow, quotaPressure, remoteControlSummary, remoteControlTodo, sortAccounts, suggestAlias, suggestName, updatedAge, windowState, } from './page-logic.js';
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';
4
4
  const LOGIC = [
5
5
  aliasArgsOf,
6
6
  aliasIssue,
7
7
  attentionReasons,
8
8
  attentionWhy,
9
9
  cacheHitRate,
10
+ fixPrompt,
10
11
  formatDuration,
11
12
  handoffChangeOf,
12
13
  handoffHintAccount,
14
+ inDisplayOrder,
13
15
  loginClusters,
14
16
  loginKey,
15
17
  maskEmail,
@@ -17,17 +19,21 @@ const LOGIC = [
17
19
  mergeDaily,
18
20
  newAccountDir,
19
21
  nextReset,
22
+ panelFixes,
20
23
  panelHint,
21
24
  panelLang,
22
25
  passedWindow,
23
26
  quotaPressure,
24
27
  remoteControlSummary,
25
28
  remoteControlTodo,
29
+ reorder,
26
30
  sortAccounts,
27
31
  suggestAlias,
28
32
  suggestName,
29
33
  updatedAge,
34
+ pinnedIn,
30
35
  windowState,
36
+ withPinned,
31
37
  ]
32
38
  .map((fn) => fn.toString())
33
39
  .join('\n');
@@ -57,14 +63,14 @@ const MAIN = String.raw `
57
63
  silent: false, lastTry: 0, pending: false, fastUntil: 0, fastFamily: null,
58
64
  }
59
65
  // View state that is not saved: the search text, the attention filter, the Health check filter.
60
- const ui = { query: '', attention: false, allFindings: false, healthRef: null, checkingRef: null, desktopRef: null, browserRef: null, appBusy: null, appRemove: null, appNew: null, highlight: null, fade: true, loginRefs: new Set() }
66
+ const ui = { query: '', attention: false, allFindings: false, healthRef: null, checkingRef: null, desktopRef: null, browserRef: null, appBusy: null, appRemove: null, appNew: null, highlight: null, fade: true, loginRefs: new Map(), order: { families: [], units: {} }, drag: null, arrange: null }
61
67
  const $ = (id) => document.getElementById(id)
62
68
 
63
69
  // ---------- preferences ----------
64
70
  // Kept in localStorage, which can be missing or throw (private windows, blocked storage): every access is guarded
65
71
  // and the defaults apply.
66
72
  const PREFS_KEY = 'sideby.panel.v2'
67
- const CHOICES = { view: ['list', 'cards'], sort: ['pressure', 'name', 'recent'], quota: ['used', 'left'], theme: ['system', 'light', 'dark'], lang: ['en', 'zh'] }
73
+ const CHOICES = { view: ['list', 'cards'], sort: ['pressure', 'name', 'recent', 'manual'], quota: ['used', 'left'], theme: ['system', 'light', 'dark'], lang: ['en', 'zh'] }
68
74
  const prefs = loadPrefs()
69
75
  function loadPrefs() {
70
76
  let raw = {}
@@ -235,6 +241,8 @@ const MAIN = String.raw `
235
241
  function uiBusy() {
236
242
  // A row or group in the middle of folding; a render now would cut the motion short.
237
243
  for (const el of folding) if (el.isConnected) return true
244
+ // A row or group being dragged into a new place (spec §3.25).
245
+ if (ui.drag) return true
238
246
  if (menu.el || (dlg.el && dlg.el.open) || document.querySelector('.tip.is-open')) return true
239
247
  // A plan on the Auto Handoff page, and the change a person opened to read, stay as they are.
240
248
  if (!$('handoff-page').hidden && (hp.preview || hp.askSame || document.querySelector('#handoff-page details[open]'))) return true
@@ -249,6 +257,7 @@ const MAIN = String.raw `
249
257
  search: 'M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM20 20l-4.2-4.2',
250
258
  chevron: 'M9 6l6 6-6 6',
251
259
  more: 'M5 12h.01M12 12h.01M19 12h.01',
260
+ grip: 'M9 6h.01M9 12h.01M9 18h.01M15 6h.01M15 12h.01M15 18h.01',
252
261
  plus: 'M12 5v14M5 12h14',
253
262
  sun: 'M12 16a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4',
254
263
  moon: 'M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5z',
@@ -263,6 +272,9 @@ const MAIN = String.raw `
263
272
  userPlus: 'M15 19a6 6 0 0 0-12 0M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM19 8v6M16 11h6',
264
273
  x: 'M6 6l12 12M18 6L6 18',
265
274
  arrowUp: 'M12 19V5M5 12l7-7 7 7',
275
+ arrowDown: 'M12 5v14M5 12l7 7 7-7',
276
+ // Lucide "pin" (ISC license)
277
+ 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',
266
278
  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',
267
279
  grid: 'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z',
268
280
  list: 'M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01',
@@ -491,7 +503,7 @@ const MAIN = String.raw `
491
503
  try { const u = new URL(url); ok = u.protocol === 'https:' || u.protocol === 'http:' } catch {}
492
504
  return ok ? h('a', { class: cls, href: url, target: '_blank', rel: 'noopener noreferrer' }, text) : null
493
505
  }
494
- async function copyText(text) {
506
+ async function copyText(text, shown) {
495
507
  let ok = true
496
508
  try { await navigator.clipboard.writeText(text) } catch {
497
509
  const ta = h('textarea', { css: { position: 'fixed', opacity: '0' } })
@@ -501,7 +513,7 @@ const MAIN = String.raw `
501
513
  try { ok = document.execCommand('copy') } catch { ok = false }
502
514
  ta.remove()
503
515
  }
504
- if (ok) toast(t('toast.copied', { text: prefs.hideEmail && text.includes('@') ? maskEmail(text) : text }))
516
+ if (ok) toast(t('toast.copied', { text: shown || (prefs.hideEmail && text.includes('@') ? maskEmail(text) : text) }))
505
517
  else toast(t('toast.copyFailed'), { kind: 'fail' })
506
518
  }
507
519
  function codeCopy(text) {
@@ -560,6 +572,7 @@ const MAIN = String.raw `
560
572
  function openMenu(anchor, items, label) {
561
573
  const el = h('div', { class: 'menu', role: 'menu', 'aria-label': label },
562
574
  items.filter(Boolean).map((it) => it === '-' ? h('div', { class: 'menu-sep', role: 'separator' })
575
+ : it.header ? h('div', { class: 'menu-head', role: 'presentation' }, it.header)
563
576
  : h('button', { class: 'menu-item', type: 'button', role: 'menuitem', tabindex: '-1', disabled: it.disabled, onclick: () => { closeMenu(true); it.run() } },
564
577
  icon(it.icon), h('span', { class: 'menu-label' }, it.label), it.hint ? h('code', { class: 'menu-hint', title: it.hint }, it.hint.length > 24 ? '…' + it.hint.slice(-23) : it.hint) : null)))
565
578
  el.addEventListener('keydown', menuKeys)
@@ -607,24 +620,81 @@ const MAIN = String.raw `
607
620
  window.addEventListener('resize', () => closeMenu())
608
621
  window.addEventListener('scroll', () => closeMenu(), { passive: true })
609
622
 
610
- function accountMenu(a, anchor) {
623
+ // What one directory does: start it, its short commands, its desktop app, its folder, model, tuning, Health.
624
+ function dirCopies(a) {
611
625
  const run = 'sideby run ' + a.ref
612
- const items = [
626
+ return [
613
627
  { icon: 'terminal', label: t('menu.copyRun'), hint: run, run: () => copyText(run) },
614
628
  ...(a.aliases || []).slice(0, 2).map((x) => ({ icon: 'terminal', label: t('menu.copyAlias'), hint: x, run: () => copyText(x) })),
615
- a.desktopApp && a.desktopApp.installed && a.kind !== 'api' && !BOOT.readOnly ? { icon: 'terminal', label: t(a.desktopApp.running ? 'desk.show' : 'desk.open', { app: a.desktopApp.app }), disabled: Boolean(ui.desktopRef), run: () => openDesktop(a) } : null,
616
- browserFor(a) ? { icon: 'terminal', label: t('br.open'), disabled: Boolean(ui.browserRef), run: () => openBrowserDialog(a) } : null,
617
- a.kind !== 'api' ? { icon: 'key', label: t('menu.copyLogin'), hint: 'sideby login ' + a.ref, run: () => copyText('sideby login ' + a.ref) } : null,
618
629
  a.dir ? { icon: 'folder', label: t('menu.copyDir'), hint: a.dir, run: () => copyText(a.dir) } : null,
619
- a.identity && a.identity.email ? { icon: 'mail', label: t('menu.copyEmail'), hint: emailText(a.identity.email), run: () => copyText(a.identity.email) } : null,
620
- '-',
630
+ ]
631
+ }
632
+ function deskItem(a) {
633
+ return a.desktopApp && a.desktopApp.installed && a.kind !== 'api' && !BOOT.readOnly ? { icon: 'monitor', label: t(a.desktopApp.running ? 'desk.show' : 'desk.open', { app: a.desktopApp.app }), disabled: Boolean(ui.desktopRef), run: () => openDesktop(a) } : null
634
+ }
635
+ function dirItems(a) { return [...dirCopies(a), deskItem(a)] }
636
+ function dirTools(a) {
637
+ return [
621
638
  a.modelInfo && a.modelInfo.settable ? { icon: 'terminal', label: t('menu.model'), run: () => openModel(a) } : null,
622
639
  family(a.family).tuning ? { icon: 'check', label: t('menu.tune'), run: () => openTune(a) } : null,
623
640
  { icon: 'check', label: t('menu.check'), disabled: S.checking || S.fixing || Boolean(ui.checkingRef), run: () => checkAccount(a.ref) },
624
- BOOT.readOnly ? null : { icon: 'userPlus', label: t('menu.like'), run: () => openCreate({ family: a.family, api: a.kind === 'api' }) },
625
641
  ]
642
+ }
643
+ // What the login does, whichever directory: the browser, signing in, the email, a new account like it.
644
+ function loginItems(a) {
645
+ return [
646
+ browserFor(a) ? { icon: 'globe', label: t('br.open'), disabled: Boolean(ui.browserRef), run: () => openBrowserDialog(a) } : null,
647
+ a.kind !== 'api' ? { icon: 'key', label: t('menu.copyLogin'), hint: 'sideby login ' + a.ref, run: () => copyText('sideby login ' + a.ref) } : null,
648
+ a.identity && a.identity.email ? { icon: 'mail', label: t('menu.copyEmail'), hint: emailText(a.identity.email), run: () => copyText(a.identity.email) } : null,
649
+ ]
650
+ }
651
+ function likeItem(a) { return BOOT.readOnly ? null : { icon: 'userPlus', label: t('menu.like'), run: () => openCreate({ family: a.family, api: a.kind === 'api' }) } }
652
+ // Grouped by what the user wants to do: open, copy, manage.
653
+ function accountMenu(a, anchor) {
654
+ const [browser, login, email] = loginItems(a)
655
+ const opens = [deskItem(a), browser].filter(Boolean)
656
+ const copies = dirCopies(a)
657
+ const items = [pinItem(a),
658
+ opens.length ? '-' : null, opens.length ? { header: t('menu.groupOpen') } : null, ...opens,
659
+ '-', { header: t('menu.groupCopy') }, ...copies, login, email,
660
+ '-', { header: t('menu.groupManage') }, ...dirTools(a), likeItem(a)]
626
661
  openMenu(anchor, items, t('menu.label', { ref: a.ref }))
627
662
  }
663
+ // A Same Login row (ADR-0026): pin first, then a section per directory, then what the login shares.
664
+ function loginMenu(ms, anchor) {
665
+ const items = [pinItem(ms[0])]
666
+ for (const m of ms) items.push('-', { header: m.name + ((m.aliases || []).length ? ' · ' + m.aliases[0] : '') }, ...dirItems(m), ...dirTools(m))
667
+ items.push('-', ...loginItems(ms[0]), likeItem(ms[0]))
668
+ openMenu(anchor, items, t('login.label', { names: ms.map((m) => m.name).join(', ') }))
669
+ }
670
+ function loginMoreButton(ms) {
671
+ const b = h('button', { class: 'icon-btn more', type: 'button', 'aria-haspopup': 'menu', 'aria-expanded': 'false', 'aria-label': t('login.label', { names: ms.map((m) => m.name).join(', ') }), title: t('menu.title') }, icon('more'))
672
+ b.addEventListener('click', () => { if (menu.anchor === b) closeMenu(); else loginMenu(ms, b) })
673
+ return b
674
+ }
675
+ // The most used action, on the row itself: copy the command that starts this account (its short command when it
676
+ // has one, which is shorter to type).
677
+ function runButton(a) {
678
+ const cmd = (a.aliases || [])[0] || 'sideby run ' + a.ref
679
+ const text = t('row.copyRun', { cmd })
680
+ return h('button', { class: 'icon-btn row-run', type: 'button', 'aria-label': text, title: text, onclick: (e) => { e.stopPropagation(); copyText(cmd) } }, icon('terminal'))
681
+ }
682
+ // The pin at the end of a row (spec §3.25): one click pins or unpins. A pinned row always shows it, filled; the
683
+ // others show it on hover or focus. A read-only Panel shows the mark of a pinned row and no button.
684
+ function pinButton(a, label) {
685
+ const on = isPinned(a)
686
+ 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
687
+ const full = !on && pinnedUnits(a.family) >= PIN_MAX
688
+ const text = full ? t('pin.full', { n: PIN_MAX }) : t(on ? 'pin.unpinName' : 'pin.pinName', { name: label, title: family(a.family).title })
689
+ 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,
690
+ onclick: (e) => { e.stopPropagation(); if (full) toast(t('pin.full', { n: PIN_MAX })); else togglePin(a) } }, icon('pin'))
691
+ }
692
+ function pinItem(a) {
693
+ if (BOOT.readOnly) return null
694
+ const on = isPinned(a)
695
+ const full = !on && pinnedUnits(a.family) >= PIN_MAX
696
+ return { icon: 'pin', label: full ? t('pin.full', { n: PIN_MAX }) : t(on ? 'pin.unpin' : 'pin.pin'), disabled: full, run: () => togglePin(a) }
697
+ }
628
698
  function moreButton(a) {
629
699
  const b = h('button', { class: 'icon-btn more', type: 'button', 'aria-haspopup': 'menu', 'aria-expanded': 'false', 'aria-label': t('menu.label', { ref: a.ref }), title: t('menu.title') }, icon('more'))
630
700
  b.addEventListener('click', () => { if (menu.anchor === b) closeMenu(); else accountMenu(a, b) })
@@ -658,7 +728,7 @@ const MAIN = String.raw `
658
728
  function whyOf(a, now) {
659
729
  const m = attentionWhy(a, healthOf(a.ref), now, WARN)
660
730
  if (m && m.key === 'why.not-installed' && family(a.family).brokenBin) return t('why.broken')
661
- return m ? t(m.key, m.vars) : ''
731
+ return m ? t(m.key, m.vars && m.vars.label ? Object.assign({}, m.vars, { label: winTitle(m.vars.label) }) : m.vars) : ''
662
732
  }
663
733
  function setupFor(fam) { return S.state && S.state.quotaSetups.find((s) => s.family === fam) }
664
734
 
@@ -735,7 +805,10 @@ const MAIN = String.raw `
735
805
  for (const r of rs) counts[r] = (counts[r] || 0) + 1
736
806
  }
737
807
  const order = ['error', 'health-fail', 'signed-out', 'not-installed', 'quota-high', 'health-warn']
738
- const needSub = need ? order.filter((r) => counts[r]).slice(0, 2).map((r) => tn('need.' + r, counts[r])).join(' · ') : t('stat.allClear')
808
+ // Who needs a look, by name: counting reasons beside a count of accounts made numbers that did not add up.
809
+ const needing = list.filter((a) => reasonsOf(a, now).length)
810
+ const needSub = need ? t(needing.length > 2 ? 'stat.needWhoMore' : 'stat.needWho', { names: needing.slice(0, 2).map((a) => a.ref).join(' · '), n: needing.length - 2 }) : t('stat.allClear')
811
+ const needTitle = need ? order.filter((r) => counts[r]).map((r) => tn('need.' + r, counts[r])).join(' · ') : null
739
812
  const usages = list.filter((a) => a.usage && a.usage.status === 'ok')
740
813
  const total = usages.reduce((s, a) => s + (Number(a.usage.totalTokens) || 0), 0)
741
814
  const sessions = usages.reduce((s, a) => s + (Number(a.usage.sessions) || 0), 0)
@@ -744,12 +817,12 @@ const MAIN = String.raw `
744
817
  patch(box, [
745
818
  stat(t('stat.accounts'), String(list.length), dots([tn('stat.families', famCount), apiCount ? tn('stat.api', apiCount) : null])),
746
819
  need
747
- ? stat(t('stat.attention'), String(need), needSub, null, { cls: 'stat-warn', pressed: ui.attention, title: t(ui.attention ? 'stat.attentionOff' : 'stat.attentionOn'), onclick: () => { ui.attention = !ui.attention; renderSummary(); renderAccounts() } })
820
+ ? stat(t('stat.attention'), String(need), needSub, h('span', { class: 'stat-act' }, t(ui.attention ? 'stat.attentionOff' : 'stat.attentionOn')), { cls: 'stat-warn', pressed: ui.attention, title: [needTitle, t(ui.attention ? 'stat.attentionOff' : 'stat.attentionOn')].join('\n'), onclick: () => { ui.attention = !ui.attention; renderSummary(); renderAccounts() } })
748
821
  : stat(t('stat.attention'), '0', needSub, null, { cls: 'stat-ok' }),
749
822
  stat(t('stat.tokens', { n: BOOT.usageDays }), tokens(total), tn('stat.sessions', sessions), spark(daily, 'spark-lg')),
750
823
  stat(h('span', { class: 'label-tip' }, t('stat.nextReset'), tip('age')),
751
824
  nr ? t('time.in', { d: formatDuration(nr.at - now, lang) }) : '—',
752
- nr ? dots([h('span', { class: 'mono' }, nr.ref), nr.label, clock(new Date(nr.at).toISOString())]) : t('stat.noQuota')),
825
+ nr ? dots([h('span', { class: 'mono' }, nr.ref), winTitle(nr.label), clock(new Date(nr.at).toISOString())]) : t('stat.noQuota')),
753
826
  ])
754
827
  }
755
828
 
@@ -775,10 +848,20 @@ const MAIN = String.raw `
775
848
  })
776
849
  tb.family = h('select', { class: 'tb-select', 'aria-label': t('tb.family') })
777
850
  tb.family.addEventListener('change', () => { prefs.family = tb.family.value; savePrefs(); renderAccounts() })
851
+ // "Arrange order…" is not a sort: it opens the arrange mode (spec §3.25), and the sort shows "my order".
778
852
  tb.sort = h('select', { class: 'tb-select', 'aria-label': t('tb.sort') },
779
- CHOICES.sort.map((v) => h('option', { value: v }, t('sort.' + v))))
853
+ CHOICES.sort.map((v) => h('option', { value: v }, t('sort.' + v))),
854
+ BOOT.readOnly ? null : h('option', { value: 'arrange' }, t('sort.arrange')))
780
855
  tb.sort.value = prefs.sort
781
- tb.sort.addEventListener('change', () => { prefs.sort = tb.sort.value; savePrefs(); renderAccounts() })
856
+ tb.sort.addEventListener('change', () => {
857
+ const v = tb.sort.value
858
+ // "My order" before any order of one's own would show nothing new: it opens the arrange mode instead.
859
+ if (v === 'arrange' || (v === 'manual' && !ordered() && !BOOT.readOnly)) { startArrange(); return }
860
+ if (ui.arrange) endArrange()
861
+ prefs.sort = v
862
+ savePrefs()
863
+ renderAccounts()
864
+ })
782
865
  const display = h('button', { class: 'btn tb-display', type: 'button', 'aria-haspopup': 'dialog', 'aria-expanded': 'false', title: t('tb.display') }, icon('sliders'), h('span', null, t('tb.display')))
783
866
  display.addEventListener('click', () => openPopover(display, 'display', t('tb.display'), displayPanel, 'end'))
784
867
  const create = BOOT.readOnly ? null : h('button', { class: 'btn btn-primary', type: 'button', 'aria-label': t('tb.new'), onclick: () => openCreate(null) }, icon('plus'), h('span', null, t('tb.new')))
@@ -799,7 +882,7 @@ const MAIN = String.raw `
799
882
  h('div', { class: 'dp-row' }, h('span', { class: 'dp-label' }, t('tb.quota')),
800
883
  seg(t('tb.quota'), [['used', t('tb.used')], ['left', t('tb.left')]], prefs.quota, (v) => { prefs.quota = v; redo() })),
801
884
  h('div', { class: 'dp-row' }, h('span', { class: 'dp-label' }, t('tb.view')),
802
- seg(t('tb.view'), [['list', icon('list'), t('tb.list')], ['cards', icon('grid'), t('tb.cards')]], prefs.view, (v) => { prefs.view = v; ui.fade = true; redo() })),
885
+ seg(t('tb.view'), [['list', [icon('list'), h('span', null, t('tb.list'))], t('tb.list')], ['cards', [icon('grid'), h('span', null, t('tb.cards'))], t('tb.cards')]], prefs.view, (v) => { prefs.view = v; ui.fade = true; redo() })),
803
886
  emails ? h('label', { class: 'dp-row dp-check' },
804
887
  h('input', { type: 'checkbox', checked: prefs.hideEmail, onchange: (e) => { prefs.hideEmail = e.target.checked; redo() } }),
805
888
  h('span', null, t('tb.hideEmails'))) : null)
@@ -829,12 +912,18 @@ const MAIN = String.raw `
829
912
  // One column per Quota window label. 5h and 7d always have one, so rows of every group line up under the same
830
913
  // header; a label only some account reports (such as 60m) adds a column, at most three in all.
831
914
  const KNOWN_WINDOWS = { '5h': 300, '7d': 10080 }
915
+ // A window's column: the models it covers when it covers only some (Cursor's "Other models"), else its length.
916
+ function winKey(w) { return w.name || w.label }
917
+ // The words for a window in labels (5-hour, weekly) instead of 5h and 7d; other labels as they are.
918
+ function winTitle(k) { return k === '5h' || k === '7d' ? t('win.' + k) : k }
919
+ // One group's columns: the windows its accounts report, shortest first; 5h and 7d when none reports any yet.
832
920
  function quotaColumns(list) {
833
- const mins = Object.assign({}, KNOWN_WINDOWS)
921
+ const mins = {}
834
922
  for (const a of list)
835
923
  if (a.quota && a.quota.status === 'ok')
836
- for (const w of a.quota.windows || []) if (!Object.hasOwn(mins, w.label)) mins[w.label] = Number(w.windowMinutes) || 0
837
- return Object.keys(mins).sort((x, y) => mins[x] - mins[y]).slice(0, 3)
924
+ for (const w of a.quota.windows || []) if (!Object.hasOwn(mins, winKey(w))) mins[winKey(w)] = (Number(w.windowMinutes) || 0) + (w.name ? -0.5 : 0)
925
+ if (!Object.keys(mins).length) Object.assign(mins, KNOWN_WINDOWS)
926
+ return Object.keys(mins).sort((x, y) => mins[x] - mins[y]).slice(0, 4)
838
927
  }
839
928
  // A window's cell: percent and countdown on one line, the bar under them. The label shows where no column
840
929
  // header does (cards, narrow screens).
@@ -843,13 +932,16 @@ const MAIN = String.raw `
843
932
  const passed = passedWindow(w, observedAt, now)
844
933
  const left = prefs.quota === 'left'
845
934
  const shown = left ? 100 - st.pct : st.pct
846
- const title = passed ? t('quota.resetNoData', { date: day(passed.since) }) : t('quota.resetsAt', { at: clock(w.resetsAt) })
935
+ const asOf = stale ? t('quota.asOf', { ago: ago(observedAt) }) : ''
936
+ const reset = passed ? t('quota.resetShort') : t('quota.resetsIn', { d: formatDuration(Date.parse(w.resetsAt) - now, lang) })
937
+ const title = [passed ? t('quota.resetNoData', { date: day(passed.since) }) : t('quota.resetsAt', { at: clock(w.resetsAt) }), asOf].filter(Boolean).join(' · ')
847
938
  return h('div', { class: 'meter lvl-' + st.level + (stale || passed ? ' lvl-stale' : ''), title },
848
939
  h('div', { class: 'm-top' },
849
- h('span', { class: 'm-label' }, w.label),
850
- h('span', { class: 'm-pct' }, shown + '%', h('span', { class: 'm-unit' }, ' ' + t(left ? 'quota.unitLeft' : 'quota.unitUsed'))),
851
- h('span', { class: 'm-reset' + (passed ? ' m-passed' : '') }, passed ? t('quota.resetShort') : countdown(w.resetsAt, now))),
852
- h('div', { class: 'bar', role: 'meter', 'aria-label': t(left ? 'quota.meterLeft' : 'quota.meterUsed', { label: w.label }), 'aria-valuemin': '0', 'aria-valuemax': '100', 'aria-valuenow': String(shown), 'aria-valuetext': t(left ? 'quota.pctLeft' : 'quota.pctUsed', { n: shown }) }, h('span', { css: { width: shown + '%' } })))
940
+ h('span', { class: 'm-label' }, winTitle(winKey(w))),
941
+ h('span', { class: 'm-pct' }, shown + '%', h('span', { class: 'm-unit' }, ' ' + t(left ? 'quota.unitLeft' : 'quota.unitUsed')),
942
+ null),
943
+ h('span', { class: 'm-reset' + (passed ? ' m-passed' : '') }, Number.isFinite(Date.parse(w.resetsAt)) || passed ? reset : '?')),
944
+ h('div', { class: 'bar', role: 'meter', 'aria-label': t(left ? 'quota.meterLeft' : 'quota.meterUsed', { label: winTitle(winKey(w)) }), 'aria-valuemin': '0', 'aria-valuemax': '100', 'aria-valuenow': String(shown), 'aria-valuetext': t(left ? 'quota.pctLeft' : 'quota.pctUsed', { n: shown }) }, h('span', { css: { width: shown + '%' } })))
853
945
  }
854
946
  // A column whose window this account does not report right now, such as a 5h window that has not started.
855
947
  function noMeter(label) {
@@ -859,6 +951,9 @@ const MAIN = String.raw `
859
951
  function quotaNote(a, q) {
860
952
  const setup = setupFor(a.family)
861
953
  // One setup turns quota on for every account of the Family, so the switch lives in the group header only.
954
+ // A check that asks the Host (Grok) and is turned off, or failed (spec §3.26): say why and what to do.
955
+ 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 || ''))
956
+ 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')))
862
957
  if (q.reason === 'not-enabled') {
863
958
  const host = famTitle(a)
864
959
  const body = setup && setup.plan.status === 'ready' && !BOOT.readOnly ? t('tip.quotaOff.body', { host })
@@ -870,22 +965,25 @@ const MAIN = String.raw `
870
965
  const text = { 'no-session': 'quota.noSession', 'no-source': 'quota.noSource', unrecognized: 'quota.unrecognized' }[q.reason]
871
966
  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 }))
872
967
  }
873
- // readFrom: the Account a login group's head shows the quota of, named on hover instead of "same login as".
874
- function quotaBlock(a, now, cols, readFrom) {
968
+ function quotaBlock(a, now, cols) {
875
969
  const q = a.quota
876
970
  if (!q) return h('div', { class: 'quota' }, h('div', { class: 'q-note' }, '—'))
877
971
  if (q.status !== 'ok') return h('div', { class: 'quota' }, quotaNote(a, q))
878
972
  const stale = isStale(q.observedAt, now)
879
973
  const wins = q.windows || []
880
974
  // A reading borrowed from another Account of the same login says where it came from (spec §3.21).
881
- const from = readFrom ? t('quota.readFrom', { name: refName(readFrom), ago: ago(q.observedAt) })
882
- : q.from ? t('quota.sharedFrom', { name: refName(q.from), ago: ago(q.observedAt) }) : null
883
- return h('div', { class: 'quota' + (stale ? ' is-stale' : ''), title: from || (stale ? null : t('quota.dataFrom', { ago: ago(q.observedAt) })) },
975
+ const from = q.from ? t('quota.sharedFrom', { name: refName(q.from), ago: ago(q.observedAt) }) : null
976
+ // Where the numbers came from when the Host was asked (spec §3.26), and why the latest ask failed.
977
+ const via = q.fetched ? t('quota.fetchedFrom', { host: famTitle(a), ago: ago(q.observedAt) }) : null
978
+ const failed = q.fetchError ? t('quota.lastCheckFailed', { why: q.fetchError }) : null
979
+ const title = [from || via || (stale ? null : t('quota.dataFrom', { ago: ago(q.observedAt) })), failed].filter(Boolean).join('\n') || null
980
+ return h('div', { class: 'quota' + (stale ? ' is-stale' : ''), title },
884
981
  wins.length ? cols.map((c) => {
885
- const w = wins.find((x) => x.label === c)
886
- return w ? meter(w, now, stale, q.observedAt) : noMeter(c)
982
+ const w = wins.find((x) => winKey(x) === c)
983
+ return w ? meter(w, now, stale, q.observedAt) : noMeter(winTitle(c))
887
984
  }) : h('div', { class: 'q-note' }, t('quota.noWindows')),
888
- stale ? h('div', { class: 'q-asof' }, icon('clock'), t('quota.asOf', { ago: ago(q.observedAt) })) : null)
985
+ // Old numbers keep their colours and say their age once, under the bars.
986
+ stale && wins.length ? h('div', { class: 'q-asof', title: t('quota.asOf', { ago: ago(q.observedAt) }) }, icon('clock'), t('quota.staleShort', { ago: agoShort(now - Date.parse(q.observedAt)) })) : null)
889
987
  }
890
988
  function usageBlock(a) {
891
989
  const u = a.usage
@@ -921,16 +1019,23 @@ const MAIN = String.raw `
921
1019
  }
922
1020
  // A dot after the name when the account needs a look that no tag shows: a fuller quota window or a Health
923
1021
  // finding. Its reason is on hover; the details say more.
924
- // Inside a login group the quota warning is the head's, so a row's dot is only about the row itself.
1022
+ // Inside a login group the quota warning is the first row's, so another row's dot is only about the row itself.
925
1023
  function attentionDot(a) {
926
- const subject = inLogin(a) ? Object.assign({}, a, { quota: undefined }) : a
1024
+ const lead = loginLead(a)
1025
+ const subject = !lead ? a
1026
+ : lead !== a.ref ? Object.assign({}, a, { quota: undefined })
1027
+ : Object.assign({}, a, { quota: a.quota && Object.assign({}, a.quota, { from: undefined }) })
927
1028
  const r = reasonsOf(subject, Date.now())
928
1029
  const lvl = r.includes('health-fail') ? 'fail' : r.includes('quota-high') || r.includes('health-warn') ? 'warn' : ''
929
1030
  if (!lvl) return null
930
1031
  const why = whyOf(subject, Date.now())
931
- return h('span', { class: 'dot dot-' + lvl + ' att-dot', title: why, role: 'img', 'aria-label': why })
1032
+ // A word, not only a colour (spec §3.8): what kind of look the account needs; the reason is on hover.
1033
+ const word = r.includes('health-fail') ? t('att.fix') : r.includes('quota-high') ? t('att.quota') : t('att.check')
1034
+ return h('span', { class: 'tag tag-' + lvl + ' att-tag', title: why, 'aria-label': why }, h('span', { class: 'dot dot-' + lvl, 'aria-hidden': 'true' }), word)
932
1035
  }
933
- function nameLine(a) {
1036
+ // members: the directories of a Same Login shown as this one row (spec §3.21).
1037
+ function nameLine(a, members) {
1038
+ const next = members ? members.some(isNext) : isNext(a)
934
1039
  return h('div', { class: 'ident-name' },
935
1040
  h('span', { class: 'name', title: a.name }, a.name),
936
1041
  attentionDot(a),
@@ -938,15 +1043,18 @@ const MAIN = String.raw `
938
1043
  const args = aliasArgsOf(a, x)
939
1044
  return h('span', { class: 'tag tag-alias', title: args ? t('acct.aliasArgs', { args: args.join(' ') }) : t('acct.alias') }, x)
940
1045
  }),
1046
+ members ? h('span', { class: 'tag tag-login', title: t('login.explain') }, t('login.dirs', { n: members.length })) : null,
941
1047
  a.kind === 'api' ? h('span', { class: 'tag tag-warn', title: t('tip.api.body') }, t('acct.api')) : null,
942
- isNext(a)
943
- ? h('button', { class: 'tag tag-ok tag-btn', type: 'button', title: t('acct.nextHint', { family: a.family }), 'aria-label': t('acct.nextLabel', { ref: a.ref, family: a.family }), onclick: () => copyText('sideby next ' + a.family) }, t('acct.next'))
1048
+ next
1049
+ ? nextTag(a)
944
1050
  : null,
945
1051
  a.login === 'logged-out' && a.kind !== 'api'
946
1052
  ? 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'))
947
1053
  : null,
948
1054
  a.hostInstalled === false ? h('span', { class: 'tag tag-warn' }, t(family(a.family).brokenBin ? 'acct.broken' : 'acct.notInstalled')) : null)
949
1055
  }
1056
+ // An organization name as the vendor wrote it, without a trailing comma or full stop it sometimes carries.
1057
+ function orgText(org) { return org ? String(org).replace(/[\s,,;;、。]+$/u, '') : '' }
950
1058
  // The name part of a ref (claude:001 gives 001), as rows show other Accounts of the same Family.
951
1059
  function refName(ref) { return String(ref).slice(String(ref).indexOf(':') + 1) }
952
1060
  // Who the account is signed in as, under its name; masked while "Hide emails" is on.
@@ -954,7 +1062,7 @@ const MAIN = String.raw `
954
1062
  const id = a.identity
955
1063
  if (!id || (!id.email && !browserButton(a))) return null
956
1064
  const email = id.email ? emailText(id.email) : ''
957
- return h('div', { class: 'ident-email', title: [email, id.org].filter(Boolean).join(' · ') },
1065
+ return h('div', { class: 'ident-email', title: [email, orgText(id.org)].filter(Boolean).join(' · ') },
958
1066
  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)]))
959
1067
  }
960
1068
  function subLine(a) {
@@ -1101,10 +1209,11 @@ const MAIN = String.raw `
1101
1209
  }
1102
1210
  // What a row or card shows depends on more than the account (health, view preferences, language); the
1103
1211
  // fingerprint covers what its click handlers use.
1104
- function rowSig(a) { return sig([a, healthOf(a.ref), isNext(a), ui.desktopRef === a.ref, ui.browserRef === a.ref, browserFor(a), prefs.hideEmail, isOpen(a), inLogin(a)]) }
1105
- // Whether the row sits in a login group (spec §3.21): the group's head then shows the email and the quota.
1212
+ function rowSig(a) { return sig([a, healthOf(a.ref), isNext(a), ui.desktopRef === a.ref, ui.browserRef === a.ref, browserFor(a), prefs.hideEmail, isOpen(a), loginLead(a), isPinned(a)]) }
1213
+ // Whether the row sits in a login group (spec §3.21): its first row then shows the quota for all of them.
1106
1214
  function inLogin(a) { return ui.loginRefs.has(a.ref) }
1107
- function rowEmail(a) { return inLogin(a) ? null : emailLine(a) }
1215
+ // The group's first row, which shows the quota for all of them.
1216
+ function loginLead(a) { return ui.loginRefs.get(a.ref) || null }
1108
1217
  // A row shows who the account is, its quota and its last use; a click opens the details under it (model, tokens,
1109
1218
  // Health) and closes them again. The choice is kept in this browser's preferences.
1110
1219
  function isOpen(a) { return prefs.open[a.ref] === true }
@@ -1173,11 +1282,13 @@ const MAIN = String.raw `
1173
1282
  const btn = el.querySelector('.row-toggle')
1174
1283
  if (btn) syncAttrs(btn, rowToggle(a))
1175
1284
  el.classList.toggle('row-open', open)
1176
- el.dataset.sig = rowSig(a)
1285
+ // A Same Login row (ADR-0026) opens onto its directories.
1286
+ const ms = el.classList.contains('row-login') ? loginMembers(S.state.accounts.filter((x) => ui.loginRefs.get(x.ref) === a.ref)) : null
1287
+ el.dataset.sig = ms ? sig(ms.map(rowSig)) : rowSig(a)
1177
1288
  let detail = rowDetails(el)[0]
1178
1289
  if (open && !detail) {
1179
- detail = rowDetail(a, Boolean(rowEmail(a)))
1180
- const more = el.querySelector(':scope > .more')
1290
+ detail = ms ? loginDetail(ms) : rowDetail(a, Boolean(emailLine(a)))
1291
+ const more = el.querySelector(':scope > .row-end')
1181
1292
  el.insertBefore(detail, more ? more.nextSibling : null)
1182
1293
  detail.style.opacity = '0'
1183
1294
  }
@@ -1215,72 +1326,88 @@ const MAIN = String.raw `
1215
1326
  ease(now, from, [], parts, 1, () => {})
1216
1327
  for (const p of parts) p.style.opacity = ''
1217
1328
  }
1218
- // A Same Login group folds its directory rows like a family group, and remembers it the same way.
1219
- function toggleLogin(key) {
1220
- const find = () => document.querySelector('#accounts .login[data-key="' + CSS.escape(key) + '"]')
1221
- const el = find()
1222
- const open = prefs.collapsed[key] === true
1223
- if (open) delete prefs.collapsed[key]; else prefs.collapsed[key] = true
1224
- savePrefs()
1225
- if (!el || !moving()) { renderAccounts(); return }
1226
- const from = el.getBoundingClientRect().height
1227
- const rowsOf = (x) => Array.from(x.querySelectorAll(':scope > .login-rows'))
1228
- if (!open) {
1229
- const btn = el.querySelector('.login-head .row-toggle')
1230
- if (btn) btn.setAttribute('aria-expanded', 'false')
1231
- const parts = rowsOf(el)
1232
- ease(el, from, parts, parts, 0, renderAccounts)
1233
- return
1234
- }
1235
- renderAccounts()
1236
- const now = find()
1237
- if (!now) return
1238
- const parts = rowsOf(now)
1239
- for (const p of parts) p.style.opacity = '0'
1240
- ease(now, from, [], parts, 1, () => {})
1241
- for (const p of parts) p.style.opacity = ''
1242
- }
1243
1329
  // The Account 'sideby next <family>' would pick; the server ranks it with the CLI's rule (spec §3.13).
1330
+ // "Suggested": a click says what it means and offers the command, instead of copying without a word.
1331
+ function nextTag(a) {
1332
+ const b = h('button', { class: 'tag tag-ok tag-btn', type: 'button', 'aria-haspopup': 'dialog', 'aria-expanded': 'false', title: t('acct.nextWhy', { title: famTitle(a) }) }, t('acct.next'))
1333
+ b.addEventListener('click', (e) => {
1334
+ e.stopPropagation()
1335
+ openPopover(b, 'next', t('acct.next'), () => [
1336
+ h('p', { class: 'next-why' }, t('acct.nextWhy', { title: famTitle(a) })),
1337
+ h('button', { class: 'btn btn-sm', type: 'button', onclick: () => { closeMenu(true); copyText('sideby next ' + a.family) } }, icon('copy'), t('acct.nextCopy')),
1338
+ h('code', { class: 'next-cmd' }, 'sideby next ' + a.family),
1339
+ ])
1340
+ })
1341
+ return b
1342
+ }
1244
1343
  function isNext(a) { return Boolean(S.state && S.state.handoff && S.state.handoff[a.family] === a.ref) }
1245
1344
  function row(a, now, cols) {
1345
+ if (ui.arrange) return arrangeRow(a, [a])
1246
1346
  const open = isOpen(a)
1247
- const email = rowEmail(a)
1347
+ const email = emailLine(a)
1248
1348
  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) },
1249
- h('div', { class: 'ident' }, rowToggle(a), badge(a.family, 'sm'), h('div', { class: 'ident-text' }, nameLine(a), email || subLine(a),
1250
- inLogin(a) ? h('span', { class: 'sr-only' }, t('login.sharedAbove')) : null)),
1251
- inLogin(a) ? null : quotaBlock(a, now, cols),
1349
+ h('div', { class: 'ident' }, rowToggle(a), badge(a.family, 'sm'), h('div', { class: 'ident-text' }, nameLine(a), email || subLine(a))),
1350
+ quotaBlock(a, now, cols),
1252
1351
  lastUsed(a),
1253
- moreButton(a),
1352
+ h('div', { class: 'row-end' }, runButton(a), pinButton(a, a.name), moreButton(a)),
1254
1353
  open ? rowDetail(a, Boolean(email)) : null,
1255
1354
  notes(a))
1256
1355
  }
1257
- // Directories signed in as one login (spec §3.21): the vendor counts one quota, so the head shows the email and
1258
- // that quota once, and each directory keeps a row of its own for what is its own (name, model, tokens, Health).
1259
- function loginGroup(members, now, cols) {
1260
- const first = members[0]
1261
- const source = members.find((a) => a.quota && a.quota.status === 'ok' && !a.quota.from) || first
1262
- const id = first.identity || {}
1263
- const names = members.map((a) => a.name).join(', ')
1264
- const hint = t('login.hint', { names })
1265
- const m = source.quota && source.quota.status === 'ok' ? attentionWhy({ family: source.family, name: source.name, ref: source.ref, quota: source.quota }, null, now, WARN) : null
1266
- const why = m ? t(m.key, m.vars) : ''
1267
- const email = id.email ? emailText(id.email) : ''
1268
- const key = loginKey(members.map((a) => a.ref))
1269
- const collapsed = prefs.collapsed[key] === true
1270
- const label = t(collapsed ? 'login.expand' : 'login.collapse', { names })
1271
- const toggle = h('button', { class: 'icon-btn row-toggle', type: 'button', 'aria-expanded': String(!collapsed), 'aria-label': label, title: label, onclick: (e) => { e.stopPropagation(); toggleLogin(key) } }, icon('chevron', 'chev'))
1272
- return h('div', { class: 'login' + (collapsed ? ' is-collapsed' : ''), role: 'listitem', 'aria-label': t('login.label', { names }), 'data-key': key },
1273
- h('div', { class: 'row login-head fold-hit', onclick: foldHit(() => toggleLogin(key)) },
1274
- h('div', { class: 'ident' }, toggle, badge(first.family, 'sm'),
1275
- h('div', { class: 'ident-text' },
1276
- h('div', { class: 'ident-name' },
1277
- email ? h('button', { class: 'name email-btn', type: 'button', title: t('menu.copyEmail'), 'aria-label': t('menu.copyEmail') + ' ' + email, onclick: () => copyText(id.email) }, email) : h('span', { class: 'name' }, names),
1278
- why ? h('span', { class: 'dot dot-warn att-dot', title: why, role: 'img', 'aria-label': why }) : null),
1279
- h('div', { class: 'ident-sub', title: hint }, dots([id.org ? h('span', null, id.org) : null, h('span', null, t('login.count', { n: members.length })), collapsed ? h('span', null, names) : null])))),
1280
- quotaBlock(source, now, cols, source.quota && source.quota.status === 'ok' ? source.ref : null),
1281
- h('div', { class: 'last' }),
1282
- h('div', { class: 'more' })),
1283
- collapsed ? null : h('div', { class: 'login-rows', role: 'list', 'data-key': 'rows', 'data-patch': '' }, members.map((a) => row(a, now, cols))))
1356
+ // Directories signed in as one login (spec §3.21, ADR-0026) are one row: the vendor counts one quota, so the row
1357
+ // shows it once, with the email and the newest use; opening it lists each directory with its own short command,
1358
+ // last use, tokens, Health, a button that copies its start command and its own menu.
1359
+ // The directory that names a Same Login row: the one the user put first (Display Order), else the main one, else
1360
+ // the first by name.
1361
+ function loginMembers(members) {
1362
+ const order = accountOrder(members[0].family) || []
1363
+ const at = (m) => { const i = order.indexOf(m.name); return i < 0 ? Infinity : i }
1364
+ return members.slice().sort((x, y) => at(x) - at(y) || Number(Boolean(y.isMain)) - Number(Boolean(x.isMain)) || x.name.localeCompare(y.name, undefined, { numeric: true }))
1365
+ }
1366
+ // Names the row after another directory: puts it just before the current one in the Display Order.
1367
+ function showLoginAs(ms, m) {
1368
+ const before = displayOf()
1369
+ const next = copyDisplay(before)
1370
+ const names = allNames(m.family).filter((n) => n !== m.name)
1371
+ names.splice(Math.max(0, names.indexOf(ms[0].name)), 0, m.name)
1372
+ next.accounts[m.family] = names
1373
+ saveDisplay(before, next)
1374
+ }
1375
+ function newestOf(members) {
1376
+ const at = (m) => { const v = Date.parse((m.usage && m.usage.status === 'ok' && m.usage.lastActivityAt) || ''); return Number.isFinite(v) ? v : -1 }
1377
+ return members.reduce((x, y) => at(y) > at(x) ? y : x)
1378
+ }
1379
+ function loginRow(members, now, cols) {
1380
+ const ms = loginMembers(members)
1381
+ const a = ms[0]
1382
+ if (ui.arrange) return arrangeRow(a, ms)
1383
+ const open = isOpen(a)
1384
+ const email = emailLine(a)
1385
+ 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) },
1386
+ h('div', { class: 'ident' }, rowToggle(a), badge(a.family, 'sm'), h('div', { class: 'ident-text' }, nameLine(a, ms), email || subLine(a))),
1387
+ quotaBlock(a, now, cols),
1388
+ lastUsed(newestOf(ms)),
1389
+ h('div', { class: 'row-end' }, runButton(a), pinButton(a, ms.map((m) => m.name).join(', ')), loginMoreButton(ms)),
1390
+ open ? loginDetail(ms) : null,
1391
+ notes(a))
1392
+ }
1393
+ function loginDetail(ms) {
1394
+ return h('div', { class: 'row-detail login-detail' },
1395
+ h('p', { class: 'rd-note' }, t('login.explain')),
1396
+ h('div', { class: 'login-dirs', role: 'list' }, ms.map((m) => {
1397
+ const run = 'sideby run ' + m.ref
1398
+ return h('div', { class: 'login-dir', role: 'listitem', 'data-ref': m.ref },
1399
+ h('div', { class: 'ld-name' },
1400
+ h('span', { class: 'name' }, m.name),
1401
+ m.ref !== ms[0].ref ? attentionDot(m) : null,
1402
+ (m.aliases || []).slice(0, 3).map((x) => h('span', { class: 'tag tag-alias' }, x))),
1403
+ m.ref === ms[0].ref ? h('span', { class: 'tag ld-shown', title: t('login.shownHint') }, t('login.shown'))
1404
+ : BOOT.readOnly ? h('span') : h('button', { class: 'link-btn small', type: 'button', title: t('login.showAsHint', { name: m.name }), onclick: () => showLoginAs(ms, m) }, t('login.showAs')),
1405
+ lastUsed(m),
1406
+ usageBlock(m),
1407
+ healthChip(m),
1408
+ h('button', { class: 'btn btn-sm', type: 'button', title: run, onclick: () => copyText(run) }, icon('terminal'), t('login.copyRun')),
1409
+ moreButton(m))
1410
+ })))
1284
1411
  }
1285
1412
  // What a click on a row opens: how it runs (ref, model and effort, a shared login), its tokens and its Health.
1286
1413
  function sameLoginNote(a) {
@@ -1301,7 +1428,7 @@ const MAIN = String.raw `
1301
1428
  h('header', { class: 'card-head' },
1302
1429
  badge(a.family, 'sm'),
1303
1430
  h('div', { class: 'ident-text' }, nameLine(a), emailLine(a), subLine(a), sameLoginNote(a)),
1304
- moreButton(a)),
1431
+ h('div', { class: 'row-end' }, pinButton(a, a.name), moreButton(a))),
1305
1432
  notes(a),
1306
1433
  quotaBlock(a, now, cols),
1307
1434
  h('footer', { class: 'card-foot' }, usageBlock(a), h('div', { class: 'card-side' }, lastUsed(a), healthChip(a))))
@@ -1313,7 +1440,7 @@ const MAIN = String.raw `
1313
1440
  const titled = list.some((a) => a.quota && a.quota.status === 'ok')
1314
1441
  return h('div', { class: 'col-head', 'aria-hidden': 'true', 'data-key': 'cols' },
1315
1442
  h('span', { class: 'ch-id' }, t('col.account')),
1316
- h('span', { class: 'quota' }, titled ? cols.map((c) => h('span', null, t(left ? 'col.left' : 'col.used', { label: c }))) : null),
1443
+ h('span', { class: 'quota' }, titled ? cols.map((c) => h('span', null, t(left ? 'col.left' : 'col.used', { label: winTitle(c) }))) : null),
1317
1444
  h('span', { class: 'ch-last' }, t('col.last')),
1318
1445
  h('span', { class: 'ch-more' }))
1319
1446
  }
@@ -1414,30 +1541,43 @@ const MAIN = String.raw `
1414
1541
  }
1415
1542
  function appGroup(list) {
1416
1543
  const app = list[0].app
1417
- const collapsed = prefs.collapsed['app:' + app] === true
1544
+ const id = appId(list[0].ref)
1545
+ ui.order.units[id] = list.map((p) => [p.name])
1546
+ const collapsed = prefs.collapsed['app:' + app] === true && !ui.arrange
1418
1547
  const bodyId = 'grp-app-' + app.replace(/[^a-zA-Z0-9_-]/g, '_')
1419
- const head = h('div', { class: 'group-head', 'data-key': 'head' },
1420
- h('button', { class: 'group-toggle', type: 'button', 'aria-expanded': String(!collapsed), 'aria-controls': bodyId, title: t(collapsed ? 'group.expand' : 'group.collapse'), 'aria-label': t('group.toggle', { title: app, count: String(list.length), action: t(collapsed ? 'group.expand' : 'group.collapse') }), onclick: () => toggleGroup('app:' + app, 'app:' + app) },
1548
+ const head = h('div', { class: 'group-head', 'data-key': ui.arrange ? 'head-arrange' : 'head' },
1549
+ dragHandle('family', id, id, app),
1550
+ h('button', { class: 'group-toggle', type: 'button', disabled: ui.arrange ? true : undefined, 'aria-expanded': String(!collapsed), 'aria-controls': bodyId, title: t(collapsed ? 'group.expand' : 'group.collapse'), 'aria-label': t('group.toggle', { title: app, count: String(list.length), action: t(collapsed ? 'group.expand' : 'group.collapse') }), onclick: () => toggleGroup('app:' + app, 'app:' + app) },
1421
1551
  icon('chevron', 'chev'), appBadge(), h('span', { class: 'group-title' }, app),
1422
1552
  h('span', { class: 'group-count' }, String(list.length))),
1423
1553
  h('div', { class: 'group-meta' },
1424
1554
  h('span', { class: 'muted small label-tip' }, t('app.apart'), tipRaw(app, t('app.apartTip', { app })))),
1425
1555
  list[0].installed ? null : h('div', { class: 'group-meta' }, h('span', { class: 'tag tag-warn' }, t('acct.notInstalled'))))
1426
- const body = collapsed ? null : h('div', { class: 'rows', id: bodyId, role: 'list', 'data-key': 'rows', 'data-patch': '' }, list.map(appRow))
1427
- return h('section', { class: 'group group-list' + (collapsed ? ' is-collapsed' : ''), 'aria-label': app, 'data-key': 'app:' + app, 'data-patch': '' },
1428
- head, body, collapsed ? null : appFoot(list))
1556
+ const body = collapsed ? null : h('div', { class: 'rows', id: bodyId, role: 'list', 'data-key': ui.arrange ? 'rows-arrange' : 'rows', 'data-patch': '' }, list.map((p) => ui.arrange ? appArrangeRow(p, id) : appRow(p)))
1557
+ return h('section', { class: 'group group-list' + (collapsed ? ' is-collapsed' : '') + (ui.arrange ? ' is-arranging' : ''), 'aria-label': app, 'data-key': 'app:' + app, 'data-fam': id, 'data-patch': '' },
1558
+ head, body, collapsed || ui.arrange ? null : appFoot(list))
1559
+ }
1560
+ // A desktop app window in the arrange mode: grip, mark, name, up and down.
1561
+ function appArrangeRow(p, id) {
1562
+ const units = ui.order.units[id] || []
1563
+ const at = units.findIndex((u) => u[0] === p.name)
1564
+ const name = p.isMain ? t('app.mainName') : p.name
1565
+ return h('div', { class: 'row row-arrange', role: 'listitem', 'data-ref': p.ref, 'data-key': 'arr:' + p.ref, 'data-sig': sig([p.ref, at, units.length]) },
1566
+ h('div', { class: 'ident' }, dragHandle('account', id, p.name, name), appBadge(), h('div', { class: 'ident-text' }, h('div', { class: 'ident-name' }, h('span', { class: 'name' }, name), h('span', { class: 'muted small mono' }, p.ref)))),
1567
+ h('div', { class: 'arrange-tools' },
1568
+ h('button', { class: 'icon-btn', type: 'button', 'aria-label': t('order.up', { name }), title: t('order.up', { name }), disabled: at <= 0 ? true : undefined, onclick: () => moveUnit('account', id, at, at - 1, { focus: p.name, label: name, button: 'up' }) }, icon('arrowUp')),
1569
+ h('button', { class: 'icon-btn', type: 'button', 'aria-label': t('order.down', { name }), title: t('order.down', { name }), disabled: at >= units.length - 1 ? true : undefined, onclick: () => moveUnit('account', id, at, at + 1, { focus: p.name, label: name, button: 'down' }) }, icon('arrowDown'))))
1429
1570
  }
1430
1571
  // Shown with the account groups unless a family filter or the attention filter is on; the search matches names.
1431
- function appGroups() {
1432
- if (prefs.family || ui.attention) return []
1572
+ function appGroups() { return appIds().map(appGroupFor).filter(Boolean) }
1573
+ // A desktop app's group, its windows in the Display Order (spec §3.25); none under a family or attention filter.
1574
+ function appGroupFor(id) {
1575
+ if (prefs.family || ui.attention) return null
1433
1576
  const q = ui.query.trim().toLowerCase()
1434
- const out = []
1435
- for (const app of new Set(appProfiles().map((p) => p.app))) {
1436
- const all = appProfiles().filter((p) => p.app === app)
1437
- const list = q && !app.toLowerCase().includes(q) ? all.filter((p) => p.ref.toLowerCase().includes(q)) : all
1438
- if (list.length) out.push(appGroup(list))
1439
- }
1440
- return out
1577
+ const all = inDisplayOrder(appProfiles().filter((p) => appId(p.ref) === id), (p) => p.name, accountOrder(id))
1578
+ const app = all.length ? all[0].app : ''
1579
+ const list = q && !app.toLowerCase().includes(q) ? all.filter((p) => p.ref.toLowerCase().includes(q)) : all
1580
+ return list.length ? appGroup(list) : null
1441
1581
  }
1442
1582
  async function openApp(ref) {
1443
1583
  if (ui.appBusy) return
@@ -1483,9 +1623,13 @@ const MAIN = String.raw `
1483
1623
  const setup = setupFor(fam)
1484
1624
  const off = list.some((a) => a.quota && a.quota.status === 'unavailable' && a.quota.reason === 'not-enabled')
1485
1625
  const bodyId = 'grp-' + fam.replace(/[^a-zA-Z0-9_-]/g, '_')
1486
- const cards = prefs.view === 'cards'
1487
- const head = h('div', { class: 'group-head', 'data-key': 'head' },
1488
- h('button', { class: 'group-toggle', type: 'button', 'aria-expanded': String(!collapsed), 'aria-controls': bodyId, title: t(collapsed ? 'group.expand' : 'group.collapse'), 'aria-label': t('group.toggle', { title: info.title, count: list.length === total ? String(total) : list.length + ' / ' + total, action: t(collapsed ? 'group.expand' : 'group.collapse') }), onclick: () => toggleGroup(fam, 'g:' + fam) },
1626
+ // The arrange mode shows every group open, as a short list, in either view (spec §3.25).
1627
+ const cards = prefs.view === 'cards' && !ui.arrange
1628
+ const collapsedNow = collapsed && !ui.arrange
1629
+ if (!cards) ui.order.units[fam] = loginClusters(list).map((x) => Array.isArray(x) ? x.map((a) => a.name) : [x.name])
1630
+ const head = h('div', { class: 'group-head', 'data-key': ui.arrange ? 'head-arrange' : 'head' },
1631
+ dragHandle('family', fam, fam, info.title),
1632
+ h('button', { class: 'group-toggle', type: 'button', disabled: ui.arrange ? true : undefined, 'aria-expanded': String(!collapsedNow), 'aria-controls': bodyId, title: t(collapsed ? 'group.expand' : 'group.collapse'), 'aria-label': t('group.toggle', { title: info.title, count: list.length === total ? String(total) : list.length + ' / ' + total, action: t(collapsed ? 'group.expand' : 'group.collapse') }), onclick: () => toggleGroup(fam, 'g:' + fam) },
1489
1633
  icon('chevron', 'chev'), badge(fam, 'sm'), h('span', { class: 'group-title' }, info.title),
1490
1634
  h('span', { class: 'group-count' }, list.length === total ? String(total) : list.length + ' / ' + total)),
1491
1635
  h('div', { class: 'group-meta' },
@@ -1497,11 +1641,11 @@ const MAIN = String.raw `
1497
1641
  h('button', { class: 'btn btn-sm', type: 'button', title: setup.summary, 'aria-label': t('quota.turnOnFamilyLabel', { host: info.title }), onclick: () => openSetup(fam) }, t('quota.turnOnFamily')))
1498
1642
  : null,
1499
1643
  off && setup && setup.plan.status === 'blocked' ? h('span', { class: 'muted small label-tip' }, t('quota.blocked'), tipRaw(t('quota.blocked'), setup.plan.message)) : null))
1500
- const body = collapsed ? null : cards
1644
+ const body = collapsedNow ? null : cards
1501
1645
  ? h('div', { class: 'grid', id: bodyId, 'data-key': 'cards', 'data-patch': '' }, list.map((a) => card(a, now, cols)))
1502
- : h('div', { class: 'rows', id: bodyId, role: 'list', 'data-key': 'rows', 'data-patch': '' }, loginClusters(list).map((x) => Array.isArray(x) ? loginGroup(x, now, cols) : row(x, now, cols)))
1503
- return h('section', { class: 'group' + (cards ? ' group-cards' : ' group-list') + (collapsed ? ' is-collapsed' : ''), 'aria-label': info.title, 'data-key': 'g:' + fam, 'data-patch': '' },
1504
- head, collapsed || cards ? null : colHead(cols, list), body)
1646
+ : h('div', { class: 'rows', id: bodyId, role: 'list', 'data-key': ui.arrange ? 'rows-arrange' : 'rows', 'data-patch': '' }, loginClusters(list).map((x) => Array.isArray(x) ? loginRow(x, now, cols) : row(x, now, cols)))
1647
+ return h('section', { class: 'group' + (cards ? ' group-cards' : ' group-list') + (collapsedNow ? ' is-collapsed' : '') + (ui.arrange ? ' is-arranging' : ''), 'aria-label': info.title, 'data-key': 'g:' + fam, 'data-fam': fam, 'data-patch': '' },
1648
+ head, collapsedNow || cards || ui.arrange ? null : colHead(cols, list), body)
1505
1649
  }
1506
1650
  function emptyState() {
1507
1651
  const fams = S.state.families.slice().sort((x, y) => Number(y.installed) - Number(x.installed))
@@ -1512,7 +1656,8 @@ const MAIN = String.raw `
1512
1656
  h('p', { class: 'muted' }, t('empty.body')),
1513
1657
  fams.length ? h('ul', { class: 'host-list' }, fams.map((f) => h('li', null, badge(f.id, 'sm'), h('span', null, f.title),
1514
1658
  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')),
1515
- BOOT.readOnly ? null : h('button', { class: 'btn', type: 'button', onclick: () => openCreate(null) }, icon('plus'), t('empty.create')))
1659
+ BOOT.readOnly ? null : h('button', { class: 'btn btn-primary', type: 'button', onclick: () => openCreate(null) }, icon('plus'), t('empty.create')),
1660
+ BOOT.readOnly ? null : h('p', { class: 'muted small' }, t('ai.emptyHint'), ' ', h('button', { class: 'link-btn', type: 'button', onclick: openAi }, t('ai.how'))))
1516
1661
  }
1517
1662
  function clearFilters() {
1518
1663
  ui.query = ''
@@ -1536,6 +1681,276 @@ const MAIN = String.raw `
1536
1681
  h('p', null, S.loadError.message),
1537
1682
  h('button', { class: 'btn', type: 'button', onclick: () => load({ check: !S.report }) }, icon('refresh'), t('btn.retry')))
1538
1683
  }
1684
+ // ---------- Display Order and Pinned Accounts (spec §3.25) ----------
1685
+ const PIN_MAX = 3
1686
+ function displayOf() { return (S.state && S.state.display) || {} }
1687
+ // Every Family id in the Display Order, the ones only accounts name (a plugin that failed) last.
1688
+ function familyOrder() {
1689
+ const ids = S.state.families.map((f) => f.id)
1690
+ for (const a of S.state.accounts) if (!ids.includes(a.family)) ids.push(a.family)
1691
+ // Desktop apps with windows of their own (Grok Bot) take a place in the same order, after the tools by default.
1692
+ for (const id of appIds()) if (!ids.includes(id)) ids.push(id)
1693
+ return inDisplayOrder(ids, (x) => x, displayOf().families)
1694
+ }
1695
+ function appIds() { return [...new Set(appProfiles().map((p) => appId(p.ref)))] }
1696
+ function isAppGroup(id) { return appIds().includes(id) && !S.state.families.some((f) => f.id === id) }
1697
+ function accountOrder(fam) { return (displayOf().accounts || {})[fam] }
1698
+ function pinnedOf(fam) { return (displayOf().pinned || {})[fam] || [] }
1699
+ function isPinned(a) { return pinnedIn(a, pinnedOf(a.family)) }
1700
+ // Whether a shown unit (an account, or the directories of one login) is pinned.
1701
+ function unitPinned(fam, unit) {
1702
+ const pins = pinnedOf(fam)
1703
+ return unit.some((n) => { const a = S.state.accounts.find((x) => x.family === fam && x.name === n); return a ? pinnedIn(a, pins) : pins.includes(n) })
1704
+ }
1705
+ // Pinned places taken in a Family: a Same Login pinned together counts once.
1706
+ function pinnedUnits(fam) {
1707
+ const names = pinnedOf(fam)
1708
+ return loginClusters(S.state.accounts.filter((a) => a.family === fam && names.includes(a.name))).length
1709
+ }
1710
+ // Every account name of a Family, hidden ones too, in the Display Order.
1711
+ function allNames(fam) {
1712
+ if (isAppGroup(fam)) return inDisplayOrder(appProfiles().filter((p) => appId(p.ref) === fam).map((p) => p.name), (x) => x, accountOrder(fam))
1713
+ return sortAccounts(S.state.accounts.filter((a) => a.family === fam), 'manual', Date.now(), accountOrder(fam)).map((a) => a.name)
1714
+ }
1715
+ function ordered() { const d = displayOf(); return Boolean((d.families && d.families.length) || (d.accounts && Object.keys(d.accounts).length)) }
1716
+ function copyDisplay(d) {
1717
+ return { families: d.families ? d.families.slice() : [], accounts: Object.assign({}, d.accounts || {}), pinned: Object.assign({}, d.pinned || {}) }
1718
+ }
1719
+ // Shows next at once and writes it to the config. Writes go one at a time; a failed one puts back what the config
1720
+ // last confirmed, never another write's unsaved state.
1721
+ const displaySaves = { chain: Promise.resolve(), pending: 0, confirmed: null }
1722
+ function saveDisplay(before, next, failKey) {
1723
+ if (!displaySaves.pending) displaySaves.confirmed = before
1724
+ displaySaves.pending++
1725
+ S.state.display = next
1726
+ renderAccounts()
1727
+ const run = displaySaves.chain.then(async () => {
1728
+ try {
1729
+ const r = await api('/api/display-order', { display: next })
1730
+ displaySaves.confirmed = r.display
1731
+ if (S.state.display === next) S.state.display = r.display
1732
+ return true
1733
+ } catch (e) {
1734
+ // A newer write already on its way settles the screen; only the latest one puts things back.
1735
+ if (S.state.display === next) { S.state.display = displaySaves.confirmed; renderAccounts() }
1736
+ toast(t(failKey || 'order.failed', { msg: e.message }), { kind: 'fail' })
1737
+ return false
1738
+ } finally { displaySaves.pending-- }
1739
+ })
1740
+ displaySaves.chain = run.catch(() => {})
1741
+ return run
1742
+ }
1743
+ // Pins or unpins an Account, with the other directories of its Same Login. A new pin goes last among the pinned.
1744
+ function togglePin(a) {
1745
+ const before = displayOf()
1746
+ const next = copyDisplay(before)
1747
+ const names = [a.name].concat((a.sameLogin || []).map(refName))
1748
+ // Names of accounts that are gone are dropped on the way (spec §3.25).
1749
+ const list = pinnedOf(a.family).filter((n) => !names.includes(n) && S.state.accounts.some((x) => x.family === a.family && x.name === n))
1750
+ if (!isPinned(a)) {
1751
+ if (pinnedUnits(a.family) >= PIN_MAX) { toast(t('pin.full', { n: PIN_MAX }), { kind: 'fail' }); return }
1752
+ list.push(...names.filter((n) => S.state.accounts.some((x) => x.family === a.family && x.name === n)))
1753
+ }
1754
+ next.pinned[a.family] = list
1755
+ saveDisplay(before, next, 'pin.failed')
1756
+ }
1757
+
1758
+ // The arrange mode: the only place to drag (spec §3.25). It starts from the order on screen, saves each move at
1759
+ // once, and "Done" offers to put back the order it started from.
1760
+ function sortAfter(start) { return start.sortBefore === 'manual' ? 'pressure' : start.sortBefore }
1761
+ function startArrange() {
1762
+ if (BOOT.readOnly) return
1763
+ closeMenu()
1764
+ if (!ui.arrange) ui.arrange = { before: copyDisplay(displayOf()), sortBefore: prefs.sort }
1765
+ prefs.sort = 'manual'
1766
+ savePrefs()
1767
+ if (tb.sort) tb.sort.value = 'manual'
1768
+ renderAccounts()
1769
+ const first = document.querySelector('#accounts .drag-handle')
1770
+ if (first) first.focus({ preventScroll: true })
1771
+ }
1772
+ function endArrange() {
1773
+ const start = ui.arrange
1774
+ if (!start) return
1775
+ ui.arrange = null
1776
+ // With no order of one's own left, "my order" is the default order: go back to the usual sort.
1777
+ if (!ordered()) { prefs.sort = sortAfter(start); savePrefs() }
1778
+ if (tb.sort) tb.sort.value = prefs.sort
1779
+ renderAccounts()
1780
+ if (JSON.stringify(copyDisplay(displayOf())) === JSON.stringify(start.before)) return
1781
+ toast(t('order.saved'), { action: { label: t('order.undo'), run: async () => {
1782
+ if (!(await saveDisplay(displayOf(), start.before))) return
1783
+ if (!ordered() && prefs.sort === 'manual') { prefs.sort = sortAfter(start); savePrefs(); if (tb.sort) tb.sort.value = prefs.sort; renderAccounts() }
1784
+ toast(t('order.undone'))
1785
+ } } })
1786
+ }
1787
+ function arrangeBar() {
1788
+ return h('div', { class: 'arrange-bar', role: 'region', 'aria-label': t('sort.arrange'), 'data-key': 'arrange-bar', 'data-sig': sig([ordered(), lang]) },
1789
+ 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'))),
1790
+ h('div', { class: 'arrange-actions' },
1791
+ h('button', { class: 'link-btn', type: 'button', title: t('order.resetHint'), disabled: ordered() ? undefined : true, onclick: resetOrder }, t('order.resetButton')),
1792
+ h('button', { class: 'btn btn-primary btn-sm', type: 'button', onclick: endArrange }, t('order.done'))))
1793
+ }
1794
+ // An account row in the arrange mode: grip, mark, name and short commands, then up, down and pin. Nothing else.
1795
+ function arrangeRow(a, ms) {
1796
+ const fam = a.family
1797
+ const units = ui.order.units[fam] || []
1798
+ const at = units.findIndex((u) => u.includes(a.name))
1799
+ const nPinned = units.filter((u) => unitPinned(fam, u)).length
1800
+ const pinned = isPinned(a)
1801
+ const lo = pinned ? 0 : nPinned
1802
+ const hi = pinned ? nPinned - 1 : units.length - 1
1803
+ const label = ms.map((m) => m.name).join(', ')
1804
+ const full = !pinned && pinnedUnits(fam) >= PIN_MAX
1805
+ const pinLabel = full ? t('pin.full', { n: PIN_MAX }) : t(pinned ? 'pin.unpinName' : 'pin.pinName', { name: label, title: family(fam).title })
1806
+ 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]) },
1807
+ h('div', { class: 'ident' }, dragHandle('account', fam, a.name, label), badge(fam, 'sm'), h('div', { class: 'ident-text' }, h('div', { class: 'ident-name' },
1808
+ h('span', { class: 'name', title: a.name }, a.name),
1809
+ (a.aliases || []).slice(0, 3).map((x) => h('span', { class: 'tag tag-alias' }, x)),
1810
+ ms.length > 1 ? h('span', { class: 'tag tag-login', title: t('login.explain') }, t('login.dirs', { n: ms.length })) : null))),
1811
+ h('div', { class: 'arrange-tools' },
1812
+ 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')),
1813
+ 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')),
1814
+ 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'))))
1815
+ }
1816
+ // 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
1817
+ // the arrow keys. Only in the arrange mode.
1818
+ function dragHandle(kind, fam, key, name) {
1819
+ if (BOOT.readOnly || !ui.arrange) return null
1820
+ const label = t(kind === 'family' ? 'order.moveFamily' : 'order.moveAccount', { name })
1821
+ return h('button', { class: 'icon-btn drag-handle', type: 'button', draggable: 'true', 'data-move': kind, 'data-fam': fam, 'data-name': key, 'aria-label': label, title: t('order.handle'),
1822
+ onclick: (e) => e.stopPropagation(),
1823
+ onkeydown: (e) => {
1824
+ const keys = { ArrowUp: -1, ArrowDown: 1, Home: -Infinity, End: Infinity }
1825
+ if (!Object.hasOwn(keys, e.key)) return
1826
+ e.preventDefault()
1827
+ e.stopPropagation()
1828
+ const units = unitsOf(kind, fam)
1829
+ const from = units.findIndex((u) => u.includes(key))
1830
+ const step = keys[e.key]
1831
+ const to = Math.max(0, Math.min(units.length - 1, Number.isFinite(step) ? from + step : step < 0 ? 0 : units.length - 1))
1832
+ moveUnit(kind, fam, from, to, { focus: key, label: name })
1833
+ },
1834
+ ondragstart: (e) => {
1835
+ const units = unitsOf(kind, fam)
1836
+ ui.drag = { kind, fam, from: units.findIndex((u) => u.includes(key)), to: null, other: null, dropped: false }
1837
+ e.dataTransfer.effectAllowed = 'move'
1838
+ try { e.dataTransfer.setData('text/plain', name) } catch {}
1839
+ const el = unitElement(kind, fam, key)
1840
+ if (el) { e.dataTransfer.setDragImage(el, 16, 16); requestAnimationFrame(() => el.classList.add('is-dragging')) }
1841
+ },
1842
+ ondragend: () => endDrag(),
1843
+ }, icon('grip'))
1844
+ }
1845
+ function unitsOf(kind, fam) { return kind === 'family' ? ui.order.families.map((f) => [f]) : (ui.order.units[fam] || []) }
1846
+ // The element that moves: a Family's group, or an account's row (a Same Login is one row).
1847
+ function unitElement(kind, fam, key) {
1848
+ if (kind === 'family') return document.querySelector('#accounts > section[data-fam="' + CSS.escape(fam) + '"]')
1849
+ return document.querySelector('#accounts .row[data-ref="' + CSS.escape(fam + ':' + key) + '"]')
1850
+ }
1851
+ // Where a drop at the pointer lands: the list of places it can go and the index among them, or null outside.
1852
+ function dropTarget(e) {
1853
+ const d = ui.drag
1854
+ if (!d) return null
1855
+ if (d.kind === 'family') {
1856
+ const els = Array.from(document.querySelectorAll('#accounts > section[data-fam]'))
1857
+ return els.length ? { els, at: slotAt(els, e.clientY) } : null
1858
+ }
1859
+ const sec = e.target.closest && e.target.closest('#accounts > section[data-fam]')
1860
+ // Over another Family: remembered, so letting go there can say why nothing moved.
1861
+ d.other = sec && sec.getAttribute('data-fam') !== d.fam ? sec.getAttribute('data-fam') : null
1862
+ if (!sec || d.other) return null
1863
+ const box = sec.querySelector('.rows')
1864
+ if (!box) return null
1865
+ const els = Array.from(box.children)
1866
+ return { els, at: slotAt(els, e.clientY) }
1867
+ }
1868
+ function slotAt(els, y) {
1869
+ for (let i = 0; i < els.length; i++) {
1870
+ const r = els[i].getBoundingClientRect()
1871
+ if (y < r.top + r.height / 2) return i
1872
+ }
1873
+ return els.length
1874
+ }
1875
+ function clearDropMarks() { for (const el of document.querySelectorAll('.drop-before, .drop-after')) el.classList.remove('drop-before', 'drop-after') }
1876
+ function endDrag() {
1877
+ const d = ui.drag
1878
+ clearDropMarks()
1879
+ for (const el of document.querySelectorAll('.is-dragging')) el.classList.remove('is-dragging')
1880
+ ui.drag = null
1881
+ if (d && d.other && !d.dropped) toast(t('order.sameFamily', { title: family(d.fam).title }))
1882
+ if (S.pending) { S.pending = false; renderAll() }
1883
+ }
1884
+ function onDragOver(e) {
1885
+ const tgt = dropTarget(e)
1886
+ if (!tgt) return
1887
+ e.preventDefault()
1888
+ e.dataTransfer.dropEffect = 'move'
1889
+ clearDropMarks()
1890
+ if (tgt.at < tgt.els.length) tgt.els[tgt.at].classList.add('drop-before')
1891
+ else tgt.els[tgt.els.length - 1].classList.add('drop-after')
1892
+ // Dropped after itself means one place earlier once it is taken out.
1893
+ ui.drag.to = tgt.at > ui.drag.from ? tgt.at - 1 : tgt.at
1894
+ }
1895
+ function onDrop(e) {
1896
+ const d = ui.drag
1897
+ if (!d || !dropTarget(e)) return
1898
+ e.preventDefault()
1899
+ d.dropped = true
1900
+ const to = d.to
1901
+ endDrag()
1902
+ if (to !== null && to !== d.from) moveUnit(d.kind, d.fam, d.from, to)
1903
+ }
1904
+ // Records a move: Families anywhere; pinned accounts only among the pinned (their pin order), the others only
1905
+ // below them (the Display Order). A move past the edge of its part stops at the edge.
1906
+ function moveUnit(kind, fam, from, to, opts) {
1907
+ opts = opts || {}
1908
+ const units = unitsOf(kind, fam)
1909
+ if (from < 0 || to < 0 || from >= units.length || to >= units.length) return
1910
+ const before = displayOf()
1911
+ const next = copyDisplay(before)
1912
+ let place = to
1913
+ let first = 0
1914
+ let total = units.length
1915
+ if (kind === 'family') next.families = reorder(familyOrder(), units, from, to)
1916
+ else {
1917
+ // Names of accounts that are gone are dropped on the way (spec §3.25).
1918
+ const pins = pinnedOf(fam).filter((n) => S.state.accounts.some((x) => x.family === fam && x.name === n))
1919
+ const nPinned = units.filter((u) => unitPinned(fam, u)).length
1920
+ const pinned = from < nPinned
1921
+ place = pinned ? Math.min(to, nPinned - 1) : Math.max(to, nPinned)
1922
+ first = pinned ? 0 : nPinned
1923
+ total = pinned ? nPinned : units.length - nPinned
1924
+ if (place !== from) {
1925
+ if (pinned) next.pinned[fam] = reorder(pins, units.slice(0, nPinned), from, place)
1926
+ else next.accounts[fam] = reorder(allNames(fam), units, from, place)
1927
+ }
1928
+ }
1929
+ const where = () => { if (opts.label) say(t('order.position', { name: opts.label, n: place - first + 1, total })) }
1930
+ if (place === from) { where(); return }
1931
+ saveDisplay(before, next)
1932
+ if (opts.focus) {
1933
+ const grip = '#accounts .drag-handle[data-move="' + kind + '"][data-fam="' + CSS.escape(fam) + '"][data-name="' + CSS.escape(opts.focus) + '"]'
1934
+ let btn = opts.button ? document.querySelector('#accounts .row[data-ref="' + CSS.escape(fam + ':' + opts.focus) + '"] .arrange-tools button:nth-child(' + (opts.button === 'up' ? 1 : 2) + ')') : null
1935
+ // At the edge the arrow it used is off now: the grip keeps the focus.
1936
+ if (!btn || btn.disabled) btn = document.querySelector(grip)
1937
+ if (btn) btn.focus({ preventScroll: false })
1938
+ where()
1939
+ }
1940
+ }
1941
+ // Forgets the order of Families and accounts; pins stay.
1942
+ async function resetOrder() {
1943
+ const before = displayOf()
1944
+ const pinned = before.pinned && Object.keys(before.pinned).length ? before.pinned : null
1945
+ if (await saveDisplay(before, pinned ? { pinned } : {})) toast(t('order.reset'))
1946
+ }
1947
+ // Words for screen readers only, such as the new place of something moved with the keyboard.
1948
+ let live = null
1949
+ function say(text) {
1950
+ if (!live) { live = h('div', { class: 'sr-only', 'aria-live': 'polite' }); document.body.append(live) }
1951
+ live.textContent = ''
1952
+ setTimeout(() => { live.textContent = text }, 30)
1953
+ }
1539
1954
  function renderAccounts() {
1540
1955
  closeMenu()
1541
1956
  const box = $('accounts')
@@ -1556,19 +1971,30 @@ const MAIN = String.raw `
1556
1971
  : tn('count.accounts', all.length))
1557
1972
  const apps = appGroups()
1558
1973
  if (!shown.length && !apps.length) { patch(box, noMatch()); return }
1559
- const cols = quotaColumns(shown)
1560
- box.style.setProperty('--qn', String(cols.length))
1561
- ui.loginRefs = new Set()
1562
- if (prefs.view !== 'cards')
1563
- for (const x of loginClusters(shown)) if (Array.isArray(x)) for (const a of x) ui.loginRefs.add(a.ref)
1564
- const ids = S.state.families.map((f) => f.id)
1565
- for (const a of all) if (!ids.includes(a.family)) ids.push(a.family)
1974
+ ui.loginRefs = new Map()
1975
+ if (prefs.view !== 'cards' || ui.arrange)
1976
+ for (const x of loginClusters(shown)) if (Array.isArray(x)) for (const a of x) ui.loginRefs.set(a.ref, loginMembers(x)[0].ref)
1977
+ // Families in the Display Order (spec §3.25) whatever the sort; each group gets the quota columns it uses.
1978
+ // Every group is as wide as the one with the most windows, so the columns after them line up.
1979
+ ui.order = { families: [], units: {} }
1566
1980
  const groups = []
1567
- for (const fam of ids) {
1981
+ let width = 1
1982
+ for (const fam of familyOrder()) {
1983
+ if (isAppGroup(fam)) {
1984
+ const g = appGroupFor(fam)
1985
+ if (g) { ui.order.families.push(fam); groups.push(g) }
1986
+ continue
1987
+ }
1568
1988
  const list = shown.filter((a) => a.family === fam)
1569
- if (list.length) groups.push(group(fam, sortAccounts(list, prefs.sort, now), all.filter((a) => a.family === fam).length, now, cols))
1989
+ if (!list.length) continue
1990
+ ui.order.families.push(fam)
1991
+ const sorted = withPinned(sortAccounts(list, prefs.sort, now, accountOrder(fam)), pinnedOf(fam))
1992
+ const cols = quotaColumns(sorted)
1993
+ width = Math.max(width, cols.length)
1994
+ groups.push(group(fam, sorted, all.filter((a) => a.family === fam).length, now, cols))
1570
1995
  }
1571
- patch(box, groups.concat(apps))
1996
+ box.style.setProperty('--qn', String(width))
1997
+ patch(box, (ui.arrange ? [arrangeBar()] : []).concat(groups))
1572
1998
  if (ui.fade && !REDUCED) {
1573
1999
  box.classList.add('view-in')
1574
2000
  setTimeout(() => box.classList.remove('view-in'), 400)
@@ -1640,7 +2066,17 @@ const MAIN = String.raw `
1640
2066
  renderCheckup()
1641
2067
  $('checkup').scrollIntoView({ behavior: REDUCED ? 'auto' : 'smooth', block: 'start' })
1642
2068
  }
2069
+ // The Panel updates an outdated sideby skill itself when the Update plan allows it (spec §3.16, §3.23).
2070
+ function skillFixable() {
2071
+ const u = updState()
2072
+ return !BOOT.readOnly && !!(u && u.method.kind !== 'local' && u.skillUpdate && u.skillUpdate.outdated && u.skillUpdate.canRun)
2073
+ }
2074
+ // A Finding in plain words (its code's sentence); the check's own text goes under "Details".
1643
2075
  function findingRow(f) {
2076
+ const own = panelFixes(f, skillFixable())
2077
+ const key = 'finding.' + f.code
2078
+ const plain = Object.hasOwn(MESSAGES.en, key) ? t(key, { item: f.item }) : ''
2079
+ const fixHere = plain && f.fixable && !BOOT.readOnly && !own
1644
2080
  return h('li', { class: 'finding lvl-' + f.level },
1645
2081
  h('span', { class: 'dot', title: f.level }),
1646
2082
  h('div', { class: 'finding-body' },
@@ -1648,12 +2084,20 @@ const MAIN = String.raw `
1648
2084
  ui.healthRef ? null : h('button', { class: 'chip chip-btn mono', type: 'button', title: t('health.only', { ref: f.account }), 'aria-label': t('health.only', { ref: f.account }), onclick: () => filterHealth(f.account) }, badge(f.account.split(':')[0], 'xs'), f.account),
1649
2085
  h('span', { class: 'muted small mono' }, f.item),
1650
2086
  f.fixable ? h('span', { class: 'chip chip-sm chip-accent' }, t('health.autoFix')) : null,
1651
- f.source && f.source !== 'core' ? h('span', { class: 'muted small' }, t('health.from', { source: f.source })) : null),
1652
- h('p', null, f.message),
1653
- f.hint ? h('p', { class: 'hint' }, panelHint(f.hint, f.fixable && !BOOT.readOnly, t('fix.hintAction'))) : null))
2087
+ f.source && f.source !== 'core' ? h('span', { class: 'muted small' }, t('health.from', { source: f.source })) : null,
2088
+ own ? h('button', { class: 'btn btn-sm btn-primary', type: 'button', onclick: openUpdate }, icon('arrowUp'), t('upd.skillLabel')) : null),
2089
+ h('p', null, plain || f.message),
2090
+ own ? null
2091
+ : fixHere ? h('p', { class: 'hint' }, t('finding.fixHint'))
2092
+ : f.hint && !plain ? h('p', { class: 'hint' }, panelHint(f.hint, f.fixable && !BOOT.readOnly, t('fix.hintAction'))) : null,
2093
+ // The check's own words (and any command to run) stay one click away.
2094
+ plain ? h('details', { class: 'finding-raw' }, h('summary', null, t('finding.raw')), h('p', { class: 'mono small' }, f.message), f.hint ? h('p', { class: 'mono small' }, f.hint) : null) : null))
1654
2095
  }
1655
2096
  function renderCheckup() {
1656
2097
  const box = $('checkup')
2098
+ // With no account there is nothing to check: the empty state is the only thing to do.
2099
+ box.hidden = Boolean(S.state && !S.state.accounts.length)
2100
+ if (box.hidden) return
1657
2101
  const all = findingsNow()
1658
2102
  const ref = ui.healthRef
1659
2103
  const findings = all && ref ? all.filter((f) => f.account === ref) : all
@@ -1661,7 +2105,11 @@ const MAIN = String.raw `
1661
2105
  const busy = S.checking || S.fixing
1662
2106
  const fixable = all ? all.filter((f) => f.fixable).length : 0
1663
2107
  // Say why Fix all is off, unless a running check or fix already explains it.
1664
- const fixWhy = busy || fixable ? null : !all ? t('fix.whyNoCheck') : all.length ? t('fix.whyManual') : t('fix.whyNothing')
2108
+ const skillFix = skillFixable()
2109
+ const forAi = (list) => list.filter((f) => !panelFixes(f, skillFix))
2110
+ const fixWhy = busy || fixable ? null : !all ? t('fix.whyNoCheck') : !all.length ? t('fix.whyNothing')
2111
+ : forAi(all).length ? t('fix.whyManual') : t('fix.whyButton')
2112
+ const prompt = findings && !busy ? fixPrompt(forAi(findings), { lead: t('fix.promptLead'), hint: t('fix.promptHint'), fixFirst: t('fix.promptFixFirst'), check: t('fix.promptCheck') }) : ''
1665
2113
  const status = S.checking ? t('health.running') : S.fixing ? t('health.fixing') : at ? t('health.last', { ago: ago(at) }) : t('health.never')
1666
2114
  const out = [h('div', { class: 'section-head' },
1667
2115
  h('div', null,
@@ -1670,6 +2118,7 @@ const MAIN = String.raw `
1670
2118
  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')) }))),
1671
2119
  h('div', { class: 'actions' },
1672
2120
  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')),
2121
+ prompt ? h('button', { class: 'btn', type: 'button', title: t('fix.copyAiTitle'), onclick: () => copyText(prompt, t('fix.promptShown')) }, icon('copy'), t('fix.copyAi')) : null,
1673
2122
  BOOT.readOnly ? null : h('button', { class: 'btn btn-primary', type: 'button', disabled: busy || !fixable, 'aria-busy': S.fixing ? 'true' : null, title: fixWhy, onclick: runFix },
1674
2123
  S.fixing ? spinner() : null, S.fixing ? t('health.fixing') : t('fix.all') + (fixable ? ' (' + fixable + ')' : ''))))]
1675
2124
  if (ref) out.push(h('div', { class: 'filter-bar' },
@@ -1864,10 +2313,12 @@ const MAIN = String.raw `
1864
2313
  if (g.outOfRange) rows.push(h('p', { class: 'note note-warn' }, t('tune.outOfRange', { size: tokens(g.current) })))
1865
2314
  if (!BOOT.readOnly) {
1866
2315
  if (pick && !asking) rows.push(h('div', { class: 'row-actions' }, h('button', { class: 'btn btn-primary', type: 'button', onclick: () => useAsk(g, pick.threshold) }, t('tune.use', { size: tokens(pick.threshold) }))))
1867
- rows.push(h('div', { class: 'tune-setting' },
2316
+ // With a suggestion there is one main button; choosing a size by hand waits behind a click.
2317
+ const custom = h('div', { class: 'tune-setting' },
1868
2318
  h('b', { class: 'small' }, t('tune.setting')),
1869
2319
  h('div', { class: 'row-actions' }, sel,
1870
- h('button', { class: 'btn btn-sm', type: 'button', disabled: Boolean(busy) || value === g.current, onclick: () => useAsk(g, value) }, t('tune.useThis')))))
2320
+ h('button', { class: 'btn btn-sm', type: 'button', disabled: Boolean(busy) || value === g.current, onclick: () => useAsk(g, value) }, t('tune.useThis'))))
2321
+ rows.push(pick ? h('details', { class: 'tune-custom', open: value !== (pick ? pick.threshold : null) && value !== g.current ? true : undefined }, h('summary', null, t('tune.custom')), custom) : custom)
1871
2322
  rows.push(useBox(g, false))
1872
2323
  if (g.undo && !(mine && (useUi.step === 'done' || useUi.step === 'undone')))
1873
2324
  rows.push(h('p', { class: 'small tune-undo' }, t('tune.applied', { prev: tuneSize(g.undo.previous) }), ' ',
@@ -1962,7 +2413,9 @@ const MAIN = String.raw `
1962
2413
  setKids(nav, h('div', { class: 'seg', role: 'presentation' }, tabs.map(([id, label]) =>
1963
2414
  h('button', { class: 'seg-btn', type: 'button', role: 'tab', 'aria-selected': curPage === id ? 'true' : 'false', onclick: () => setPage(id) }, label))))
1964
2415
  const away = curPage !== 'accounts' && tabs.some(([id]) => id === curPage)
1965
- for (const id of ['summary', 'accounts-page', 'checkup']) $(id).hidden = away
2416
+ // With no account yet the empty state is the whole page: no summary, no health check.
2417
+ const none = Boolean(S.state && !S.state.accounts.length)
2418
+ for (const id of ['summary', 'accounts-page', 'checkup']) $(id).hidden = away || (none && id !== 'accounts-page')
1966
2419
  const onTune = away && curPage === 'tuning'
1967
2420
  const onHandoff = away && curPage === 'handoff'
1968
2421
  $('tuning-page').hidden = !onTune
@@ -2107,9 +2560,12 @@ const MAIN = String.raw `
2107
2560
  h('p', { class: 'muted small' }, t('nf.intro')),
2108
2561
  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'))),
2109
2562
  h('div', { class: 'field' }, h('label', { class: 'field', for: 'nf-name' }, h('span', null, t('nf.name')), e.name), e.suggest),
2110
- h('div', { class: 'field' }, h('label', { class: 'field', for: 'nf-alias' }, h('span', null, t('nf.alias'), ' ', h('span', { class: 'muted small' }, t('nf.optional'))), e.alias), e.aliasHint, e.aliasErr),
2111
- 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')),
2112
- e.preview,
2563
+ // The first screen asks only for the tool and a name; the rest waits behind "More options".
2564
+ h('details', { class: 'more-opts', open: nf.api || Boolean(nf.alias) || Boolean(nf.error) || undefined },
2565
+ h('summary', null, t('nf.more')),
2566
+ h('div', { class: 'field' }, h('label', { class: 'field', for: 'nf-alias' }, h('span', null, t('nf.alias'), ' ', h('span', { class: 'muted small' }, t('nf.optional'))), e.alias), e.aliasHint, e.aliasErr),
2567
+ 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')),
2568
+ e.preview),
2113
2569
  e.error,
2114
2570
  e.errNote = nf.error ? errorNote(nf.error, create, nf.error.code === 'create-refused' && !nf.api ? t('nf.refusedApi') : null) : null)
2115
2571
  setKids(el, dialogFrame(t('nf.title'), null, body, [
@@ -2842,6 +3298,29 @@ const MAIN = String.raw `
2842
3298
  const api = S.state.accounts.filter((a) => a.kind === 'api').map((a) => a.ref)
2843
3299
  return remoteControlSummary(S.state.remoteControl || [], api)
2844
3300
  }
3301
+ // The refresh button reads everything again; Families that ask their Host for Quota (Grok, spec §3.26) are asked
3302
+ // again first, so the numbers are new and not the kept ones.
3303
+ function asking() { return !BOOT.readOnly && S.state && (S.state.quotaChecks || []).some((c) => c.on && !c.blocked) }
3304
+ async function refreshAll() {
3305
+ if (asking()) {
3306
+ const btn = $('refresh')
3307
+ btn.disabled = true
3308
+ btn.classList.add('is-spinning')
3309
+ try { await api('/api/quota/refresh', {}) } catch (e) { toast(t('quota.refreshFailed', { msg: e.message }), { kind: 'fail' }) }
3310
+ btn.classList.remove('is-spinning')
3311
+ btn.disabled = false
3312
+ }
3313
+ load({ check: true, announce: true })
3314
+ }
3315
+ function checkOf(fam) { return S.state && (S.state.quotaChecks || []).find((c) => c.family === fam) }
3316
+ async function setCheck(fam, on) {
3317
+ try {
3318
+ await api('/api/quota/check', { family: fam, on })
3319
+ toast(t(on ? 'quota.checkOnToast' : 'quota.checkOffToast', { host: famTitleOf(fam) }))
3320
+ } catch (e) { toast(t('quota.checkFailed', { msg: e.message }), { kind: 'fail' }) }
3321
+ load({ silent: true })
3322
+ }
3323
+ function famTitleOf(fam) { const f = S.state && S.state.families.find((x) => x.id === fam); return f ? f.title : fam }
2845
3324
  // ---------- settings ----------
2846
3325
  // One header button holds what is set once and rarely changed: language, theme, Remote Control and Auto Handoff.
2847
3326
  // A dot on it says Remote Control starts by itself, so that state stays visible after its banner is gone.
@@ -2849,8 +3328,11 @@ const MAIN = String.raw `
2849
3328
  const out = []
2850
3329
  if (!LANG_FIXED) out.push('lang')
2851
3330
  if (BOOT.colorScheme === 'auto') out.push('theme')
3331
+ if (S.state && S.state.accounts.length && !BOOT.readOnly) out.push('order')
3332
+ out.push('ai')
2852
3333
  if (S.state && rcSummary()) out.push('rc')
2853
3334
  if (S.state && ahState()) out.push('ah')
3335
+ if (S.state && (S.state.quotaChecks || []).length) out.push('checks')
2854
3336
  return out
2855
3337
  }
2856
3338
  function renderSettingsButton() {
@@ -2873,7 +3355,19 @@ const MAIN = String.raw `
2873
3355
  h('span', { class: 'menu-label' }, label), h('span', { class: 'chip chip-sm ' + cls }, state), icon('chevron'))
2874
3356
  if (secs.includes('lang')) out.push(seg(t('set.lang'), [['en', 'English'], ['zh', '中文']], lang, setLang))
2875
3357
  if (secs.includes('theme')) out.push(seg(t('set.theme'), CHOICES.theme.map((v) => [v, t('set.theme.' + v)]), prefs.theme, setTheme))
2876
- if (out.length && (secs.includes('rc') || secs.includes('ah'))) out.push(h('div', { class: 'menu-sep', role: 'separator' }))
3358
+ if (out.length && (secs.includes('order') || secs.includes('rc') || secs.includes('ah'))) out.push(h('div', { class: 'menu-sep', role: 'separator' }))
3359
+ // Everything here can also be asked of an AI assistant through the sideby skill (spec §3.8).
3360
+ if (secs.includes('ai')) out.push(link(t('ai.title'), t(skillInstalled() ? 'ai.installed' : 'ai.notInstalled'), skillInstalled() ? 'chip-ok' : '', openAi))
3361
+ // The arrange mode (spec §3.25) has a place here too, where people look for "how do I change this".
3362
+ if (secs.includes('order')) {
3363
+ const pins = Object.values(displayOf().pinned || {}).some((x) => x.length)
3364
+ out.push(link(t('set.order'), t(ordered() || pins ? 'set.orderMine' : 'set.orderDefault'), '', () => {
3365
+ setPage('accounts')
3366
+ startArrange()
3367
+ const bar = document.querySelector('#accounts .arrange-bar')
3368
+ if (bar) bar.scrollIntoView({ behavior: REDUCED ? 'auto' : 'smooth', block: 'center' })
3369
+ }))
3370
+ }
2877
3371
  if (secs.includes('rc')) {
2878
3372
  const sum = rcSummary()
2879
3373
  out.push(link(t('rc.button'), t('rc.state.' + sum), sum === 'on' ? 'chip-warn' : sum === 'off' ? 'chip-ok' : '', openRc))
@@ -2882,6 +3376,18 @@ const MAIN = String.raw `
2882
3376
  const on = ahState().settings.auto
2883
3377
  out.push(link(t('ah.button'), on ? t('ah.on') : t('ah.off'), on ? 'chip-ok' : '', () => setPage('handoff')))
2884
3378
  }
3379
+ // Quota checks that ask the Host (spec §3.26): one switch per Family, with what it does in plain words.
3380
+ if (secs.includes('checks')) {
3381
+ if (out.length) out.push(h('div', { class: 'menu-sep', role: 'separator' }))
3382
+ for (const c of S.state.quotaChecks) {
3383
+ const label = t('set.quotaCheck', { host: c.title })
3384
+ out.push(h('div', { class: 'set-row set-check' },
3385
+ 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 }))),
3386
+ BOOT.readOnly ? h('span', { class: 'chip chip-sm' + (c.on ? ' chip-ok' : '') }, c.on ? t('ah.on') : t('ah.off'))
3387
+ : h('div', { class: 'seg', role: 'group', 'aria-label': label }, [[true, t('ah.on')], [false, t('ah.off')]].map(([v, text]) =>
3388
+ h('button', { class: 'seg-btn', type: 'button', 'aria-pressed': String(v === c.on), onclick: () => { if (v !== c.on) { closeMenu(); setCheck(c.family, v) } } }, text)))))
3389
+ }
3390
+ }
2885
3391
  return out
2886
3392
  }
2887
3393
  function setLang(v) {
@@ -2906,6 +3412,25 @@ const MAIN = String.raw `
2906
3412
  applyTheme()
2907
3413
  refreshPopover('settings')
2908
3414
  }
3415
+ // The sideby Agent Skill: what to say to an AI assistant instead of clicking, and how to install it when needed.
3416
+ function skillInstalled() {
3417
+ const u = S.state && S.state.update
3418
+ return Boolean(u && u.skillUpdate && u.skillUpdate.installed && u.skillUpdate.installed.length)
3419
+ }
3420
+ const AI_ASKS = ['ai.ask.quota', 'ai.ask.new', 'ai.ask.pin', 'ai.ask.fix', 'ai.ask.handoff', 'ai.ask.rc']
3421
+ function openAi() {
3422
+ showDialog('ai')
3423
+ const asks = AI_ASKS.map((k) => {
3424
+ const text = t(k)
3425
+ return h('li', null, h('button', { class: 'ai-ask', type: 'button', title: t('ai.copy'), onclick: () => copyText(text) }, icon('copy'), h('span', null, text)))
3426
+ })
3427
+ setKids(dlg.el, dialogFrame(t('ai.title'), null, h('div', { class: 'form' },
3428
+ h('p', null, t('ai.intro')),
3429
+ skillInstalled() ? h('p', { class: 'note' }, t('ai.ready')) : h('div', { class: 'field' }, h('p', { class: 'small' }, t('ai.install')), codeCopy(BOOT.skillInstall)),
3430
+ h('p', { class: 'small muted' }, t('ai.examples')),
3431
+ h('ul', { class: 'ai-asks' }, asks)),
3432
+ [h('button', { class: 'btn', type: 'button', onclick: () => dlg.el.close() }, t('btn.close'))]))
3433
+ }
2909
3434
  function openRc() {
2910
3435
  if (!S.state || !rcSummary()) return
2911
3436
  rc.target = null
@@ -3043,6 +3568,17 @@ const MAIN = String.raw `
3043
3568
  // build and reloads (a background Panel restarts itself, a Portal loads the new code by itself).
3044
3569
  let up = null
3045
3570
  function updState() { return BOOT.updates && S.state ? S.state.update || null : null }
3571
+ // The skill agents read is part of the version (spec §3.23): behind a current CLI it is the one thing to update.
3572
+ const isLocal = (u) => u.method.kind === 'local'
3573
+ const skillOld = (u) => !!(u && !u.available && !isLocal(u) && u.skillUpdate && u.skillUpdate.outdated)
3574
+ const skillDue = (u) => skillOld(u) && u.skillUpdate.canRun
3575
+ function skillFor(u) {
3576
+ const v = u.skillUpdate.installed.find((x) => x !== u.current)
3577
+ return v ? v : t('upd.skillNoVer')
3578
+ }
3579
+ // Why sideby cannot do something itself, in the viewer's language; the English reason when the code is unknown.
3580
+ const why = (o) => (o && o.reasonCode ? t('why.' + o.reasonCode, { at: o.reasonAt || '' }) : (o && o.reason) || '')
3581
+ const offText = (u) => (u.checksOffCode ? t('off.' + u.checksOffCode, { why: u.checksOff }) : t('upd.checksOff', { why: u.checksOff }))
3046
3582
  function renderUpdateButton() {
3047
3583
  const b = $('update')
3048
3584
  const u = updState()
@@ -3051,27 +3587,29 @@ const MAIN = String.raw `
3051
3587
  const v = $('ver-top')
3052
3588
  v.hidden = !BOOT.version
3053
3589
  if (!v.hidden) {
3054
- const newer = !!(u && u.available)
3590
+ const newer = !!(u && (u.available || skillDue(u)))
3055
3591
  v.textContent = verLabel()
3056
3592
  v.classList.toggle('has-update', newer)
3057
3593
  v.disabled = !BOOT.updates
3058
- const tip = !BOOT.updates ? '' : newer ? t('upd.labelLong', { v: u.latest, cur: u.current }) : t('upd.verTitle', { v: BOOT.version })
3594
+ const tip = !BOOT.updates ? '' : u && u.available ? t('upd.labelLong', { v: u.latest, cur: u.current })
3595
+ : skillDue(u) ? t('upd.skillLabelLong', { s: skillFor(u), cur: u.current }) : t('upd.verTitle', { v: BOOT.version })
3059
3596
  v.title = tip
3060
3597
  if (tip) v.setAttribute('aria-label', tip)
3061
3598
  else v.removeAttribute('aria-label')
3062
3599
  }
3063
- b.hidden = !(u && u.available)
3600
+ b.hidden = !(u && (u.available || skillDue(u)))
3064
3601
  if (!b.hidden) {
3065
- setKids(b, [icon('arrowUp'), h('span', null, t('upd.label', { v: u.latest }))])
3066
- b.setAttribute('aria-label', t('upd.labelLong', { v: u.latest, cur: u.current }))
3067
- b.title = t('upd.labelLong', { v: u.latest, cur: u.current })
3602
+ const long = u.available ? t('upd.labelLong', { v: u.latest, cur: u.current }) : t('upd.skillLabelLong', { s: skillFor(u), cur: u.current })
3603
+ setKids(b, [icon('arrowUp'), h('span', null, u.available ? t('upd.label', { v: u.latest }) : t('upd.skillLabel'))])
3604
+ b.setAttribute('aria-label', long)
3605
+ b.title = long
3068
3606
  }
3069
3607
  if (dlg.kind === 'update' && up && !up.busy) renderUpdate()
3070
3608
  if (!vp.checking) renderVerPop()
3071
3609
  }
3072
3610
  function openUpdate() {
3073
3611
  if (!BOOT.updates) return
3074
- up = { busy: false, error: null, done: null, changed: false, autoPlan: null, autoBusy: false, waitUntil: 0, checking: false, checked: false }
3612
+ up = { busy: false, error: null, done: null, changed: false, skillDone: false, skillChanged: false, autoPlan: null, autoBusy: false, waitUntil: 0, checking: false, checked: false }
3075
3613
  showDialog('update')
3076
3614
  renderUpdate()
3077
3615
  }
@@ -3098,6 +3636,27 @@ const MAIN = String.raw `
3098
3636
  dlg.busy = false
3099
3637
  renderUpdate()
3100
3638
  }
3639
+ // The CLI is current and the skill is behind: update the skill alone, as sideby update --yes does.
3640
+ async function updSkillRun() {
3641
+ up.busy = true
3642
+ dlg.busy = true
3643
+ up.error = null
3644
+ up.skillChanged = false
3645
+ renderUpdate()
3646
+ try {
3647
+ const r = await api('/api/update', { confirm: true, skill: true })
3648
+ if (r.skillUpdated) {
3649
+ up.skillDone = true
3650
+ toast(t('upd.skillDoneOnly'))
3651
+ // The health check still lists the old skill: check again so its Findings go.
3652
+ if ((findingsNow() || []).some((f) => f.code === 'skill.outdated')) runCheck()
3653
+ } else up.skillChanged = true
3654
+ await load({ silent: true })
3655
+ } catch (e) { up.error = e }
3656
+ up.busy = false
3657
+ dlg.busy = false
3658
+ renderUpdate()
3659
+ }
3101
3660
  // The version pill opens a small popover, not the dialog: the version, whether it is the newest, "Check for
3102
3661
  // updates" in place, and the way into the dialog when there is something to do (update, Auto Update).
3103
3662
  const vp = { checking: false, checked: false, error: null }
@@ -3121,9 +3680,9 @@ const MAIN = String.raw `
3121
3680
  if (BOOT.local) kids.push(h('p', { class: 'muted small' }, t(BOOT.local.dirty ? 'upd.localDirty' : 'upd.local', { at: BOOT.local.builtAt ? clock(BOOT.local.builtAt) : '' })))
3122
3681
  let status
3123
3682
  if (!u) status = t('upd.loading')
3124
- else if (u.available) status = null
3683
+ else if (u.available || skillOld(u)) status = null
3125
3684
  else if (vp.checked && u.latest) status = t('upd.latestNow')
3126
- else if (u.checksOff) status = t('upd.checksOff', { why: u.checksOff })
3685
+ else if (u.checksOff) status = offText(u)
3127
3686
  else if (u.latest) status = t('upd.latest') + (u.checkedAt ? ' · ' + t('upd.checked', { ago: ago(u.checkedAt) }) : '')
3128
3687
  else status = t('upd.notYet')
3129
3688
  if (status) kids.push(h('p', { class: 'muted small' }, status))
@@ -3131,12 +3690,15 @@ const MAIN = String.raw `
3131
3690
  kids.push(h('p', { class: 'small' }, t('upd.available', { v: u.latest }), ' ',
3132
3691
  h('a', { href: u.releaseNotes, target: '_blank', rel: 'noopener noreferrer' }, t('upd.notes'))))
3133
3692
  kids.push(h('div', { class: 'popover-actions' }, h('button', { class: 'btn btn-sm btn-primary', type: 'button', onclick: toDialog }, t('upd.open'))))
3693
+ } else if (skillOld(u)) {
3694
+ kids.push(h('p', { class: 'small' }, t('upd.skillBehind', { s: skillFor(u) })))
3695
+ kids.push(h('div', { class: 'popover-actions' }, h('button', { class: 'btn btn-sm' + (skillDue(u) ? ' btn-primary' : ''), type: 'button', onclick: toDialog }, t('upd.open'))))
3134
3696
  } else if (!BOOT.readOnly) {
3135
3697
  kids.push(h('div', { class: 'popover-actions' }, h('button', { class: 'btn btn-sm', type: 'button', disabled: vp.checking, 'aria-busy': vp.checking ? 'true' : null, onclick: verPopCheck },
3136
3698
  vp.checking ? spinner() : icon('refresh'), vp.checking ? t('upd.checking') : t('upd.check'))))
3137
3699
  }
3138
3700
  if (vp.error) kids.push(errorNote(vp.error, null))
3139
- if (u && !BOOT.readOnly)
3701
+ if (u && !BOOT.readOnly && !isLocal(u))
3140
3702
  kids.push(h('p', { class: 'muted small popover-foot' }, t(u.auto ? 'upd.autoIsOn' : 'upd.autoIsOff'), ' · ',
3141
3703
  h('button', { class: 'link-btn', type: 'button', onclick: toDialog }, t('upd.settings'))))
3142
3704
  return kids
@@ -3198,13 +3760,17 @@ const MAIN = String.raw `
3198
3760
  up.autoBusy = false
3199
3761
  renderUpdate()
3200
3762
  }
3201
- // The installed sideby skill follows the CLI (spec §3.23): said before the Update, or what to run when sideby may not.
3763
+ // The installed sideby skill follows the CLI (spec §3.23): one quiet line when the Update brings it along, the
3764
+ // reason and the command only when sideby may not.
3202
3765
  function updSkill(u) {
3203
3766
  const s = u.skillUpdate
3204
- if (!s) return [h('p', { class: 'small' }, t('upd.skill')), codeCopy(u.skill)]
3205
- if (!s.outdated) return []
3767
+ if (!s || !s.outdated) return []
3206
3768
  if (s.canRun) return [h('p', { class: 'muted small' }, t('upd.skillToo'))]
3207
- return [h('p', { class: 'small' }, t('upd.skillManaged', { why: s.reason || '' })), codeCopy(s.command)]
3769
+ return [h('p', { class: 'small' }, t('upd.skillManaged', { why: why(s) })), codeCopy(s.command)]
3770
+ }
3771
+ // What a person runs by hand, behind a click: the default path is the button.
3772
+ function updManual(cmds) {
3773
+ return h('details', { class: 'tune-details' }, h('summary', null, t('upd.manual')), cmds)
3208
3774
  }
3209
3775
  function renderUpdate() {
3210
3776
  if (!up || dlg.kind !== 'update') return
@@ -3221,30 +3787,50 @@ const MAIN = String.raw `
3221
3787
  body.push(h('p', { class: 'muted small' }, Date.now() > up.waitUntil ? t('upd.restartHint') : [spinner(), ' ', t('upd.reloading')]))
3222
3788
  if (up.done.skillUpdated) body.push(h('p', { class: 'small' }, t('upd.skillDone')))
3223
3789
  else body.push(...updSkill(u))
3790
+ } else if (up.skillDone && !skillOld(u)) {
3791
+ body.push(h('p', { class: 'note' }, t('upd.skillDoneOnly')))
3792
+ } else if (u.available && isLocal(u)) {
3793
+ // A Local Build (ADR-0024): one way back to npm, which brings the skill along.
3794
+ body.push(h('p', null, t('upd.localAvail', { v: u.latest }), ' ',
3795
+ h('a', { href: u.releaseNotes, target: '_blank', rel: 'noopener noreferrer' }, t('upd.notes'))))
3796
+ body.push(codeCopy(u.command))
3224
3797
  } else if (u.available) {
3225
3798
  if (up.changed) body.push(h('p', { class: 'note note-warn' }, t('upd.changed', { v: u.latest })))
3226
3799
  body.push(h('p', null, h('b', null, t('upd.available', { v: u.latest })), ' ',
3227
3800
  h('a', { href: u.releaseNotes, target: '_blank', rel: 'noopener noreferrer' }, t('upd.notes'))))
3228
3801
  if (u.canRun) {
3229
- body.push(h('p', { class: 'small' }, t('upd.runs')), codeCopy(u.command))
3802
+ body.push(...updSkill(u))
3230
3803
  body.push(h('p', { class: 'muted small' }, t('upd.after')))
3804
+ body.push(updManual([h('p', { class: 'small' }, t('upd.runs')), codeCopy(u.command)]))
3231
3805
  } else {
3232
- body.push(h('p', { class: 'note note-warn' }, t('upd.cannot', { why: u.method.reason || u.method.kind })))
3806
+ body.push(h('p', { class: 'note note-warn' }, t('upd.cannot', { why: why(u.method) || u.method.kind })))
3233
3807
  body.push(codeCopy(u.command))
3808
+ // An install by hand leaves the skill behind; sideby update (or Auto Update) brings it along afterwards.
3809
+ const s = u.skillUpdate
3810
+ if (s && s.outdated && s.canRun) body.push(h('p', { class: 'muted small' }, t('upd.skillLater')))
3811
+ else body.push(...updSkill(u))
3234
3812
  }
3235
- body.push(...updSkill(u))
3813
+ } else if (skillOld(u)) {
3814
+ if (up.skillChanged) body.push(h('p', { class: 'note note-warn' }, t('upd.skillChanged')))
3815
+ const s = u.skillUpdate
3816
+ body.push(h('p', null, h('b', null, t('upd.skillBehind', { s: skillFor(u) }))))
3817
+ if (s.canRun) {
3818
+ body.push(h('p', { class: 'muted small' }, t('upd.skillWhat', { cur: u.current })))
3819
+ body.push(updManual([codeCopy(s.command)]))
3820
+ } else body.push(h('p', { class: 'small' }, t('upd.skillManaged', { why: why(s) })), codeCopy(s.command))
3236
3821
  } else if (up.checked && u.latest) {
3237
3822
  body.push(h('p', { class: 'note' }, t('upd.latestNow')))
3238
3823
  } else if (u.checksOff) {
3239
- body.push(h('p', { class: 'muted small' }, t('upd.checksOff', { why: u.checksOff })))
3824
+ body.push(h('p', { class: 'muted small' }, offText(u)))
3240
3825
  } else if (u.latest) {
3241
3826
  body.push(h('p', { class: 'muted small' }, t('upd.latest') + (u.checkedAt ? ' · ' + t('upd.checked', { ago: ago(u.checkedAt) }) : '')))
3242
3827
  } else body.push(h('p', { class: 'muted small' }, t('upd.notYet')))
3243
3828
  // A read-only Panel sends no request and writes nothing, so it cannot check (spec §3.23).
3244
- if (!BOOT.readOnly && !up.done && !u.available)
3829
+ if (!BOOT.readOnly && !up.done && !u.available && !skillDue(u))
3245
3830
  body.push(h('p', null, h('button', { class: 'btn btn-sm', type: 'button', disabled: up.checking || up.busy, 'aria-busy': up.checking ? 'true' : null, onclick: updCheck },
3246
3831
  up.checking ? spinner() : icon('refresh'), up.checking ? t('upd.checking') : t('upd.check'))))
3247
- if (!BOOT.readOnly && !up.done) {
3832
+ if (!BOOT.readOnly && !up.done && isLocal(u)) body.push(h('p', { class: 'muted small' }, t('upd.autoLocal')))
3833
+ else if (!BOOT.readOnly && !up.done) {
3248
3834
  const box = h('input', { type: 'checkbox', disabled: up.autoBusy || !u.method.canUpdate })
3249
3835
  box.checked = up.autoPlan ? up.autoPlan.auto : u.auto
3250
3836
  box.addEventListener('change', () => updAuto(box.checked))
@@ -3256,8 +3842,9 @@ const MAIN = String.raw `
3256
3842
  if (BOOT.readOnly) body.push(h('p', { class: 'note' }, t('banner.readOnly')))
3257
3843
  if (up.error) body.push(errorNote(up.error, null))
3258
3844
  const foot = [h('button', { class: 'btn', type: 'button', disabled: up.busy, onclick: () => dlg.el.close() }, t('btn.close'))]
3259
- if (u.available && u.canRun && !BOOT.readOnly && !up.done)
3260
- foot.push(h('button', { class: 'btn btn-primary', type: 'button', disabled: up.busy, 'aria-busy': up.busy ? 'true' : null, onclick: updRun }, up.busy ? spinner() : null, up.busy ? t('upd.updating') : t('upd.now', { v: u.latest })))
3845
+ 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)
3846
+ if (u.available && u.canRun && !BOOT.readOnly && !up.done) foot.push(primary(t('upd.now', { v: u.latest }), updRun))
3847
+ else if (skillDue(u) && !BOOT.readOnly && !up.done) foot.push(primary(t('upd.skillNow'), updSkillRun))
3261
3848
  setKids(dlg.el, dialogFrame(t('upd.title'), null, body, foot))
3262
3849
  }
3263
3850
 
@@ -3267,7 +3854,14 @@ const MAIN = String.raw `
3267
3854
  $('ver').addEventListener('click', () => openVerPop($('ver')))
3268
3855
  $('update').addEventListener('click', openUpdate)
3269
3856
  $('ver-top').addEventListener('click', () => openVerPop($('ver-top')))
3270
- $('refresh').addEventListener('click', () => load({ check: true, announce: true }))
3857
+ $('refresh').addEventListener('click', refreshAll)
3858
+ $('accounts').addEventListener('dragover', onDragOver)
3859
+ $('accounts').addEventListener('drop', onDrop)
3860
+ // Escape leaves the arrange mode, as "Done" does, unless a menu or dialog takes it first.
3861
+ document.addEventListener('keydown', (e) => {
3862
+ if (e.key !== 'Escape' || e.defaultPrevented || !ui.arrange || menu.el || (dlg.el && dlg.el.open) || e.target.closest('input, select, textarea')) return
3863
+ endArrange()
3864
+ })
3271
3865
  $('settings').addEventListener('click', () => openPopover($('settings'), 'settings', t('set.button'), settingsPop, 'end'))
3272
3866
  applyTheme()
3273
3867
  renderStatic()