sideby 0.14.0 → 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 (63) hide show
  1. package/README.md +1 -0
  2. package/dist/build.json +1 -1
  3. package/dist/src/cli/commands.d.ts +2 -0
  4. package/dist/src/cli/commands.js +176 -9
  5. package/dist/src/cli/json-schemas.js +90 -2
  6. package/dist/src/cli/main.js +24 -5
  7. package/dist/src/core/config.d.ts +1 -0
  8. package/dist/src/core/config.js +8 -2
  9. package/dist/src/core/skill-check.d.ts +14 -0
  10. package/dist/src/core/skill-check.js +29 -3
  11. package/dist/src/core/update.d.ts +67 -1
  12. package/dist/src/core/update.js +147 -23
  13. package/dist/src/panel/handler.d.ts +7 -1
  14. package/dist/src/panel/handler.js +21 -5
  15. package/dist/src/panel/i18n.d.ts +118 -5
  16. package/dist/src/panel/i18n.js +266 -40
  17. package/dist/src/panel/page-logic.d.ts +41 -0
  18. package/dist/src/panel/page-logic.js +46 -0
  19. package/dist/src/panel/page-script.js +571 -162
  20. package/dist/src/panel/page-styles.js +71 -23
  21. package/dist/src/panel/page.js +3 -0
  22. package/dist/src/runtime.d.ts +2 -0
  23. package/package.json +3 -2
  24. package/schemas/alias.json +1 -1
  25. package/schemas/app-install.json +1 -1
  26. package/schemas/app-uninstall.json +1 -1
  27. package/schemas/browser-open.json +1 -1
  28. package/schemas/browser-switch.json +1 -1
  29. package/schemas/browser.json +1 -1
  30. package/schemas/config.json +15 -1
  31. package/schemas/desktop-remove.json +1 -1
  32. package/schemas/desktop.json +1 -1
  33. package/schemas/doctor.json +1 -1
  34. package/schemas/error.json +1 -1
  35. package/schemas/families.json +1 -1
  36. package/schemas/handoff-disable.json +1 -1
  37. package/schemas/handoff-enable.json +1 -1
  38. package/schemas/handoff-ready.json +1 -1
  39. package/schemas/handoff-setup.json +1 -1
  40. package/schemas/handoff-status.json +282 -1
  41. package/schemas/handoff-teardown.json +1 -1
  42. package/schemas/list.json +6 -1
  43. package/schemas/model.json +1 -1
  44. package/schemas/new.json +1 -1
  45. package/schemas/next.json +1 -1
  46. package/schemas/order.json +148 -0
  47. package/schemas/pin.json +126 -0
  48. package/schemas/plugins.json +1 -1
  49. package/schemas/quota-check.json +88 -0
  50. package/schemas/quota-setup.json +1 -1
  51. package/schemas/quota-teardown.json +1 -1
  52. package/schemas/quota.json +1 -1
  53. package/schemas/remote-control.json +1 -1
  54. package/schemas/tune-change.json +1 -1
  55. package/schemas/tune.json +1 -1
  56. package/schemas/unpin.json +126 -0
  57. package/schemas/update-auto.json +84 -0
  58. package/schemas/update.json +79 -1
  59. package/skills/sideby/SKILL.md +7 -3
  60. package/skills/sideby/references/handoff.md +8 -0
  61. package/skills/sideby/references/panel.md +28 -0
  62. package/skills/sideby/references/quota.md +6 -0
  63. package/skills/sideby/references/update.md +6 -2
@@ -1,12 +1,13 @@
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, inDisplayOrder, loginClusters, loginKey, maskEmail, matchesQuery, mergeDaily, newAccountDir, nextReset, panelHint, panelLang, passedWindow, quotaPressure, remoteControlSummary, remoteControlTodo, reorder, 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,
@@ -18,6 +19,7 @@ const LOGIC = [
18
19
  mergeDaily,
19
20
  newAccountDir,
20
21
  nextReset,
22
+ panelFixes,
21
23
  panelHint,
22
24
  panelLang,
23
25
  passedWindow,
@@ -29,7 +31,9 @@ const LOGIC = [
29
31
  suggestAlias,
30
32
  suggestName,
31
33
  updatedAge,
34
+ pinnedIn,
32
35
  windowState,
36
+ withPinned,
33
37
  ]
34
38
  .map((fn) => fn.toString())
35
39
  .join('\n');
@@ -59,7 +63,7 @@ const MAIN = String.raw `
59
63
  silent: false, lastTry: 0, pending: false, fastUntil: 0, fastFamily: null,
60
64
  }
61
65
  // View state that is not saved: the search text, the attention filter, the Health check filter.
62
- 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 }
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 }
63
67
  const $ = (id) => document.getElementById(id)
64
68
 
65
69
  // ---------- preferences ----------
@@ -268,6 +272,9 @@ const MAIN = String.raw `
268
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',
269
273
  x: 'M6 6l12 12M18 6L6 18',
270
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',
271
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',
272
279
  grid: 'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z',
273
280
  list: 'M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01',
@@ -496,7 +503,7 @@ const MAIN = String.raw `
496
503
  try { const u = new URL(url); ok = u.protocol === 'https:' || u.protocol === 'http:' } catch {}
497
504
  return ok ? h('a', { class: cls, href: url, target: '_blank', rel: 'noopener noreferrer' }, text) : null
498
505
  }
499
- async function copyText(text) {
506
+ async function copyText(text, shown) {
500
507
  let ok = true
501
508
  try { await navigator.clipboard.writeText(text) } catch {
502
509
  const ta = h('textarea', { css: { position: 'fixed', opacity: '0' } })
@@ -506,7 +513,7 @@ const MAIN = String.raw `
506
513
  try { ok = document.execCommand('copy') } catch { ok = false }
507
514
  ta.remove()
508
515
  }
509
- 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) }))
510
517
  else toast(t('toast.copyFailed'), { kind: 'fail' })
511
518
  }
512
519
  function codeCopy(text) {
@@ -565,6 +572,7 @@ const MAIN = String.raw `
565
572
  function openMenu(anchor, items, label) {
566
573
  const el = h('div', { class: 'menu', role: 'menu', 'aria-label': label },
567
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)
568
576
  : h('button', { class: 'menu-item', type: 'button', role: 'menuitem', tabindex: '-1', disabled: it.disabled, onclick: () => { closeMenu(true); it.run() } },
569
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)))
570
578
  el.addEventListener('keydown', menuKeys)
@@ -612,24 +620,81 @@ const MAIN = String.raw `
612
620
  window.addEventListener('resize', () => closeMenu())
613
621
  window.addEventListener('scroll', () => closeMenu(), { passive: true })
614
622
 
615
- 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) {
616
625
  const run = 'sideby run ' + a.ref
617
- const items = [
626
+ return [
618
627
  { icon: 'terminal', label: t('menu.copyRun'), hint: run, run: () => copyText(run) },
619
628
  ...(a.aliases || []).slice(0, 2).map((x) => ({ icon: 'terminal', label: t('menu.copyAlias'), hint: x, run: () => copyText(x) })),
620
- 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,
621
- browserFor(a) ? { icon: 'terminal', label: t('br.open'), disabled: Boolean(ui.browserRef), run: () => openBrowserDialog(a) } : null,
622
- a.kind !== 'api' ? { icon: 'key', label: t('menu.copyLogin'), hint: 'sideby login ' + a.ref, run: () => copyText('sideby login ' + a.ref) } : null,
623
629
  a.dir ? { icon: 'folder', label: t('menu.copyDir'), hint: a.dir, run: () => copyText(a.dir) } : null,
624
- a.identity && a.identity.email ? { icon: 'mail', label: t('menu.copyEmail'), hint: emailText(a.identity.email), run: () => copyText(a.identity.email) } : null,
625
- '-',
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 [
626
638
  a.modelInfo && a.modelInfo.settable ? { icon: 'terminal', label: t('menu.model'), run: () => openModel(a) } : null,
627
639
  family(a.family).tuning ? { icon: 'check', label: t('menu.tune'), run: () => openTune(a) } : null,
628
640
  { icon: 'check', label: t('menu.check'), disabled: S.checking || S.fixing || Boolean(ui.checkingRef), run: () => checkAccount(a.ref) },
629
- BOOT.readOnly ? null : { icon: 'userPlus', label: t('menu.like'), run: () => openCreate({ family: a.family, api: a.kind === 'api' }) },
630
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)]
631
661
  openMenu(anchor, items, t('menu.label', { ref: a.ref }))
632
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
+ }
633
698
  function moreButton(a) {
634
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'))
635
700
  b.addEventListener('click', () => { if (menu.anchor === b) closeMenu(); else accountMenu(a, b) })
@@ -663,7 +728,7 @@ const MAIN = String.raw `
663
728
  function whyOf(a, now) {
664
729
  const m = attentionWhy(a, healthOf(a.ref), now, WARN)
665
730
  if (m && m.key === 'why.not-installed' && family(a.family).brokenBin) return t('why.broken')
666
- 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) : ''
667
732
  }
668
733
  function setupFor(fam) { return S.state && S.state.quotaSetups.find((s) => s.family === fam) }
669
734
 
@@ -740,7 +805,10 @@ const MAIN = String.raw `
740
805
  for (const r of rs) counts[r] = (counts[r] || 0) + 1
741
806
  }
742
807
  const order = ['error', 'health-fail', 'signed-out', 'not-installed', 'quota-high', 'health-warn']
743
- 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
744
812
  const usages = list.filter((a) => a.usage && a.usage.status === 'ok')
745
813
  const total = usages.reduce((s, a) => s + (Number(a.usage.totalTokens) || 0), 0)
746
814
  const sessions = usages.reduce((s, a) => s + (Number(a.usage.sessions) || 0), 0)
@@ -749,12 +817,12 @@ const MAIN = String.raw `
749
817
  patch(box, [
750
818
  stat(t('stat.accounts'), String(list.length), dots([tn('stat.families', famCount), apiCount ? tn('stat.api', apiCount) : null])),
751
819
  need
752
- ? 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() } })
753
821
  : stat(t('stat.attention'), '0', needSub, null, { cls: 'stat-ok' }),
754
822
  stat(t('stat.tokens', { n: BOOT.usageDays }), tokens(total), tn('stat.sessions', sessions), spark(daily, 'spark-lg')),
755
823
  stat(h('span', { class: 'label-tip' }, t('stat.nextReset'), tip('age')),
756
824
  nr ? t('time.in', { d: formatDuration(nr.at - now, lang) }) : '—',
757
- 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')),
758
826
  ])
759
827
  }
760
828
 
@@ -780,21 +848,29 @@ const MAIN = String.raw `
780
848
  })
781
849
  tb.family = h('select', { class: 'tb-select', 'aria-label': t('tb.family') })
782
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".
783
852
  tb.sort = h('select', { class: 'tb-select', 'aria-label': t('tb.sort') },
784
- 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')))
785
855
  tb.sort.value = prefs.sort
786
- tb.sort.addEventListener('change', () => { prefs.sort = tb.sort.value; savePrefs(); renderAccounts() })
787
- tb.reset = h('button', { class: 'btn btn-sm tb-reset', type: 'button', title: t('order.resetHint'), onclick: resetOrder }, t('order.resetButton'))
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
+ })
788
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')))
789
866
  display.addEventListener('click', () => openPopover(display, 'display', t('tb.display'), displayPanel, 'end'))
790
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')))
791
868
  setKids($('toolbar'), [
792
869
  h('label', { class: 'search' }, icon('search', 'search-ic'), tb.search, h('kbd', { class: 'kbd', 'aria-hidden': 'true' }, '/')),
793
- h('div', { class: 'tools' }, tb.family, tb.sort, tb.reset, display),
870
+ h('div', { class: 'tools' }, tb.family, tb.sort, display),
794
871
  create,
795
872
  ])
796
873
  renderFamilyFilter()
797
- renderOrderReset()
798
874
  if (hadFocus) tb.search.focus()
799
875
  }
800
876
  // "Display": the view settings people change rarely, one popover instead of three buttons in the toolbar.
@@ -806,7 +882,7 @@ const MAIN = String.raw `
806
882
  h('div', { class: 'dp-row' }, h('span', { class: 'dp-label' }, t('tb.quota')),
807
883
  seg(t('tb.quota'), [['used', t('tb.used')], ['left', t('tb.left')]], prefs.quota, (v) => { prefs.quota = v; redo() })),
808
884
  h('div', { class: 'dp-row' }, h('span', { class: 'dp-label' }, t('tb.view')),
809
- 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() })),
810
886
  emails ? h('label', { class: 'dp-row dp-check' },
811
887
  h('input', { type: 'checkbox', checked: prefs.hideEmail, onchange: (e) => { prefs.hideEmail = e.target.checked; redo() } }),
812
888
  h('span', null, t('tb.hideEmails'))) : null)
@@ -838,6 +914,8 @@ const MAIN = String.raw `
838
914
  const KNOWN_WINDOWS = { '5h': 300, '7d': 10080 }
839
915
  // A window's column: the models it covers when it covers only some (Cursor's "Other models"), else its length.
840
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 }
841
919
  // One group's columns: the windows its accounts report, shortest first; 5h and 7d when none reports any yet.
842
920
  function quotaColumns(list) {
843
921
  const mins = {}
@@ -855,14 +933,15 @@ const MAIN = String.raw `
855
933
  const left = prefs.quota === 'left'
856
934
  const shown = left ? 100 - st.pct : st.pct
857
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) })
858
937
  const title = [passed ? t('quota.resetNoData', { date: day(passed.since) }) : t('quota.resetsAt', { at: clock(w.resetsAt) }), asOf].filter(Boolean).join(' · ')
859
938
  return h('div', { class: 'meter lvl-' + st.level + (stale || passed ? ' lvl-stale' : ''), title },
860
939
  h('div', { class: 'm-top' },
861
- h('span', { class: 'm-label' }, winKey(w)),
940
+ h('span', { class: 'm-label' }, winTitle(winKey(w))),
862
941
  h('span', { class: 'm-pct' }, shown + '%', h('span', { class: 'm-unit' }, ' ' + t(left ? 'quota.unitLeft' : 'quota.unitUsed')),
863
- stale ? h('span', { class: 'm-asof', role: 'img', 'aria-label': asOf }, icon('clock')) : null),
864
- h('span', { class: 'm-reset' + (passed ? ' m-passed' : '') }, passed ? t('quota.resetShort') : countdown(w.resetsAt, now))),
865
- h('div', { class: 'bar', role: 'meter', 'aria-label': t(left ? 'quota.meterLeft' : 'quota.meterUsed', { label: 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 + '%' } })))
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 + '%' } })))
866
945
  }
867
946
  // A column whose window this account does not report right now, such as a 5h window that has not started.
868
947
  function noMeter(label) {
@@ -901,8 +980,10 @@ const MAIN = String.raw `
901
980
  return h('div', { class: 'quota' + (stale ? ' is-stale' : ''), title },
902
981
  wins.length ? cols.map((c) => {
903
982
  const w = wins.find((x) => winKey(x) === c)
904
- return w ? meter(w, now, stale, q.observedAt) : noMeter(c)
905
- }) : h('div', { class: 'q-note' }, t('quota.noWindows')))
983
+ return w ? meter(w, now, stale, q.observedAt) : noMeter(winTitle(c))
984
+ }) : h('div', { class: 'q-note' }, t('quota.noWindows')),
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)
906
987
  }
907
988
  function usageBlock(a) {
908
989
  const u = a.usage
@@ -948,9 +1029,13 @@ const MAIN = String.raw `
948
1029
  const lvl = r.includes('health-fail') ? 'fail' : r.includes('quota-high') || r.includes('health-warn') ? 'warn' : ''
949
1030
  if (!lvl) return null
950
1031
  const why = whyOf(subject, Date.now())
951
- 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)
952
1035
  }
953
- 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)
954
1039
  return h('div', { class: 'ident-name' },
955
1040
  h('span', { class: 'name', title: a.name }, a.name),
956
1041
  attentionDot(a),
@@ -958,9 +1043,10 @@ const MAIN = String.raw `
958
1043
  const args = aliasArgsOf(a, x)
959
1044
  return h('span', { class: 'tag tag-alias', title: args ? t('acct.aliasArgs', { args: args.join(' ') }) : t('acct.alias') }, x)
960
1045
  }),
1046
+ members ? h('span', { class: 'tag tag-login', title: t('login.explain') }, t('login.dirs', { n: members.length })) : null,
961
1047
  a.kind === 'api' ? h('span', { class: 'tag tag-warn', title: t('tip.api.body') }, t('acct.api')) : null,
962
- isNext(a)
963
- ? 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)
964
1050
  : null,
965
1051
  a.login === 'logged-out' && a.kind !== 'api'
966
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'))
@@ -1123,7 +1209,7 @@ const MAIN = String.raw `
1123
1209
  }
1124
1210
  // What a row or card shows depends on more than the account (health, view preferences, language); the
1125
1211
  // fingerprint covers what its click handlers use.
1126
- 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)]) }
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)]) }
1127
1213
  // Whether the row sits in a login group (spec §3.21): its first row then shows the quota for all of them.
1128
1214
  function inLogin(a) { return ui.loginRefs.has(a.ref) }
1129
1215
  // The group's first row, which shows the quota for all of them.
@@ -1196,11 +1282,13 @@ const MAIN = String.raw `
1196
1282
  const btn = el.querySelector('.row-toggle')
1197
1283
  if (btn) syncAttrs(btn, rowToggle(a))
1198
1284
  el.classList.toggle('row-open', open)
1199
- 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)
1200
1288
  let detail = rowDetails(el)[0]
1201
1289
  if (open && !detail) {
1202
- detail = rowDetail(a, Boolean(emailLine(a)))
1203
- const more = el.querySelector(':scope > .more')
1290
+ detail = ms ? loginDetail(ms) : rowDetail(a, Boolean(emailLine(a)))
1291
+ const more = el.querySelector(':scope > .row-end')
1204
1292
  el.insertBefore(detail, more ? more.nextSibling : null)
1205
1293
  detail.style.opacity = '0'
1206
1294
  }
@@ -1239,33 +1327,87 @@ const MAIN = String.raw `
1239
1327
  for (const p of parts) p.style.opacity = ''
1240
1328
  }
1241
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
+ }
1242
1343
  function isNext(a) { return Boolean(S.state && S.state.handoff && S.state.handoff[a.family] === a.ref) }
1243
1344
  function row(a, now, cols) {
1345
+ if (ui.arrange) return arrangeRow(a, [a])
1244
1346
  const open = isOpen(a)
1245
1347
  const email = emailLine(a)
1246
- const lead = loginLead(a)
1247
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) },
1248
- h('div', { class: 'ident' }, dragHandle('account', a.family, a.name, a.name), rowToggle(a), badge(a.family, 'sm'), h('div', { class: 'ident-text' }, nameLine(a), email || subLine(a))),
1249
- lead && lead !== a.ref ? sharedQuota(lead) : 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),
1250
1351
  lastUsed(a),
1251
- moreButton(a),
1352
+ h('div', { class: 'row-end' }, runButton(a), pinButton(a, a.name), moreButton(a)),
1252
1353
  open ? rowDetail(a, Boolean(email)) : null,
1253
1354
  notes(a))
1254
1355
  }
1255
- // Directories signed in as one login (spec §3.21): the vendor counts one quota, so it shows once, on the group's
1256
- // first row; every directory stays an ordinary row (name, email, browser, model, tokens, Health), and a rail on
1257
- // the left ties the rows together.
1258
- function loginGroup(members, now, cols) {
1259
- const names = members.map((a) => a.name).join(', ')
1260
- // The first row as sorted carries the quota.
1261
- for (const a of members) ui.loginRefs.set(a.ref, members[0].ref)
1262
- return h('div', { class: 'login', role: 'listitem', 'aria-label': t('login.label', { names }), title: t('login.hint', { names }), 'data-key': loginKey(members.map((a) => a.ref)) },
1263
- h('div', { class: 'login-rows', role: 'list', 'data-key': 'rows', 'data-patch': '' }, members.map((a) => row(a, now, cols))))
1264
- }
1265
- // The quota cell of a group's other rows: the numbers are the first row's.
1266
- function sharedQuota(lead) {
1267
- const name = refName(lead)
1268
- return h('div', { class: 'quota' }, h('div', { class: 'q-note q-shared', title: t('login.sharedHint', { name }) }, icon('arrowUp'), t('login.shared', { name })))
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
+ })))
1269
1411
  }
1270
1412
  // What a click on a row opens: how it runs (ref, model and effort, a shared login), its tokens and its Health.
1271
1413
  function sameLoginNote(a) {
@@ -1286,7 +1428,7 @@ const MAIN = String.raw `
1286
1428
  h('header', { class: 'card-head' },
1287
1429
  badge(a.family, 'sm'),
1288
1430
  h('div', { class: 'ident-text' }, nameLine(a), emailLine(a), subLine(a), sameLoginNote(a)),
1289
- moreButton(a)),
1431
+ h('div', { class: 'row-end' }, pinButton(a, a.name), moreButton(a))),
1290
1432
  notes(a),
1291
1433
  quotaBlock(a, now, cols),
1292
1434
  h('footer', { class: 'card-foot' }, usageBlock(a), h('div', { class: 'card-side' }, lastUsed(a), healthChip(a))))
@@ -1298,7 +1440,7 @@ const MAIN = String.raw `
1298
1440
  const titled = list.some((a) => a.quota && a.quota.status === 'ok')
1299
1441
  return h('div', { class: 'col-head', 'aria-hidden': 'true', 'data-key': 'cols' },
1300
1442
  h('span', { class: 'ch-id' }, t('col.account')),
1301
- 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),
1302
1444
  h('span', { class: 'ch-last' }, t('col.last')),
1303
1445
  h('span', { class: 'ch-more' }))
1304
1446
  }
@@ -1399,30 +1541,43 @@ const MAIN = String.raw `
1399
1541
  }
1400
1542
  function appGroup(list) {
1401
1543
  const app = list[0].app
1402
- 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
1403
1547
  const bodyId = 'grp-app-' + app.replace(/[^a-zA-Z0-9_-]/g, '_')
1404
- const head = h('div', { class: 'group-head', 'data-key': 'head' },
1405
- 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) },
1406
1551
  icon('chevron', 'chev'), appBadge(), h('span', { class: 'group-title' }, app),
1407
1552
  h('span', { class: 'group-count' }, String(list.length))),
1408
1553
  h('div', { class: 'group-meta' },
1409
1554
  h('span', { class: 'muted small label-tip' }, t('app.apart'), tipRaw(app, t('app.apartTip', { app })))),
1410
1555
  list[0].installed ? null : h('div', { class: 'group-meta' }, h('span', { class: 'tag tag-warn' }, t('acct.notInstalled'))))
1411
- const body = collapsed ? null : h('div', { class: 'rows', id: bodyId, role: 'list', 'data-key': 'rows', 'data-patch': '' }, list.map(appRow))
1412
- return h('section', { class: 'group group-list' + (collapsed ? ' is-collapsed' : ''), 'aria-label': app, 'data-key': 'app:' + app, 'data-patch': '' },
1413
- 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'))))
1414
1570
  }
1415
1571
  // Shown with the account groups unless a family filter or the attention filter is on; the search matches names.
1416
- function appGroups() {
1417
- 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
1418
1576
  const q = ui.query.trim().toLowerCase()
1419
- const out = []
1420
- for (const app of new Set(appProfiles().map((p) => p.app))) {
1421
- const all = appProfiles().filter((p) => p.app === app)
1422
- const list = q && !app.toLowerCase().includes(q) ? all.filter((p) => p.ref.toLowerCase().includes(q)) : all
1423
- if (list.length) out.push(appGroup(list))
1424
- }
1425
- 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
1426
1581
  }
1427
1582
  async function openApp(ref) {
1428
1583
  if (ui.appBusy) return
@@ -1468,11 +1623,13 @@ const MAIN = String.raw `
1468
1623
  const setup = setupFor(fam)
1469
1624
  const off = list.some((a) => a.quota && a.quota.status === 'unavailable' && a.quota.reason === 'not-enabled')
1470
1625
  const bodyId = 'grp-' + fam.replace(/[^a-zA-Z0-9_-]/g, '_')
1471
- const cards = prefs.view === 'cards'
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
1472
1629
  if (!cards) ui.order.units[fam] = loginClusters(list).map((x) => Array.isArray(x) ? x.map((a) => a.name) : [x.name])
1473
- const head = h('div', { class: 'group-head', 'data-key': 'head' },
1630
+ const head = h('div', { class: 'group-head', 'data-key': ui.arrange ? 'head-arrange' : 'head' },
1474
1631
  dragHandle('family', fam, fam, info.title),
1475
- 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) },
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) },
1476
1633
  icon('chevron', 'chev'), badge(fam, 'sm'), h('span', { class: 'group-title' }, info.title),
1477
1634
  h('span', { class: 'group-count' }, list.length === total ? String(total) : list.length + ' / ' + total)),
1478
1635
  h('div', { class: 'group-meta' },
@@ -1484,11 +1641,11 @@ const MAIN = String.raw `
1484
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')))
1485
1642
  : null,
1486
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))
1487
- const body = collapsed ? null : cards
1644
+ const body = collapsedNow ? null : cards
1488
1645
  ? h('div', { class: 'grid', id: bodyId, 'data-key': 'cards', 'data-patch': '' }, list.map((a) => card(a, now, cols)))
1489
- : 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)))
1490
- return h('section', { class: 'group' + (cards ? ' group-cards' : ' group-list') + (collapsed ? ' is-collapsed' : ''), 'aria-label': info.title, 'data-key': 'g:' + fam, 'data-fam': fam, 'data-patch': '' },
1491
- 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)
1492
1649
  }
1493
1650
  function emptyState() {
1494
1651
  const fams = S.state.families.slice().sort((x, y) => Number(y.installed) - Number(x.installed))
@@ -1499,7 +1656,8 @@ const MAIN = String.raw `
1499
1656
  h('p', { class: 'muted' }, t('empty.body')),
1500
1657
  fams.length ? h('ul', { class: 'host-list' }, fams.map((f) => h('li', null, badge(f.id, 'sm'), h('span', null, f.title),
1501
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')),
1502
- 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'))))
1503
1661
  }
1504
1662
  function clearFilters() {
1505
1663
  ui.query = ''
@@ -1523,24 +1681,142 @@ const MAIN = String.raw `
1523
1681
  h('p', null, S.loadError.message),
1524
1682
  h('button', { class: 'btn', type: 'button', onclick: () => load({ check: !S.report }) }, icon('refresh'), t('btn.retry')))
1525
1683
  }
1526
- // ---------- Display Order (spec §3.25) ----------
1684
+ // ---------- Display Order and Pinned Accounts (spec §3.25) ----------
1685
+ const PIN_MAX = 3
1527
1686
  function displayOf() { return (S.state && S.state.display) || {} }
1528
1687
  // Every Family id in the Display Order, the ones only accounts name (a plugin that failed) last.
1529
1688
  function familyOrder() {
1530
1689
  const ids = S.state.families.map((f) => f.id)
1531
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)
1532
1693
  return inDisplayOrder(ids, (x) => x, displayOf().families)
1533
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) }
1534
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
+ }
1535
1710
  // Every account name of a Family, hidden ones too, in the Display Order.
1536
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))
1537
1713
  return sortAccounts(S.state.accounts.filter((a) => a.family === fam), 'manual', Date.now(), accountOrder(fam)).map((a) => a.name)
1538
1714
  }
1539
1715
  function ordered() { const d = displayOf(); return Boolean((d.families && d.families.length) || (d.accounts && Object.keys(d.accounts).length)) }
1540
- // A grip to drag a Family group or an account (a Same Login group moves as one) into a new place, or to move it
1541
- // with the arrow keys. Not in the card view, nor on a read-only Panel.
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.
1542
1818
  function dragHandle(kind, fam, key, name) {
1543
- if (BOOT.readOnly || prefs.view === 'cards') return null
1819
+ if (BOOT.readOnly || !ui.arrange) return null
1544
1820
  const label = t(kind === 'family' ? 'order.moveFamily' : 'order.moveAccount', { name })
1545
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'),
1546
1822
  onclick: (e) => e.stopPropagation(),
@@ -1557,7 +1833,7 @@ const MAIN = String.raw `
1557
1833
  },
1558
1834
  ondragstart: (e) => {
1559
1835
  const units = unitsOf(kind, fam)
1560
- ui.drag = { kind, fam, from: units.findIndex((u) => u.includes(key)), to: null }
1836
+ ui.drag = { kind, fam, from: units.findIndex((u) => u.includes(key)), to: null, other: null, dropped: false }
1561
1837
  e.dataTransfer.effectAllowed = 'move'
1562
1838
  try { e.dataTransfer.setData('text/plain', name) } catch {}
1563
1839
  const el = unitElement(kind, fam, key)
@@ -1567,11 +1843,10 @@ const MAIN = String.raw `
1567
1843
  }, icon('grip'))
1568
1844
  }
1569
1845
  function unitsOf(kind, fam) { return kind === 'family' ? ui.order.families.map((f) => [f]) : (ui.order.units[fam] || []) }
1570
- // The element that moves: a Family's group, an account's row, or the Same Login group the row is in.
1846
+ // The element that moves: a Family's group, or an account's row (a Same Login is one row).
1571
1847
  function unitElement(kind, fam, key) {
1572
1848
  if (kind === 'family') return document.querySelector('#accounts > section[data-fam="' + CSS.escape(fam) + '"]')
1573
- const rowEl = document.querySelector('#accounts .row[data-ref="' + CSS.escape(fam + ':' + key) + '"]')
1574
- return rowEl && (rowEl.closest('.login') || rowEl)
1849
+ return document.querySelector('#accounts .row[data-ref="' + CSS.escape(fam + ':' + key) + '"]')
1575
1850
  }
1576
1851
  // Where a drop at the pointer lands: the list of places it can go and the index among them, or null outside.
1577
1852
  function dropTarget(e) {
@@ -1582,7 +1857,9 @@ const MAIN = String.raw `
1582
1857
  return els.length ? { els, at: slotAt(els, e.clientY) } : null
1583
1858
  }
1584
1859
  const sec = e.target.closest && e.target.closest('#accounts > section[data-fam]')
1585
- if (!sec || sec.getAttribute('data-fam') !== d.fam) return null
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
1586
1863
  const box = sec.querySelector('.rows')
1587
1864
  if (!box) return null
1588
1865
  const els = Array.from(box.children)
@@ -1597,9 +1874,11 @@ const MAIN = String.raw `
1597
1874
  }
1598
1875
  function clearDropMarks() { for (const el of document.querySelectorAll('.drop-before, .drop-after')) el.classList.remove('drop-before', 'drop-after') }
1599
1876
  function endDrag() {
1877
+ const d = ui.drag
1600
1878
  clearDropMarks()
1601
1879
  for (const el of document.querySelectorAll('.is-dragging')) el.classList.remove('is-dragging')
1602
1880
  ui.drag = null
1881
+ if (d && d.other && !d.dropped) toast(t('order.sameFamily', { title: family(d.fam).title }))
1603
1882
  if (S.pending) { S.pending = false; renderAll() }
1604
1883
  }
1605
1884
  function onDragOver(e) {
@@ -1617,57 +1896,53 @@ const MAIN = String.raw `
1617
1896
  const d = ui.drag
1618
1897
  if (!d || !dropTarget(e)) return
1619
1898
  e.preventDefault()
1899
+ d.dropped = true
1620
1900
  const to = d.to
1621
1901
  endDrag()
1622
1902
  if (to !== null && to !== d.from) moveUnit(d.kind, d.fam, d.from, to)
1623
1903
  }
1624
- // Records the new Display Order: shown at once, written to the config, and put back if the write fails.
1625
- async function moveUnit(kind, fam, from, to, opts) {
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) {
1626
1907
  opts = opts || {}
1627
1908
  const units = unitsOf(kind, fam)
1628
1909
  if (from < 0 || to < 0 || from >= units.length || to >= units.length) return
1629
- if (from === to) { if (opts.label) say(t('order.position', { name: opts.label, n: from + 1, total: units.length })); return }
1630
1910
  const before = displayOf()
1631
- const next = { families: before.families ? before.families.slice() : [], accounts: Object.assign({}, before.accounts || {}) }
1911
+ const next = copyDisplay(before)
1912
+ let place = to
1913
+ let first = 0
1914
+ let total = units.length
1632
1915
  if (kind === 'family') next.families = reorder(familyOrder(), units, from, to)
1633
- else next.accounts[fam] = reorder(allNames(fam), units, from, to)
1634
- S.state.display = next
1635
- // Moving an account says "my order" from now on; Families follow it in every sort.
1636
- if (kind === 'account' && prefs.sort !== 'manual') { prefs.sort = 'manual'; savePrefs(); if (tb.sort) tb.sort.value = 'manual' }
1637
- renderAccounts()
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)
1638
1932
  if (opts.focus) {
1639
- const btn = document.querySelector('#accounts .drag-handle[data-move="' + kind + '"][data-fam="' + CSS.escape(fam) + '"][data-name="' + CSS.escape(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)
1640
1937
  if (btn) btn.focus({ preventScroll: false })
1641
- say(t('order.position', { name: opts.label, n: to + 1, total: units.length }))
1642
- }
1643
- try {
1644
- const r = await api('/api/display-order', { display: next })
1645
- S.state.display = r.display
1646
- } catch (e) {
1647
- S.state.display = before
1648
- renderAccounts()
1649
- toast(t('order.failed', { msg: e.message }), { kind: 'fail' })
1938
+ where()
1650
1939
  }
1651
1940
  }
1941
+ // Forgets the order of Families and accounts; pins stay.
1652
1942
  async function resetOrder() {
1653
1943
  const before = displayOf()
1654
- S.state.display = {}
1655
- if (prefs.sort === 'manual') { prefs.sort = 'pressure'; savePrefs(); if (tb.sort) tb.sort.value = 'pressure' }
1656
- renderAccounts()
1657
- try {
1658
- const r = await api('/api/display-order', { display: null })
1659
- S.state.display = r.display
1660
- toast(t('order.reset'))
1661
- } catch (e) {
1662
- S.state.display = before
1663
- renderAccounts()
1664
- toast(t('order.failed', { msg: e.message }), { kind: 'fail' })
1665
- }
1666
- }
1667
- // The toolbar's "Default order" button, shown once the user has put things in an order of their own.
1668
- function renderOrderReset() {
1669
- if (!tb.reset) return
1670
- tb.reset.hidden = BOOT.readOnly || !ordered()
1944
+ const pinned = before.pinned && Object.keys(before.pinned).length ? before.pinned : null
1945
+ if (await saveDisplay(before, pinned ? { pinned } : {})) toast(t('order.reset'))
1671
1946
  }
1672
1947
  // Words for screen readers only, such as the new place of something moved with the keyboard.
1673
1948
  let live = null
@@ -1697,25 +1972,29 @@ const MAIN = String.raw `
1697
1972
  const apps = appGroups()
1698
1973
  if (!shown.length && !apps.length) { patch(box, noMatch()); return }
1699
1974
  ui.loginRefs = new Map()
1700
- if (prefs.view !== 'cards')
1701
- for (const x of loginClusters(shown)) if (Array.isArray(x)) for (const a of x) ui.loginRefs.set(a.ref, x[0].ref)
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)
1702
1977
  // Families in the Display Order (spec §3.25) whatever the sort; each group gets the quota columns it uses.
1703
1978
  // Every group is as wide as the one with the most windows, so the columns after them line up.
1704
1979
  ui.order = { families: [], units: {} }
1705
1980
  const groups = []
1706
1981
  let width = 1
1707
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
+ }
1708
1988
  const list = shown.filter((a) => a.family === fam)
1709
1989
  if (!list.length) continue
1710
1990
  ui.order.families.push(fam)
1711
- const sorted = sortAccounts(list, prefs.sort, now, accountOrder(fam))
1991
+ const sorted = withPinned(sortAccounts(list, prefs.sort, now, accountOrder(fam)), pinnedOf(fam))
1712
1992
  const cols = quotaColumns(sorted)
1713
1993
  width = Math.max(width, cols.length)
1714
1994
  groups.push(group(fam, sorted, all.filter((a) => a.family === fam).length, now, cols))
1715
1995
  }
1716
1996
  box.style.setProperty('--qn', String(width))
1717
- renderOrderReset()
1718
- patch(box, groups.concat(apps))
1997
+ patch(box, (ui.arrange ? [arrangeBar()] : []).concat(groups))
1719
1998
  if (ui.fade && !REDUCED) {
1720
1999
  box.classList.add('view-in')
1721
2000
  setTimeout(() => box.classList.remove('view-in'), 400)
@@ -1787,7 +2066,17 @@ const MAIN = String.raw `
1787
2066
  renderCheckup()
1788
2067
  $('checkup').scrollIntoView({ behavior: REDUCED ? 'auto' : 'smooth', block: 'start' })
1789
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".
1790
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
1791
2080
  return h('li', { class: 'finding lvl-' + f.level },
1792
2081
  h('span', { class: 'dot', title: f.level }),
1793
2082
  h('div', { class: 'finding-body' },
@@ -1795,12 +2084,20 @@ const MAIN = String.raw `
1795
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),
1796
2085
  h('span', { class: 'muted small mono' }, f.item),
1797
2086
  f.fixable ? h('span', { class: 'chip chip-sm chip-accent' }, t('health.autoFix')) : null,
1798
- f.source && f.source !== 'core' ? h('span', { class: 'muted small' }, t('health.from', { source: f.source })) : null),
1799
- h('p', null, f.message),
1800
- 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))
1801
2095
  }
1802
2096
  function renderCheckup() {
1803
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
1804
2101
  const all = findingsNow()
1805
2102
  const ref = ui.healthRef
1806
2103
  const findings = all && ref ? all.filter((f) => f.account === ref) : all
@@ -1808,7 +2105,11 @@ const MAIN = String.raw `
1808
2105
  const busy = S.checking || S.fixing
1809
2106
  const fixable = all ? all.filter((f) => f.fixable).length : 0
1810
2107
  // Say why Fix all is off, unless a running check or fix already explains it.
1811
- 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') }) : ''
1812
2113
  const status = S.checking ? t('health.running') : S.fixing ? t('health.fixing') : at ? t('health.last', { ago: ago(at) }) : t('health.never')
1813
2114
  const out = [h('div', { class: 'section-head' },
1814
2115
  h('div', null,
@@ -1817,6 +2118,7 @@ const MAIN = String.raw `
1817
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')) }))),
1818
2119
  h('div', { class: 'actions' },
1819
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,
1820
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 },
1821
2123
  S.fixing ? spinner() : null, S.fixing ? t('health.fixing') : t('fix.all') + (fixable ? ' (' + fixable + ')' : ''))))]
1822
2124
  if (ref) out.push(h('div', { class: 'filter-bar' },
@@ -2011,10 +2313,12 @@ const MAIN = String.raw `
2011
2313
  if (g.outOfRange) rows.push(h('p', { class: 'note note-warn' }, t('tune.outOfRange', { size: tokens(g.current) })))
2012
2314
  if (!BOOT.readOnly) {
2013
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) }))))
2014
- 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' },
2015
2318
  h('b', { class: 'small' }, t('tune.setting')),
2016
2319
  h('div', { class: 'row-actions' }, sel,
2017
- 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)
2018
2322
  rows.push(useBox(g, false))
2019
2323
  if (g.undo && !(mine && (useUi.step === 'done' || useUi.step === 'undone')))
2020
2324
  rows.push(h('p', { class: 'small tune-undo' }, t('tune.applied', { prev: tuneSize(g.undo.previous) }), ' ',
@@ -2109,7 +2413,9 @@ const MAIN = String.raw `
2109
2413
  setKids(nav, h('div', { class: 'seg', role: 'presentation' }, tabs.map(([id, label]) =>
2110
2414
  h('button', { class: 'seg-btn', type: 'button', role: 'tab', 'aria-selected': curPage === id ? 'true' : 'false', onclick: () => setPage(id) }, label))))
2111
2415
  const away = curPage !== 'accounts' && tabs.some(([id]) => id === curPage)
2112
- 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')
2113
2419
  const onTune = away && curPage === 'tuning'
2114
2420
  const onHandoff = away && curPage === 'handoff'
2115
2421
  $('tuning-page').hidden = !onTune
@@ -2254,9 +2560,12 @@ const MAIN = String.raw `
2254
2560
  h('p', { class: 'muted small' }, t('nf.intro')),
2255
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'))),
2256
2562
  h('div', { class: 'field' }, h('label', { class: 'field', for: 'nf-name' }, h('span', null, t('nf.name')), e.name), e.suggest),
2257
- 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),
2258
- 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')),
2259
- 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),
2260
2569
  e.error,
2261
2570
  e.errNote = nf.error ? errorNote(nf.error, create, nf.error.code === 'create-refused' && !nf.api ? t('nf.refusedApi') : null) : null)
2262
2571
  setKids(el, dialogFrame(t('nf.title'), null, body, [
@@ -3019,6 +3328,8 @@ const MAIN = String.raw `
3019
3328
  const out = []
3020
3329
  if (!LANG_FIXED) out.push('lang')
3021
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')
3022
3333
  if (S.state && rcSummary()) out.push('rc')
3023
3334
  if (S.state && ahState()) out.push('ah')
3024
3335
  if (S.state && (S.state.quotaChecks || []).length) out.push('checks')
@@ -3044,7 +3355,19 @@ const MAIN = String.raw `
3044
3355
  h('span', { class: 'menu-label' }, label), h('span', { class: 'chip chip-sm ' + cls }, state), icon('chevron'))
3045
3356
  if (secs.includes('lang')) out.push(seg(t('set.lang'), [['en', 'English'], ['zh', '中文']], lang, setLang))
3046
3357
  if (secs.includes('theme')) out.push(seg(t('set.theme'), CHOICES.theme.map((v) => [v, t('set.theme.' + v)]), prefs.theme, setTheme))
3047
- 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
+ }
3048
3371
  if (secs.includes('rc')) {
3049
3372
  const sum = rcSummary()
3050
3373
  out.push(link(t('rc.button'), t('rc.state.' + sum), sum === 'on' ? 'chip-warn' : sum === 'off' ? 'chip-ok' : '', openRc))
@@ -3089,6 +3412,25 @@ const MAIN = String.raw `
3089
3412
  applyTheme()
3090
3413
  refreshPopover('settings')
3091
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
+ }
3092
3434
  function openRc() {
3093
3435
  if (!S.state || !rcSummary()) return
3094
3436
  rc.target = null
@@ -3226,6 +3568,17 @@ const MAIN = String.raw `
3226
3568
  // build and reloads (a background Panel restarts itself, a Portal loads the new code by itself).
3227
3569
  let up = null
3228
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 }))
3229
3582
  function renderUpdateButton() {
3230
3583
  const b = $('update')
3231
3584
  const u = updState()
@@ -3234,27 +3587,29 @@ const MAIN = String.raw `
3234
3587
  const v = $('ver-top')
3235
3588
  v.hidden = !BOOT.version
3236
3589
  if (!v.hidden) {
3237
- const newer = !!(u && u.available)
3590
+ const newer = !!(u && (u.available || skillDue(u)))
3238
3591
  v.textContent = verLabel()
3239
3592
  v.classList.toggle('has-update', newer)
3240
3593
  v.disabled = !BOOT.updates
3241
- 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 })
3242
3596
  v.title = tip
3243
3597
  if (tip) v.setAttribute('aria-label', tip)
3244
3598
  else v.removeAttribute('aria-label')
3245
3599
  }
3246
- b.hidden = !(u && u.available)
3600
+ b.hidden = !(u && (u.available || skillDue(u)))
3247
3601
  if (!b.hidden) {
3248
- setKids(b, [icon('arrowUp'), h('span', null, t('upd.label', { v: u.latest }))])
3249
- b.setAttribute('aria-label', t('upd.labelLong', { v: u.latest, cur: u.current }))
3250
- 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
3251
3606
  }
3252
3607
  if (dlg.kind === 'update' && up && !up.busy) renderUpdate()
3253
3608
  if (!vp.checking) renderVerPop()
3254
3609
  }
3255
3610
  function openUpdate() {
3256
3611
  if (!BOOT.updates) return
3257
- 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 }
3258
3613
  showDialog('update')
3259
3614
  renderUpdate()
3260
3615
  }
@@ -3281,6 +3636,27 @@ const MAIN = String.raw `
3281
3636
  dlg.busy = false
3282
3637
  renderUpdate()
3283
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
+ }
3284
3660
  // The version pill opens a small popover, not the dialog: the version, whether it is the newest, "Check for
3285
3661
  // updates" in place, and the way into the dialog when there is something to do (update, Auto Update).
3286
3662
  const vp = { checking: false, checked: false, error: null }
@@ -3304,9 +3680,9 @@ const MAIN = String.raw `
3304
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) : '' })))
3305
3681
  let status
3306
3682
  if (!u) status = t('upd.loading')
3307
- else if (u.available) status = null
3683
+ else if (u.available || skillOld(u)) status = null
3308
3684
  else if (vp.checked && u.latest) status = t('upd.latestNow')
3309
- else if (u.checksOff) status = t('upd.checksOff', { why: u.checksOff })
3685
+ else if (u.checksOff) status = offText(u)
3310
3686
  else if (u.latest) status = t('upd.latest') + (u.checkedAt ? ' · ' + t('upd.checked', { ago: ago(u.checkedAt) }) : '')
3311
3687
  else status = t('upd.notYet')
3312
3688
  if (status) kids.push(h('p', { class: 'muted small' }, status))
@@ -3314,12 +3690,15 @@ const MAIN = String.raw `
3314
3690
  kids.push(h('p', { class: 'small' }, t('upd.available', { v: u.latest }), ' ',
3315
3691
  h('a', { href: u.releaseNotes, target: '_blank', rel: 'noopener noreferrer' }, t('upd.notes'))))
3316
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'))))
3317
3696
  } else if (!BOOT.readOnly) {
3318
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 },
3319
3698
  vp.checking ? spinner() : icon('refresh'), vp.checking ? t('upd.checking') : t('upd.check'))))
3320
3699
  }
3321
3700
  if (vp.error) kids.push(errorNote(vp.error, null))
3322
- if (u && !BOOT.readOnly)
3701
+ if (u && !BOOT.readOnly && !isLocal(u))
3323
3702
  kids.push(h('p', { class: 'muted small popover-foot' }, t(u.auto ? 'upd.autoIsOn' : 'upd.autoIsOff'), ' · ',
3324
3703
  h('button', { class: 'link-btn', type: 'button', onclick: toDialog }, t('upd.settings'))))
3325
3704
  return kids
@@ -3381,13 +3760,17 @@ const MAIN = String.raw `
3381
3760
  up.autoBusy = false
3382
3761
  renderUpdate()
3383
3762
  }
3384
- // 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.
3385
3765
  function updSkill(u) {
3386
3766
  const s = u.skillUpdate
3387
- if (!s) return [h('p', { class: 'small' }, t('upd.skill')), codeCopy(u.skill)]
3388
- if (!s.outdated) return []
3767
+ if (!s || !s.outdated) return []
3389
3768
  if (s.canRun) return [h('p', { class: 'muted small' }, t('upd.skillToo'))]
3390
- 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)
3391
3774
  }
3392
3775
  function renderUpdate() {
3393
3776
  if (!up || dlg.kind !== 'update') return
@@ -3404,30 +3787,50 @@ const MAIN = String.raw `
3404
3787
  body.push(h('p', { class: 'muted small' }, Date.now() > up.waitUntil ? t('upd.restartHint') : [spinner(), ' ', t('upd.reloading')]))
3405
3788
  if (up.done.skillUpdated) body.push(h('p', { class: 'small' }, t('upd.skillDone')))
3406
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))
3407
3797
  } else if (u.available) {
3408
3798
  if (up.changed) body.push(h('p', { class: 'note note-warn' }, t('upd.changed', { v: u.latest })))
3409
3799
  body.push(h('p', null, h('b', null, t('upd.available', { v: u.latest })), ' ',
3410
3800
  h('a', { href: u.releaseNotes, target: '_blank', rel: 'noopener noreferrer' }, t('upd.notes'))))
3411
3801
  if (u.canRun) {
3412
- body.push(h('p', { class: 'small' }, t('upd.runs')), codeCopy(u.command))
3802
+ body.push(...updSkill(u))
3413
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)]))
3414
3805
  } else {
3415
- 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 })))
3416
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))
3417
3812
  }
3418
- 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))
3419
3821
  } else if (up.checked && u.latest) {
3420
3822
  body.push(h('p', { class: 'note' }, t('upd.latestNow')))
3421
3823
  } else if (u.checksOff) {
3422
- body.push(h('p', { class: 'muted small' }, t('upd.checksOff', { why: u.checksOff })))
3824
+ body.push(h('p', { class: 'muted small' }, offText(u)))
3423
3825
  } else if (u.latest) {
3424
3826
  body.push(h('p', { class: 'muted small' }, t('upd.latest') + (u.checkedAt ? ' · ' + t('upd.checked', { ago: ago(u.checkedAt) }) : '')))
3425
3827
  } else body.push(h('p', { class: 'muted small' }, t('upd.notYet')))
3426
3828
  // A read-only Panel sends no request and writes nothing, so it cannot check (spec §3.23).
3427
- if (!BOOT.readOnly && !up.done && !u.available)
3829
+ if (!BOOT.readOnly && !up.done && !u.available && !skillDue(u))
3428
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 },
3429
3831
  up.checking ? spinner() : icon('refresh'), up.checking ? t('upd.checking') : t('upd.check'))))
3430
- 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) {
3431
3834
  const box = h('input', { type: 'checkbox', disabled: up.autoBusy || !u.method.canUpdate })
3432
3835
  box.checked = up.autoPlan ? up.autoPlan.auto : u.auto
3433
3836
  box.addEventListener('change', () => updAuto(box.checked))
@@ -3439,8 +3842,9 @@ const MAIN = String.raw `
3439
3842
  if (BOOT.readOnly) body.push(h('p', { class: 'note' }, t('banner.readOnly')))
3440
3843
  if (up.error) body.push(errorNote(up.error, null))
3441
3844
  const foot = [h('button', { class: 'btn', type: 'button', disabled: up.busy, onclick: () => dlg.el.close() }, t('btn.close'))]
3442
- if (u.available && u.canRun && !BOOT.readOnly && !up.done)
3443
- 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))
3444
3848
  setKids(dlg.el, dialogFrame(t('upd.title'), null, body, foot))
3445
3849
  }
3446
3850
 
@@ -3453,6 +3857,11 @@ const MAIN = String.raw `
3453
3857
  $('refresh').addEventListener('click', refreshAll)
3454
3858
  $('accounts').addEventListener('dragover', onDragOver)
3455
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
+ })
3456
3865
  $('settings').addEventListener('click', () => openPopover($('settings'), 'settings', t('set.button'), settingsPop, 'end'))
3457
3866
  applyTheme()
3458
3867
  renderStatic()