sideby 0.14.0 → 0.16.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 (66) hide show
  1. package/README.md +1 -0
  2. package/dist/build.json +1 -1
  3. package/dist/src/cli/commands.d.ts +7 -0
  4. package/dist/src/cli/commands.js +259 -9
  5. package/dist/src/cli/json-schemas.js +109 -2
  6. package/dist/src/cli/main.js +30 -6
  7. package/dist/src/core/config.d.ts +1 -0
  8. package/dist/src/core/config.js +8 -2
  9. package/dist/src/core/local-build.d.ts +63 -0
  10. package/dist/src/core/local-build.js +189 -0
  11. package/dist/src/core/skill-check.d.ts +14 -0
  12. package/dist/src/core/skill-check.js +29 -3
  13. package/dist/src/core/update.d.ts +73 -1
  14. package/dist/src/core/update.js +155 -23
  15. package/dist/src/panel/handler.d.ts +12 -1
  16. package/dist/src/panel/handler.js +84 -5
  17. package/dist/src/panel/i18n.d.ts +125 -5
  18. package/dist/src/panel/i18n.js +280 -40
  19. package/dist/src/panel/page-logic.d.ts +41 -0
  20. package/dist/src/panel/page-logic.js +46 -0
  21. package/dist/src/panel/page-script.js +616 -162
  22. package/dist/src/panel/page-styles.js +74 -23
  23. package/dist/src/panel/page.js +3 -0
  24. package/dist/src/runtime.d.ts +2 -0
  25. package/package.json +3 -2
  26. package/schemas/alias.json +1 -1
  27. package/schemas/app-install.json +1 -1
  28. package/schemas/app-uninstall.json +1 -1
  29. package/schemas/browser-open.json +1 -1
  30. package/schemas/browser-switch.json +1 -1
  31. package/schemas/browser.json +1 -1
  32. package/schemas/config.json +15 -1
  33. package/schemas/desktop-remove.json +1 -1
  34. package/schemas/desktop.json +1 -1
  35. package/schemas/doctor.json +1 -1
  36. package/schemas/error.json +1 -1
  37. package/schemas/families.json +1 -1
  38. package/schemas/handoff-disable.json +1 -1
  39. package/schemas/handoff-enable.json +1 -1
  40. package/schemas/handoff-ready.json +1 -1
  41. package/schemas/handoff-setup.json +1 -1
  42. package/schemas/handoff-status.json +282 -1
  43. package/schemas/handoff-teardown.json +1 -1
  44. package/schemas/list.json +6 -1
  45. package/schemas/local.json +165 -0
  46. package/schemas/model.json +1 -1
  47. package/schemas/new.json +1 -1
  48. package/schemas/next.json +1 -1
  49. package/schemas/order.json +148 -0
  50. package/schemas/pin.json +126 -0
  51. package/schemas/plugins.json +1 -1
  52. package/schemas/quota-check.json +88 -0
  53. package/schemas/quota-setup.json +1 -1
  54. package/schemas/quota-teardown.json +1 -1
  55. package/schemas/quota.json +1 -1
  56. package/schemas/remote-control.json +1 -1
  57. package/schemas/tune-change.json +1 -1
  58. package/schemas/tune.json +1 -1
  59. package/schemas/unpin.json +126 -0
  60. package/schemas/update-auto.json +84 -0
  61. package/schemas/update.json +79 -1
  62. package/skills/sideby/SKILL.md +8 -3
  63. package/skills/sideby/references/handoff.md +8 -0
  64. package/skills/sideby/references/panel.md +28 -0
  65. package/skills/sideby/references/quota.md +6 -0
  66. package/skills/sideby/references/update.md +15 -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,10 @@ 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
+ swap: 'M7 4 3 8l4 4M3 8h14M17 20l4-4-4-4M21 16H7',
277
+ // Lucide "pin" (ISC license)
278
+ 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
279
  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
280
  grid: 'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z',
273
281
  list: 'M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01',
@@ -496,7 +504,7 @@ const MAIN = String.raw `
496
504
  try { const u = new URL(url); ok = u.protocol === 'https:' || u.protocol === 'http:' } catch {}
497
505
  return ok ? h('a', { class: cls, href: url, target: '_blank', rel: 'noopener noreferrer' }, text) : null
498
506
  }
499
- async function copyText(text) {
507
+ async function copyText(text, shown) {
500
508
  let ok = true
501
509
  try { await navigator.clipboard.writeText(text) } catch {
502
510
  const ta = h('textarea', { css: { position: 'fixed', opacity: '0' } })
@@ -506,7 +514,7 @@ const MAIN = String.raw `
506
514
  try { ok = document.execCommand('copy') } catch { ok = false }
507
515
  ta.remove()
508
516
  }
509
- if (ok) toast(t('toast.copied', { text: prefs.hideEmail && text.includes('@') ? maskEmail(text) : text }))
517
+ if (ok) toast(t('toast.copied', { text: shown || (prefs.hideEmail && text.includes('@') ? maskEmail(text) : text) }))
510
518
  else toast(t('toast.copyFailed'), { kind: 'fail' })
511
519
  }
512
520
  function codeCopy(text) {
@@ -565,6 +573,7 @@ const MAIN = String.raw `
565
573
  function openMenu(anchor, items, label) {
566
574
  const el = h('div', { class: 'menu', role: 'menu', 'aria-label': label },
567
575
  items.filter(Boolean).map((it) => it === '-' ? h('div', { class: 'menu-sep', role: 'separator' })
576
+ : it.header ? h('div', { class: 'menu-head', role: 'presentation' }, it.header)
568
577
  : h('button', { class: 'menu-item', type: 'button', role: 'menuitem', tabindex: '-1', disabled: it.disabled, onclick: () => { closeMenu(true); it.run() } },
569
578
  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
579
  el.addEventListener('keydown', menuKeys)
@@ -612,24 +621,81 @@ const MAIN = String.raw `
612
621
  window.addEventListener('resize', () => closeMenu())
613
622
  window.addEventListener('scroll', () => closeMenu(), { passive: true })
614
623
 
615
- function accountMenu(a, anchor) {
624
+ // What one directory does: start it, its short commands, its desktop app, its folder, model, tuning, Health.
625
+ function dirCopies(a) {
616
626
  const run = 'sideby run ' + a.ref
617
- const items = [
627
+ return [
618
628
  { icon: 'terminal', label: t('menu.copyRun'), hint: run, run: () => copyText(run) },
619
629
  ...(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
630
  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
- '-',
631
+ ]
632
+ }
633
+ function deskItem(a) {
634
+ 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
635
+ }
636
+ function dirItems(a) { return [...dirCopies(a), deskItem(a)] }
637
+ function dirTools(a) {
638
+ return [
626
639
  a.modelInfo && a.modelInfo.settable ? { icon: 'terminal', label: t('menu.model'), run: () => openModel(a) } : null,
627
640
  family(a.family).tuning ? { icon: 'check', label: t('menu.tune'), run: () => openTune(a) } : null,
628
641
  { 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
642
  ]
643
+ }
644
+ // What the login does, whichever directory: the browser, signing in, the email, a new account like it.
645
+ function loginItems(a) {
646
+ return [
647
+ browserFor(a) ? { icon: 'globe', label: t('br.open'), disabled: Boolean(ui.browserRef), run: () => openBrowserDialog(a) } : null,
648
+ a.kind !== 'api' ? { icon: 'key', label: t('menu.copyLogin'), hint: 'sideby login ' + a.ref, run: () => copyText('sideby login ' + a.ref) } : null,
649
+ a.identity && a.identity.email ? { icon: 'mail', label: t('menu.copyEmail'), hint: emailText(a.identity.email), run: () => copyText(a.identity.email) } : null,
650
+ ]
651
+ }
652
+ function likeItem(a) { return BOOT.readOnly ? null : { icon: 'userPlus', label: t('menu.like'), run: () => openCreate({ family: a.family, api: a.kind === 'api' }) } }
653
+ // Grouped by what the user wants to do: open, copy, manage.
654
+ function accountMenu(a, anchor) {
655
+ const [browser, login, email] = loginItems(a)
656
+ const opens = [deskItem(a), browser].filter(Boolean)
657
+ const copies = dirCopies(a)
658
+ const items = [pinItem(a),
659
+ opens.length ? '-' : null, opens.length ? { header: t('menu.groupOpen') } : null, ...opens,
660
+ '-', { header: t('menu.groupCopy') }, ...copies, login, email,
661
+ '-', { header: t('menu.groupManage') }, ...dirTools(a), likeItem(a)]
631
662
  openMenu(anchor, items, t('menu.label', { ref: a.ref }))
632
663
  }
664
+ // A Same Login row (ADR-0026): pin first, then a section per directory, then what the login shares.
665
+ function loginMenu(ms, anchor) {
666
+ const items = [pinItem(ms[0])]
667
+ for (const m of ms) items.push('-', { header: m.name + ((m.aliases || []).length ? ' · ' + m.aliases[0] : '') }, ...dirItems(m), ...dirTools(m))
668
+ items.push('-', ...loginItems(ms[0]), likeItem(ms[0]))
669
+ openMenu(anchor, items, t('login.label', { names: ms.map((m) => m.name).join(', ') }))
670
+ }
671
+ function loginMoreButton(ms) {
672
+ 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'))
673
+ b.addEventListener('click', () => { if (menu.anchor === b) closeMenu(); else loginMenu(ms, b) })
674
+ return b
675
+ }
676
+ // The most used action, on the row itself: copy the command that starts this account (its short command when it
677
+ // has one, which is shorter to type).
678
+ function runButton(a) {
679
+ const cmd = (a.aliases || [])[0] || 'sideby run ' + a.ref
680
+ const text = t('row.copyRun', { cmd })
681
+ return h('button', { class: 'icon-btn row-run', type: 'button', 'aria-label': text, title: text, onclick: (e) => { e.stopPropagation(); copyText(cmd) } }, icon('terminal'))
682
+ }
683
+ // The pin at the end of a row (spec §3.25): one click pins or unpins. A pinned row always shows it, filled; the
684
+ // others show it on hover or focus. A read-only Panel shows the mark of a pinned row and no button.
685
+ function pinButton(a, label) {
686
+ const on = isPinned(a)
687
+ if (BOOT.readOnly) return on ? h('span', { class: 'icon-btn pin-btn row-pin is-on', role: 'img', title: t('pin.mark'), 'aria-label': t('pin.mark') }, icon('pin')) : null
688
+ const full = !on && pinnedUnits(a.family) >= PIN_MAX
689
+ const text = full ? t('pin.full', { n: PIN_MAX }) : t(on ? 'pin.unpinName' : 'pin.pinName', { name: label, title: family(a.family).title })
690
+ return h('button', { class: 'icon-btn pin-btn row-pin' + (on ? ' is-on' : ''), type: 'button', 'aria-pressed': String(on), 'aria-label': text, title: text, 'aria-disabled': full ? 'true' : null,
691
+ onclick: (e) => { e.stopPropagation(); if (full) toast(t('pin.full', { n: PIN_MAX })); else togglePin(a) } }, icon('pin'))
692
+ }
693
+ function pinItem(a) {
694
+ if (BOOT.readOnly) return null
695
+ const on = isPinned(a)
696
+ const full = !on && pinnedUnits(a.family) >= PIN_MAX
697
+ return { icon: 'pin', label: full ? t('pin.full', { n: PIN_MAX }) : t(on ? 'pin.unpin' : 'pin.pin'), disabled: full, run: () => togglePin(a) }
698
+ }
633
699
  function moreButton(a) {
634
700
  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
701
  b.addEventListener('click', () => { if (menu.anchor === b) closeMenu(); else accountMenu(a, b) })
@@ -663,7 +729,7 @@ const MAIN = String.raw `
663
729
  function whyOf(a, now) {
664
730
  const m = attentionWhy(a, healthOf(a.ref), now, WARN)
665
731
  if (m && m.key === 'why.not-installed' && family(a.family).brokenBin) return t('why.broken')
666
- return m ? t(m.key, m.vars) : ''
732
+ return m ? t(m.key, m.vars && m.vars.label ? Object.assign({}, m.vars, { label: winTitle(m.vars.label) }) : m.vars) : ''
667
733
  }
668
734
  function setupFor(fam) { return S.state && S.state.quotaSetups.find((s) => s.family === fam) }
669
735
 
@@ -740,7 +806,10 @@ const MAIN = String.raw `
740
806
  for (const r of rs) counts[r] = (counts[r] || 0) + 1
741
807
  }
742
808
  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')
809
+ // Who needs a look, by name: counting reasons beside a count of accounts made numbers that did not add up.
810
+ const needing = list.filter((a) => reasonsOf(a, now).length)
811
+ 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')
812
+ const needTitle = need ? order.filter((r) => counts[r]).map((r) => tn('need.' + r, counts[r])).join(' · ') : null
744
813
  const usages = list.filter((a) => a.usage && a.usage.status === 'ok')
745
814
  const total = usages.reduce((s, a) => s + (Number(a.usage.totalTokens) || 0), 0)
746
815
  const sessions = usages.reduce((s, a) => s + (Number(a.usage.sessions) || 0), 0)
@@ -749,12 +818,12 @@ const MAIN = String.raw `
749
818
  patch(box, [
750
819
  stat(t('stat.accounts'), String(list.length), dots([tn('stat.families', famCount), apiCount ? tn('stat.api', apiCount) : null])),
751
820
  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() } })
821
+ ? 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
822
  : stat(t('stat.attention'), '0', needSub, null, { cls: 'stat-ok' }),
754
823
  stat(t('stat.tokens', { n: BOOT.usageDays }), tokens(total), tn('stat.sessions', sessions), spark(daily, 'spark-lg')),
755
824
  stat(h('span', { class: 'label-tip' }, t('stat.nextReset'), tip('age')),
756
825
  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')),
826
+ nr ? dots([h('span', { class: 'mono' }, nr.ref), winTitle(nr.label), clock(new Date(nr.at).toISOString())]) : t('stat.noQuota')),
758
827
  ])
759
828
  }
760
829
 
@@ -780,21 +849,29 @@ const MAIN = String.raw `
780
849
  })
781
850
  tb.family = h('select', { class: 'tb-select', 'aria-label': t('tb.family') })
782
851
  tb.family.addEventListener('change', () => { prefs.family = tb.family.value; savePrefs(); renderAccounts() })
852
+ // "Arrange order…" is not a sort: it opens the arrange mode (spec §3.25), and the sort shows "my order".
783
853
  tb.sort = h('select', { class: 'tb-select', 'aria-label': t('tb.sort') },
784
- CHOICES.sort.map((v) => h('option', { value: v }, t('sort.' + v))))
854
+ CHOICES.sort.map((v) => h('option', { value: v }, t('sort.' + v))),
855
+ BOOT.readOnly ? null : h('option', { value: 'arrange' }, t('sort.arrange')))
785
856
  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'))
857
+ tb.sort.addEventListener('change', () => {
858
+ const v = tb.sort.value
859
+ // "My order" before any order of one's own would show nothing new: it opens the arrange mode instead.
860
+ if (v === 'arrange' || (v === 'manual' && !ordered() && !BOOT.readOnly)) { startArrange(); return }
861
+ if (ui.arrange) endArrange()
862
+ prefs.sort = v
863
+ savePrefs()
864
+ renderAccounts()
865
+ })
788
866
  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
867
  display.addEventListener('click', () => openPopover(display, 'display', t('tb.display'), displayPanel, 'end'))
790
868
  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
869
  setKids($('toolbar'), [
792
870
  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),
871
+ h('div', { class: 'tools' }, tb.family, tb.sort, display),
794
872
  create,
795
873
  ])
796
874
  renderFamilyFilter()
797
- renderOrderReset()
798
875
  if (hadFocus) tb.search.focus()
799
876
  }
800
877
  // "Display": the view settings people change rarely, one popover instead of three buttons in the toolbar.
@@ -806,7 +883,7 @@ const MAIN = String.raw `
806
883
  h('div', { class: 'dp-row' }, h('span', { class: 'dp-label' }, t('tb.quota')),
807
884
  seg(t('tb.quota'), [['used', t('tb.used')], ['left', t('tb.left')]], prefs.quota, (v) => { prefs.quota = v; redo() })),
808
885
  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() })),
886
+ 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
887
  emails ? h('label', { class: 'dp-row dp-check' },
811
888
  h('input', { type: 'checkbox', checked: prefs.hideEmail, onchange: (e) => { prefs.hideEmail = e.target.checked; redo() } }),
812
889
  h('span', null, t('tb.hideEmails'))) : null)
@@ -838,6 +915,8 @@ const MAIN = String.raw `
838
915
  const KNOWN_WINDOWS = { '5h': 300, '7d': 10080 }
839
916
  // A window's column: the models it covers when it covers only some (Cursor's "Other models"), else its length.
840
917
  function winKey(w) { return w.name || w.label }
918
+ // The words for a window in labels (5-hour, weekly) instead of 5h and 7d; other labels as they are.
919
+ function winTitle(k) { return k === '5h' || k === '7d' ? t('win.' + k) : k }
841
920
  // One group's columns: the windows its accounts report, shortest first; 5h and 7d when none reports any yet.
842
921
  function quotaColumns(list) {
843
922
  const mins = {}
@@ -855,14 +934,15 @@ const MAIN = String.raw `
855
934
  const left = prefs.quota === 'left'
856
935
  const shown = left ? 100 - st.pct : st.pct
857
936
  const asOf = stale ? t('quota.asOf', { ago: ago(observedAt) }) : ''
937
+ const reset = passed ? t('quota.resetShort') : t('quota.resetsIn', { d: formatDuration(Date.parse(w.resetsAt) - now, lang) })
858
938
  const title = [passed ? t('quota.resetNoData', { date: day(passed.since) }) : t('quota.resetsAt', { at: clock(w.resetsAt) }), asOf].filter(Boolean).join(' · ')
859
939
  return h('div', { class: 'meter lvl-' + st.level + (stale || passed ? ' lvl-stale' : ''), title },
860
940
  h('div', { class: 'm-top' },
861
- h('span', { class: 'm-label' }, winKey(w)),
941
+ h('span', { class: 'm-label' }, winTitle(winKey(w))),
862
942
  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 + '%' } })))
943
+ null),
944
+ h('span', { class: 'm-reset' + (passed ? ' m-passed' : '') }, Number.isFinite(Date.parse(w.resetsAt)) || passed ? reset : '?')),
945
+ 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
946
  }
867
947
  // A column whose window this account does not report right now, such as a 5h window that has not started.
868
948
  function noMeter(label) {
@@ -901,8 +981,10 @@ const MAIN = String.raw `
901
981
  return h('div', { class: 'quota' + (stale ? ' is-stale' : ''), title },
902
982
  wins.length ? cols.map((c) => {
903
983
  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')))
984
+ return w ? meter(w, now, stale, q.observedAt) : noMeter(winTitle(c))
985
+ }) : h('div', { class: 'q-note' }, t('quota.noWindows')),
986
+ // Old numbers keep their colours and say their age once, under the bars.
987
+ 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
988
  }
907
989
  function usageBlock(a) {
908
990
  const u = a.usage
@@ -948,9 +1030,13 @@ const MAIN = String.raw `
948
1030
  const lvl = r.includes('health-fail') ? 'fail' : r.includes('quota-high') || r.includes('health-warn') ? 'warn' : ''
949
1031
  if (!lvl) return null
950
1032
  const why = whyOf(subject, Date.now())
951
- return h('span', { class: 'dot dot-' + lvl + ' att-dot', title: why, role: 'img', 'aria-label': why })
1033
+ // A word, not only a colour (spec §3.8): what kind of look the account needs; the reason is on hover.
1034
+ const word = r.includes('health-fail') ? t('att.fix') : r.includes('quota-high') ? t('att.quota') : t('att.check')
1035
+ 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
1036
  }
953
- function nameLine(a) {
1037
+ // members: the directories of a Same Login shown as this one row (spec §3.21).
1038
+ function nameLine(a, members) {
1039
+ const next = members ? members.some(isNext) : isNext(a)
954
1040
  return h('div', { class: 'ident-name' },
955
1041
  h('span', { class: 'name', title: a.name }, a.name),
956
1042
  attentionDot(a),
@@ -958,9 +1044,10 @@ const MAIN = String.raw `
958
1044
  const args = aliasArgsOf(a, x)
959
1045
  return h('span', { class: 'tag tag-alias', title: args ? t('acct.aliasArgs', { args: args.join(' ') }) : t('acct.alias') }, x)
960
1046
  }),
1047
+ members ? h('span', { class: 'tag tag-login', title: t('login.explain') }, t('login.dirs', { n: members.length })) : null,
961
1048
  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'))
1049
+ next
1050
+ ? nextTag(a)
964
1051
  : null,
965
1052
  a.login === 'logged-out' && a.kind !== 'api'
966
1053
  ? h('button', { class: 'tag tag-fail tag-btn', type: 'button', title: t('acct.signInHint'), 'aria-label': t('acct.signedOutLabel', { ref: a.ref }), onclick: () => copyText('sideby login ' + a.ref) }, h('span', { class: 'dot dot-fail', 'aria-hidden': 'true' }), t('acct.signedOut'))
@@ -1123,7 +1210,7 @@ const MAIN = String.raw `
1123
1210
  }
1124
1211
  // What a row or card shows depends on more than the account (health, view preferences, language); the
1125
1212
  // 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)]) }
1213
+ 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
1214
  // Whether the row sits in a login group (spec §3.21): its first row then shows the quota for all of them.
1128
1215
  function inLogin(a) { return ui.loginRefs.has(a.ref) }
1129
1216
  // The group's first row, which shows the quota for all of them.
@@ -1196,11 +1283,13 @@ const MAIN = String.raw `
1196
1283
  const btn = el.querySelector('.row-toggle')
1197
1284
  if (btn) syncAttrs(btn, rowToggle(a))
1198
1285
  el.classList.toggle('row-open', open)
1199
- el.dataset.sig = rowSig(a)
1286
+ // A Same Login row (ADR-0026) opens onto its directories.
1287
+ const ms = el.classList.contains('row-login') ? loginMembers(S.state.accounts.filter((x) => ui.loginRefs.get(x.ref) === a.ref)) : null
1288
+ el.dataset.sig = ms ? sig(ms.map(rowSig)) : rowSig(a)
1200
1289
  let detail = rowDetails(el)[0]
1201
1290
  if (open && !detail) {
1202
- detail = rowDetail(a, Boolean(emailLine(a)))
1203
- const more = el.querySelector(':scope > .more')
1291
+ detail = ms ? loginDetail(ms) : rowDetail(a, Boolean(emailLine(a)))
1292
+ const more = el.querySelector(':scope > .row-end')
1204
1293
  el.insertBefore(detail, more ? more.nextSibling : null)
1205
1294
  detail.style.opacity = '0'
1206
1295
  }
@@ -1239,33 +1328,87 @@ const MAIN = String.raw `
1239
1328
  for (const p of parts) p.style.opacity = ''
1240
1329
  }
1241
1330
  // The Account 'sideby next <family>' would pick; the server ranks it with the CLI's rule (spec §3.13).
1331
+ // "Suggested": a click says what it means and offers the command, instead of copying without a word.
1332
+ function nextTag(a) {
1333
+ 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'))
1334
+ b.addEventListener('click', (e) => {
1335
+ e.stopPropagation()
1336
+ openPopover(b, 'next', t('acct.next'), () => [
1337
+ h('p', { class: 'next-why' }, t('acct.nextWhy', { title: famTitle(a) })),
1338
+ h('button', { class: 'btn btn-sm', type: 'button', onclick: () => { closeMenu(true); copyText('sideby next ' + a.family) } }, icon('copy'), t('acct.nextCopy')),
1339
+ h('code', { class: 'next-cmd' }, 'sideby next ' + a.family),
1340
+ ])
1341
+ })
1342
+ return b
1343
+ }
1242
1344
  function isNext(a) { return Boolean(S.state && S.state.handoff && S.state.handoff[a.family] === a.ref) }
1243
1345
  function row(a, now, cols) {
1346
+ if (ui.arrange) return arrangeRow(a, [a])
1244
1347
  const open = isOpen(a)
1245
1348
  const email = emailLine(a)
1246
- const lead = loginLead(a)
1247
1349
  return h('div', { class: 'row fold-hit' + (open ? ' row-open' : '') + (a.error ? ' row-error' : ''), role: 'listitem', 'data-ref': a.ref, 'data-key': a.ref, 'data-sig': rowSig(a), onclick: foldClick(a) },
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),
1350
+ h('div', { class: 'ident' }, rowToggle(a), badge(a.family, 'sm'), h('div', { class: 'ident-text' }, nameLine(a), email || subLine(a))),
1351
+ quotaBlock(a, now, cols),
1250
1352
  lastUsed(a),
1251
- moreButton(a),
1353
+ h('div', { class: 'row-end' }, runButton(a), pinButton(a, a.name), moreButton(a)),
1252
1354
  open ? rowDetail(a, Boolean(email)) : null,
1253
1355
  notes(a))
1254
1356
  }
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 })))
1357
+ // Directories signed in as one login (spec §3.21, ADR-0026) are one row: the vendor counts one quota, so the row
1358
+ // shows it once, with the email and the newest use; opening it lists each directory with its own short command,
1359
+ // last use, tokens, Health, a button that copies its start command and its own menu.
1360
+ // The directory that names a Same Login row: the one the user put first (Display Order), else the main one, else
1361
+ // the first by name.
1362
+ function loginMembers(members) {
1363
+ const order = accountOrder(members[0].family) || []
1364
+ const at = (m) => { const i = order.indexOf(m.name); return i < 0 ? Infinity : i }
1365
+ 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 }))
1366
+ }
1367
+ // Names the row after another directory: puts it just before the current one in the Display Order.
1368
+ function showLoginAs(ms, m) {
1369
+ const before = displayOf()
1370
+ const next = copyDisplay(before)
1371
+ const names = allNames(m.family).filter((n) => n !== m.name)
1372
+ names.splice(Math.max(0, names.indexOf(ms[0].name)), 0, m.name)
1373
+ next.accounts[m.family] = names
1374
+ saveDisplay(before, next)
1375
+ }
1376
+ function newestOf(members) {
1377
+ const at = (m) => { const v = Date.parse((m.usage && m.usage.status === 'ok' && m.usage.lastActivityAt) || ''); return Number.isFinite(v) ? v : -1 }
1378
+ return members.reduce((x, y) => at(y) > at(x) ? y : x)
1379
+ }
1380
+ function loginRow(members, now, cols) {
1381
+ const ms = loginMembers(members)
1382
+ const a = ms[0]
1383
+ if (ui.arrange) return arrangeRow(a, ms)
1384
+ const open = isOpen(a)
1385
+ const email = emailLine(a)
1386
+ return h('div', { class: 'row row-login fold-hit' + (open ? ' row-open' : '') + (a.error ? ' row-error' : ''), role: 'listitem', 'aria-label': t('login.label', { names: ms.map((m) => m.name).join(', ') }), 'data-ref': a.ref, 'data-key': loginKey(ms.map((m) => m.ref)), 'data-sig': sig(ms.map(rowSig)), onclick: foldClick(a) },
1387
+ h('div', { class: 'ident' }, rowToggle(a), badge(a.family, 'sm'), h('div', { class: 'ident-text' }, nameLine(a, ms), email || subLine(a))),
1388
+ quotaBlock(a, now, cols),
1389
+ lastUsed(newestOf(ms)),
1390
+ h('div', { class: 'row-end' }, runButton(a), pinButton(a, ms.map((m) => m.name).join(', ')), loginMoreButton(ms)),
1391
+ open ? loginDetail(ms) : null,
1392
+ notes(a))
1393
+ }
1394
+ function loginDetail(ms) {
1395
+ return h('div', { class: 'row-detail login-detail' },
1396
+ h('p', { class: 'rd-note' }, t('login.explain')),
1397
+ h('div', { class: 'login-dirs', role: 'list' }, ms.map((m) => {
1398
+ const run = 'sideby run ' + m.ref
1399
+ return h('div', { class: 'login-dir', role: 'listitem', 'data-ref': m.ref },
1400
+ h('div', { class: 'ld-name' },
1401
+ h('span', { class: 'name' }, m.name),
1402
+ m.ref !== ms[0].ref ? attentionDot(m) : null,
1403
+ (m.aliases || []).slice(0, 3).map((x) => h('span', { class: 'tag tag-alias' }, x))),
1404
+ m.ref === ms[0].ref ? h('span', { class: 'tag ld-shown', title: t('login.shownHint') }, t('login.shown'))
1405
+ : 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')),
1406
+ lastUsed(m),
1407
+ usageBlock(m),
1408
+ healthChip(m),
1409
+ h('button', { class: 'btn btn-sm', type: 'button', title: run, onclick: () => copyText(run) }, icon('terminal'), t('login.copyRun')),
1410
+ moreButton(m))
1411
+ })))
1269
1412
  }
1270
1413
  // What a click on a row opens: how it runs (ref, model and effort, a shared login), its tokens and its Health.
1271
1414
  function sameLoginNote(a) {
@@ -1286,7 +1429,7 @@ const MAIN = String.raw `
1286
1429
  h('header', { class: 'card-head' },
1287
1430
  badge(a.family, 'sm'),
1288
1431
  h('div', { class: 'ident-text' }, nameLine(a), emailLine(a), subLine(a), sameLoginNote(a)),
1289
- moreButton(a)),
1432
+ h('div', { class: 'row-end' }, pinButton(a, a.name), moreButton(a))),
1290
1433
  notes(a),
1291
1434
  quotaBlock(a, now, cols),
1292
1435
  h('footer', { class: 'card-foot' }, usageBlock(a), h('div', { class: 'card-side' }, lastUsed(a), healthChip(a))))
@@ -1298,7 +1441,7 @@ const MAIN = String.raw `
1298
1441
  const titled = list.some((a) => a.quota && a.quota.status === 'ok')
1299
1442
  return h('div', { class: 'col-head', 'aria-hidden': 'true', 'data-key': 'cols' },
1300
1443
  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),
1444
+ h('span', { class: 'quota' }, titled ? cols.map((c) => h('span', null, t(left ? 'col.left' : 'col.used', { label: winTitle(c) }))) : null),
1302
1445
  h('span', { class: 'ch-last' }, t('col.last')),
1303
1446
  h('span', { class: 'ch-more' }))
1304
1447
  }
@@ -1399,30 +1542,43 @@ const MAIN = String.raw `
1399
1542
  }
1400
1543
  function appGroup(list) {
1401
1544
  const app = list[0].app
1402
- const collapsed = prefs.collapsed['app:' + app] === true
1545
+ const id = appId(list[0].ref)
1546
+ ui.order.units[id] = list.map((p) => [p.name])
1547
+ const collapsed = prefs.collapsed['app:' + app] === true && !ui.arrange
1403
1548
  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) },
1549
+ const head = h('div', { class: 'group-head', 'data-key': ui.arrange ? 'head-arrange' : 'head' },
1550
+ dragHandle('family', id, id, app),
1551
+ 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
1552
  icon('chevron', 'chev'), appBadge(), h('span', { class: 'group-title' }, app),
1407
1553
  h('span', { class: 'group-count' }, String(list.length))),
1408
1554
  h('div', { class: 'group-meta' },
1409
1555
  h('span', { class: 'muted small label-tip' }, t('app.apart'), tipRaw(app, t('app.apartTip', { app })))),
1410
1556
  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))
1557
+ 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)))
1558
+ 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': '' },
1559
+ head, body, collapsed || ui.arrange ? null : appFoot(list))
1560
+ }
1561
+ // A desktop app window in the arrange mode: grip, mark, name, up and down.
1562
+ function appArrangeRow(p, id) {
1563
+ const units = ui.order.units[id] || []
1564
+ const at = units.findIndex((u) => u[0] === p.name)
1565
+ const name = p.isMain ? t('app.mainName') : p.name
1566
+ 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]) },
1567
+ 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)))),
1568
+ h('div', { class: 'arrange-tools' },
1569
+ 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')),
1570
+ 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
1571
  }
1415
1572
  // 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 []
1573
+ function appGroups() { return appIds().map(appGroupFor).filter(Boolean) }
1574
+ // A desktop app's group, its windows in the Display Order (spec §3.25); none under a family or attention filter.
1575
+ function appGroupFor(id) {
1576
+ if (prefs.family || ui.attention) return null
1418
1577
  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
1578
+ const all = inDisplayOrder(appProfiles().filter((p) => appId(p.ref) === id), (p) => p.name, accountOrder(id))
1579
+ const app = all.length ? all[0].app : ''
1580
+ const list = q && !app.toLowerCase().includes(q) ? all.filter((p) => p.ref.toLowerCase().includes(q)) : all
1581
+ return list.length ? appGroup(list) : null
1426
1582
  }
1427
1583
  async function openApp(ref) {
1428
1584
  if (ui.appBusy) return
@@ -1468,11 +1624,13 @@ const MAIN = String.raw `
1468
1624
  const setup = setupFor(fam)
1469
1625
  const off = list.some((a) => a.quota && a.quota.status === 'unavailable' && a.quota.reason === 'not-enabled')
1470
1626
  const bodyId = 'grp-' + fam.replace(/[^a-zA-Z0-9_-]/g, '_')
1471
- const cards = prefs.view === 'cards'
1627
+ // The arrange mode shows every group open, as a short list, in either view (spec §3.25).
1628
+ const cards = prefs.view === 'cards' && !ui.arrange
1629
+ const collapsedNow = collapsed && !ui.arrange
1472
1630
  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' },
1631
+ const head = h('div', { class: 'group-head', 'data-key': ui.arrange ? 'head-arrange' : 'head' },
1474
1632
  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) },
1633
+ 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
1634
  icon('chevron', 'chev'), badge(fam, 'sm'), h('span', { class: 'group-title' }, info.title),
1477
1635
  h('span', { class: 'group-count' }, list.length === total ? String(total) : list.length + ' / ' + total)),
1478
1636
  h('div', { class: 'group-meta' },
@@ -1484,11 +1642,11 @@ const MAIN = String.raw `
1484
1642
  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
1643
  : null,
1486
1644
  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
1645
+ const body = collapsedNow ? null : cards
1488
1646
  ? 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)
1647
+ : 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)))
1648
+ 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': '' },
1649
+ head, collapsedNow || cards || ui.arrange ? null : colHead(cols, list), body)
1492
1650
  }
1493
1651
  function emptyState() {
1494
1652
  const fams = S.state.families.slice().sort((x, y) => Number(y.installed) - Number(x.installed))
@@ -1499,7 +1657,8 @@ const MAIN = String.raw `
1499
1657
  h('p', { class: 'muted' }, t('empty.body')),
1500
1658
  fams.length ? h('ul', { class: 'host-list' }, fams.map((f) => h('li', null, badge(f.id, 'sm'), h('span', null, f.title),
1501
1659
  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')))
1660
+ BOOT.readOnly ? null : h('button', { class: 'btn btn-primary', type: 'button', onclick: () => openCreate(null) }, icon('plus'), t('empty.create')),
1661
+ BOOT.readOnly ? null : h('p', { class: 'muted small' }, t('ai.emptyHint'), ' ', h('button', { class: 'link-btn', type: 'button', onclick: openAi }, t('ai.how'))))
1503
1662
  }
1504
1663
  function clearFilters() {
1505
1664
  ui.query = ''
@@ -1523,24 +1682,142 @@ const MAIN = String.raw `
1523
1682
  h('p', null, S.loadError.message),
1524
1683
  h('button', { class: 'btn', type: 'button', onclick: () => load({ check: !S.report }) }, icon('refresh'), t('btn.retry')))
1525
1684
  }
1526
- // ---------- Display Order (spec §3.25) ----------
1685
+ // ---------- Display Order and Pinned Accounts (spec §3.25) ----------
1686
+ const PIN_MAX = 3
1527
1687
  function displayOf() { return (S.state && S.state.display) || {} }
1528
1688
  // Every Family id in the Display Order, the ones only accounts name (a plugin that failed) last.
1529
1689
  function familyOrder() {
1530
1690
  const ids = S.state.families.map((f) => f.id)
1531
1691
  for (const a of S.state.accounts) if (!ids.includes(a.family)) ids.push(a.family)
1692
+ // Desktop apps with windows of their own (Grok Bot) take a place in the same order, after the tools by default.
1693
+ for (const id of appIds()) if (!ids.includes(id)) ids.push(id)
1532
1694
  return inDisplayOrder(ids, (x) => x, displayOf().families)
1533
1695
  }
1696
+ function appIds() { return [...new Set(appProfiles().map((p) => appId(p.ref)))] }
1697
+ function isAppGroup(id) { return appIds().includes(id) && !S.state.families.some((f) => f.id === id) }
1534
1698
  function accountOrder(fam) { return (displayOf().accounts || {})[fam] }
1699
+ function pinnedOf(fam) { return (displayOf().pinned || {})[fam] || [] }
1700
+ function isPinned(a) { return pinnedIn(a, pinnedOf(a.family)) }
1701
+ // Whether a shown unit (an account, or the directories of one login) is pinned.
1702
+ function unitPinned(fam, unit) {
1703
+ const pins = pinnedOf(fam)
1704
+ 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) })
1705
+ }
1706
+ // Pinned places taken in a Family: a Same Login pinned together counts once.
1707
+ function pinnedUnits(fam) {
1708
+ const names = pinnedOf(fam)
1709
+ return loginClusters(S.state.accounts.filter((a) => a.family === fam && names.includes(a.name))).length
1710
+ }
1535
1711
  // Every account name of a Family, hidden ones too, in the Display Order.
1536
1712
  function allNames(fam) {
1713
+ if (isAppGroup(fam)) return inDisplayOrder(appProfiles().filter((p) => appId(p.ref) === fam).map((p) => p.name), (x) => x, accountOrder(fam))
1537
1714
  return sortAccounts(S.state.accounts.filter((a) => a.family === fam), 'manual', Date.now(), accountOrder(fam)).map((a) => a.name)
1538
1715
  }
1539
1716
  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.
1717
+ function copyDisplay(d) {
1718
+ return { families: d.families ? d.families.slice() : [], accounts: Object.assign({}, d.accounts || {}), pinned: Object.assign({}, d.pinned || {}) }
1719
+ }
1720
+ // Shows next at once and writes it to the config. Writes go one at a time; a failed one puts back what the config
1721
+ // last confirmed, never another write's unsaved state.
1722
+ const displaySaves = { chain: Promise.resolve(), pending: 0, confirmed: null }
1723
+ function saveDisplay(before, next, failKey) {
1724
+ if (!displaySaves.pending) displaySaves.confirmed = before
1725
+ displaySaves.pending++
1726
+ S.state.display = next
1727
+ renderAccounts()
1728
+ const run = displaySaves.chain.then(async () => {
1729
+ try {
1730
+ const r = await api('/api/display-order', { display: next })
1731
+ displaySaves.confirmed = r.display
1732
+ if (S.state.display === next) S.state.display = r.display
1733
+ return true
1734
+ } catch (e) {
1735
+ // A newer write already on its way settles the screen; only the latest one puts things back.
1736
+ if (S.state.display === next) { S.state.display = displaySaves.confirmed; renderAccounts() }
1737
+ toast(t(failKey || 'order.failed', { msg: e.message }), { kind: 'fail' })
1738
+ return false
1739
+ } finally { displaySaves.pending-- }
1740
+ })
1741
+ displaySaves.chain = run.catch(() => {})
1742
+ return run
1743
+ }
1744
+ // Pins or unpins an Account, with the other directories of its Same Login. A new pin goes last among the pinned.
1745
+ function togglePin(a) {
1746
+ const before = displayOf()
1747
+ const next = copyDisplay(before)
1748
+ const names = [a.name].concat((a.sameLogin || []).map(refName))
1749
+ // Names of accounts that are gone are dropped on the way (spec §3.25).
1750
+ const list = pinnedOf(a.family).filter((n) => !names.includes(n) && S.state.accounts.some((x) => x.family === a.family && x.name === n))
1751
+ if (!isPinned(a)) {
1752
+ if (pinnedUnits(a.family) >= PIN_MAX) { toast(t('pin.full', { n: PIN_MAX }), { kind: 'fail' }); return }
1753
+ list.push(...names.filter((n) => S.state.accounts.some((x) => x.family === a.family && x.name === n)))
1754
+ }
1755
+ next.pinned[a.family] = list
1756
+ saveDisplay(before, next, 'pin.failed')
1757
+ }
1758
+
1759
+ // The arrange mode: the only place to drag (spec §3.25). It starts from the order on screen, saves each move at
1760
+ // once, and "Done" offers to put back the order it started from.
1761
+ function sortAfter(start) { return start.sortBefore === 'manual' ? 'pressure' : start.sortBefore }
1762
+ function startArrange() {
1763
+ if (BOOT.readOnly) return
1764
+ closeMenu()
1765
+ if (!ui.arrange) ui.arrange = { before: copyDisplay(displayOf()), sortBefore: prefs.sort }
1766
+ prefs.sort = 'manual'
1767
+ savePrefs()
1768
+ if (tb.sort) tb.sort.value = 'manual'
1769
+ renderAccounts()
1770
+ const first = document.querySelector('#accounts .drag-handle')
1771
+ if (first) first.focus({ preventScroll: true })
1772
+ }
1773
+ function endArrange() {
1774
+ const start = ui.arrange
1775
+ if (!start) return
1776
+ ui.arrange = null
1777
+ // With no order of one's own left, "my order" is the default order: go back to the usual sort.
1778
+ if (!ordered()) { prefs.sort = sortAfter(start); savePrefs() }
1779
+ if (tb.sort) tb.sort.value = prefs.sort
1780
+ renderAccounts()
1781
+ if (JSON.stringify(copyDisplay(displayOf())) === JSON.stringify(start.before)) return
1782
+ toast(t('order.saved'), { action: { label: t('order.undo'), run: async () => {
1783
+ if (!(await saveDisplay(displayOf(), start.before))) return
1784
+ if (!ordered() && prefs.sort === 'manual') { prefs.sort = sortAfter(start); savePrefs(); if (tb.sort) tb.sort.value = prefs.sort; renderAccounts() }
1785
+ toast(t('order.undone'))
1786
+ } } })
1787
+ }
1788
+ function arrangeBar() {
1789
+ return h('div', { class: 'arrange-bar', role: 'region', 'aria-label': t('sort.arrange'), 'data-key': 'arrange-bar', 'data-sig': sig([ordered(), lang]) },
1790
+ h('span', { class: 'arrange-text' }, icon('grip'), h('span', null, t('order.bar')), h('button', { class: 'link-btn small', type: 'button', onclick: openAi }, t('ai.orderHint'))),
1791
+ h('div', { class: 'arrange-actions' },
1792
+ h('button', { class: 'link-btn', type: 'button', title: t('order.resetHint'), disabled: ordered() ? undefined : true, onclick: resetOrder }, t('order.resetButton')),
1793
+ h('button', { class: 'btn btn-primary btn-sm', type: 'button', onclick: endArrange }, t('order.done'))))
1794
+ }
1795
+ // An account row in the arrange mode: grip, mark, name and short commands, then up, down and pin. Nothing else.
1796
+ function arrangeRow(a, ms) {
1797
+ const fam = a.family
1798
+ const units = ui.order.units[fam] || []
1799
+ const at = units.findIndex((u) => u.includes(a.name))
1800
+ const nPinned = units.filter((u) => unitPinned(fam, u)).length
1801
+ const pinned = isPinned(a)
1802
+ const lo = pinned ? 0 : nPinned
1803
+ const hi = pinned ? nPinned - 1 : units.length - 1
1804
+ const label = ms.map((m) => m.name).join(', ')
1805
+ const full = !pinned && pinnedUnits(fam) >= PIN_MAX
1806
+ const pinLabel = full ? t('pin.full', { n: PIN_MAX }) : t(pinned ? 'pin.unpinName' : 'pin.pinName', { name: label, title: family(fam).title })
1807
+ return h('div', { class: 'row row-arrange' + (pinned ? ' is-pinned' : ''), role: 'listitem', 'data-ref': a.ref, 'data-key': 'arr:' + loginKey(ms.map((m) => m.ref)), 'data-sig': sig([ms.map(rowSig), at, lo, hi, full]) },
1808
+ h('div', { class: 'ident' }, dragHandle('account', fam, a.name, label), badge(fam, 'sm'), h('div', { class: 'ident-text' }, h('div', { class: 'ident-name' },
1809
+ h('span', { class: 'name', title: a.name }, a.name),
1810
+ (a.aliases || []).slice(0, 3).map((x) => h('span', { class: 'tag tag-alias' }, x)),
1811
+ ms.length > 1 ? h('span', { class: 'tag tag-login', title: t('login.explain') }, t('login.dirs', { n: ms.length })) : null))),
1812
+ h('div', { class: 'arrange-tools' },
1813
+ h('button', { class: 'icon-btn', type: 'button', 'aria-label': t('order.up', { name: label }), title: t('order.up', { name: label }), disabled: at <= lo ? true : undefined, onclick: () => moveUnit('account', fam, at, at - 1, { focus: a.name, label, button: 'up' }) }, icon('arrowUp')),
1814
+ h('button', { class: 'icon-btn', type: 'button', 'aria-label': t('order.down', { name: label }), title: t('order.down', { name: label }), disabled: at >= hi ? true : undefined, onclick: () => moveUnit('account', fam, at, at + 1, { focus: a.name, label, button: 'down' }) }, icon('arrowDown')),
1815
+ h('button', { class: 'icon-btn pin-btn', type: 'button', 'aria-pressed': String(pinned), 'aria-label': pinLabel, title: pinLabel, 'aria-disabled': full ? 'true' : null, onclick: () => { if (full) toast(t('pin.full', { n: PIN_MAX })); else togglePin(a) } }, icon('pin'))))
1816
+ }
1817
+ // A grip to drag a Family group or an account (a Same Login moves as one) into a new place, or to move it with
1818
+ // the arrow keys. Only in the arrange mode.
1542
1819
  function dragHandle(kind, fam, key, name) {
1543
- if (BOOT.readOnly || prefs.view === 'cards') return null
1820
+ if (BOOT.readOnly || !ui.arrange) return null
1544
1821
  const label = t(kind === 'family' ? 'order.moveFamily' : 'order.moveAccount', { name })
1545
1822
  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
1823
  onclick: (e) => e.stopPropagation(),
@@ -1557,7 +1834,7 @@ const MAIN = String.raw `
1557
1834
  },
1558
1835
  ondragstart: (e) => {
1559
1836
  const units = unitsOf(kind, fam)
1560
- ui.drag = { kind, fam, from: units.findIndex((u) => u.includes(key)), to: null }
1837
+ ui.drag = { kind, fam, from: units.findIndex((u) => u.includes(key)), to: null, other: null, dropped: false }
1561
1838
  e.dataTransfer.effectAllowed = 'move'
1562
1839
  try { e.dataTransfer.setData('text/plain', name) } catch {}
1563
1840
  const el = unitElement(kind, fam, key)
@@ -1567,11 +1844,10 @@ const MAIN = String.raw `
1567
1844
  }, icon('grip'))
1568
1845
  }
1569
1846
  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.
1847
+ // The element that moves: a Family's group, or an account's row (a Same Login is one row).
1571
1848
  function unitElement(kind, fam, key) {
1572
1849
  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)
1850
+ return document.querySelector('#accounts .row[data-ref="' + CSS.escape(fam + ':' + key) + '"]')
1575
1851
  }
1576
1852
  // Where a drop at the pointer lands: the list of places it can go and the index among them, or null outside.
1577
1853
  function dropTarget(e) {
@@ -1582,7 +1858,9 @@ const MAIN = String.raw `
1582
1858
  return els.length ? { els, at: slotAt(els, e.clientY) } : null
1583
1859
  }
1584
1860
  const sec = e.target.closest && e.target.closest('#accounts > section[data-fam]')
1585
- if (!sec || sec.getAttribute('data-fam') !== d.fam) return null
1861
+ // Over another Family: remembered, so letting go there can say why nothing moved.
1862
+ d.other = sec && sec.getAttribute('data-fam') !== d.fam ? sec.getAttribute('data-fam') : null
1863
+ if (!sec || d.other) return null
1586
1864
  const box = sec.querySelector('.rows')
1587
1865
  if (!box) return null
1588
1866
  const els = Array.from(box.children)
@@ -1597,9 +1875,11 @@ const MAIN = String.raw `
1597
1875
  }
1598
1876
  function clearDropMarks() { for (const el of document.querySelectorAll('.drop-before, .drop-after')) el.classList.remove('drop-before', 'drop-after') }
1599
1877
  function endDrag() {
1878
+ const d = ui.drag
1600
1879
  clearDropMarks()
1601
1880
  for (const el of document.querySelectorAll('.is-dragging')) el.classList.remove('is-dragging')
1602
1881
  ui.drag = null
1882
+ if (d && d.other && !d.dropped) toast(t('order.sameFamily', { title: family(d.fam).title }))
1603
1883
  if (S.pending) { S.pending = false; renderAll() }
1604
1884
  }
1605
1885
  function onDragOver(e) {
@@ -1617,57 +1897,53 @@ const MAIN = String.raw `
1617
1897
  const d = ui.drag
1618
1898
  if (!d || !dropTarget(e)) return
1619
1899
  e.preventDefault()
1900
+ d.dropped = true
1620
1901
  const to = d.to
1621
1902
  endDrag()
1622
1903
  if (to !== null && to !== d.from) moveUnit(d.kind, d.fam, d.from, to)
1623
1904
  }
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) {
1905
+ // Records a move: Families anywhere; pinned accounts only among the pinned (their pin order), the others only
1906
+ // below them (the Display Order). A move past the edge of its part stops at the edge.
1907
+ function moveUnit(kind, fam, from, to, opts) {
1626
1908
  opts = opts || {}
1627
1909
  const units = unitsOf(kind, fam)
1628
1910
  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
1911
  const before = displayOf()
1631
- const next = { families: before.families ? before.families.slice() : [], accounts: Object.assign({}, before.accounts || {}) }
1912
+ const next = copyDisplay(before)
1913
+ let place = to
1914
+ let first = 0
1915
+ let total = units.length
1632
1916
  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()
1917
+ else {
1918
+ // Names of accounts that are gone are dropped on the way (spec §3.25).
1919
+ const pins = pinnedOf(fam).filter((n) => S.state.accounts.some((x) => x.family === fam && x.name === n))
1920
+ const nPinned = units.filter((u) => unitPinned(fam, u)).length
1921
+ const pinned = from < nPinned
1922
+ place = pinned ? Math.min(to, nPinned - 1) : Math.max(to, nPinned)
1923
+ first = pinned ? 0 : nPinned
1924
+ total = pinned ? nPinned : units.length - nPinned
1925
+ if (place !== from) {
1926
+ if (pinned) next.pinned[fam] = reorder(pins, units.slice(0, nPinned), from, place)
1927
+ else next.accounts[fam] = reorder(allNames(fam), units, from, place)
1928
+ }
1929
+ }
1930
+ const where = () => { if (opts.label) say(t('order.position', { name: opts.label, n: place - first + 1, total })) }
1931
+ if (place === from) { where(); return }
1932
+ saveDisplay(before, next)
1638
1933
  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) + '"]')
1934
+ const grip = '#accounts .drag-handle[data-move="' + kind + '"][data-fam="' + CSS.escape(fam) + '"][data-name="' + CSS.escape(opts.focus) + '"]'
1935
+ 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
1936
+ // At the edge the arrow it used is off now: the grip keeps the focus.
1937
+ if (!btn || btn.disabled) btn = document.querySelector(grip)
1640
1938
  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' })
1939
+ where()
1650
1940
  }
1651
1941
  }
1942
+ // Forgets the order of Families and accounts; pins stay.
1652
1943
  async function resetOrder() {
1653
1944
  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()
1945
+ const pinned = before.pinned && Object.keys(before.pinned).length ? before.pinned : null
1946
+ if (await saveDisplay(before, pinned ? { pinned } : {})) toast(t('order.reset'))
1671
1947
  }
1672
1948
  // Words for screen readers only, such as the new place of something moved with the keyboard.
1673
1949
  let live = null
@@ -1697,25 +1973,29 @@ const MAIN = String.raw `
1697
1973
  const apps = appGroups()
1698
1974
  if (!shown.length && !apps.length) { patch(box, noMatch()); return }
1699
1975
  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)
1976
+ if (prefs.view !== 'cards' || ui.arrange)
1977
+ 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
1978
  // Families in the Display Order (spec §3.25) whatever the sort; each group gets the quota columns it uses.
1703
1979
  // Every group is as wide as the one with the most windows, so the columns after them line up.
1704
1980
  ui.order = { families: [], units: {} }
1705
1981
  const groups = []
1706
1982
  let width = 1
1707
1983
  for (const fam of familyOrder()) {
1984
+ if (isAppGroup(fam)) {
1985
+ const g = appGroupFor(fam)
1986
+ if (g) { ui.order.families.push(fam); groups.push(g) }
1987
+ continue
1988
+ }
1708
1989
  const list = shown.filter((a) => a.family === fam)
1709
1990
  if (!list.length) continue
1710
1991
  ui.order.families.push(fam)
1711
- const sorted = sortAccounts(list, prefs.sort, now, accountOrder(fam))
1992
+ const sorted = withPinned(sortAccounts(list, prefs.sort, now, accountOrder(fam)), pinnedOf(fam))
1712
1993
  const cols = quotaColumns(sorted)
1713
1994
  width = Math.max(width, cols.length)
1714
1995
  groups.push(group(fam, sorted, all.filter((a) => a.family === fam).length, now, cols))
1715
1996
  }
1716
1997
  box.style.setProperty('--qn', String(width))
1717
- renderOrderReset()
1718
- patch(box, groups.concat(apps))
1998
+ patch(box, (ui.arrange ? [arrangeBar()] : []).concat(groups))
1719
1999
  if (ui.fade && !REDUCED) {
1720
2000
  box.classList.add('view-in')
1721
2001
  setTimeout(() => box.classList.remove('view-in'), 400)
@@ -1787,7 +2067,17 @@ const MAIN = String.raw `
1787
2067
  renderCheckup()
1788
2068
  $('checkup').scrollIntoView({ behavior: REDUCED ? 'auto' : 'smooth', block: 'start' })
1789
2069
  }
2070
+ // The Panel updates an outdated sideby skill itself when the Update plan allows it (spec §3.16, §3.23).
2071
+ function skillFixable() {
2072
+ const u = updState()
2073
+ return !BOOT.readOnly && !!(u && u.method.kind !== 'local' && u.skillUpdate && u.skillUpdate.outdated && u.skillUpdate.canRun)
2074
+ }
2075
+ // A Finding in plain words (its code's sentence); the check's own text goes under "Details".
1790
2076
  function findingRow(f) {
2077
+ const own = panelFixes(f, skillFixable())
2078
+ const key = 'finding.' + f.code
2079
+ const plain = Object.hasOwn(MESSAGES.en, key) ? t(key, { item: f.item }) : ''
2080
+ const fixHere = plain && f.fixable && !BOOT.readOnly && !own
1791
2081
  return h('li', { class: 'finding lvl-' + f.level },
1792
2082
  h('span', { class: 'dot', title: f.level }),
1793
2083
  h('div', { class: 'finding-body' },
@@ -1795,12 +2085,20 @@ const MAIN = String.raw `
1795
2085
  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
2086
  h('span', { class: 'muted small mono' }, f.item),
1797
2087
  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))
2088
+ f.source && f.source !== 'core' ? h('span', { class: 'muted small' }, t('health.from', { source: f.source })) : null,
2089
+ own ? h('button', { class: 'btn btn-sm btn-primary', type: 'button', onclick: openUpdate }, icon('arrowUp'), t('upd.skillLabel')) : null),
2090
+ h('p', null, plain || f.message),
2091
+ own ? null
2092
+ : fixHere ? h('p', { class: 'hint' }, t('finding.fixHint'))
2093
+ : f.hint && !plain ? h('p', { class: 'hint' }, panelHint(f.hint, f.fixable && !BOOT.readOnly, t('fix.hintAction'))) : null,
2094
+ // The check's own words (and any command to run) stay one click away.
2095
+ 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
2096
  }
1802
2097
  function renderCheckup() {
1803
2098
  const box = $('checkup')
2099
+ // With no account there is nothing to check: the empty state is the only thing to do.
2100
+ box.hidden = Boolean(S.state && !S.state.accounts.length)
2101
+ if (box.hidden) return
1804
2102
  const all = findingsNow()
1805
2103
  const ref = ui.healthRef
1806
2104
  const findings = all && ref ? all.filter((f) => f.account === ref) : all
@@ -1808,7 +2106,11 @@ const MAIN = String.raw `
1808
2106
  const busy = S.checking || S.fixing
1809
2107
  const fixable = all ? all.filter((f) => f.fixable).length : 0
1810
2108
  // 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')
2109
+ const skillFix = skillFixable()
2110
+ const forAi = (list) => list.filter((f) => !panelFixes(f, skillFix))
2111
+ const fixWhy = busy || fixable ? null : !all ? t('fix.whyNoCheck') : !all.length ? t('fix.whyNothing')
2112
+ : forAi(all).length ? t('fix.whyManual') : t('fix.whyButton')
2113
+ const prompt = findings && !busy ? fixPrompt(forAi(findings), { lead: t('fix.promptLead'), hint: t('fix.promptHint'), fixFirst: t('fix.promptFixFirst'), check: t('fix.promptCheck') }) : ''
1812
2114
  const status = S.checking ? t('health.running') : S.fixing ? t('health.fixing') : at ? t('health.last', { ago: ago(at) }) : t('health.never')
1813
2115
  const out = [h('div', { class: 'section-head' },
1814
2116
  h('div', null,
@@ -1817,6 +2119,7 @@ const MAIN = String.raw `
1817
2119
  tx('health.lead', { shared: h('span', { class: 'label-tip' }, t('health.sharedTerm'), tip('shared')), drift: h('span', { class: 'label-tip' }, t('health.driftTerm'), tip('drift')) }))),
1818
2120
  h('div', { class: 'actions' },
1819
2121
  h('button', { class: 'btn', type: 'button', disabled: busy || !S.state, 'aria-busy': S.checking ? 'true' : null, title: !S.state && !busy ? t('health.waiting') : null, onclick: runCheck }, S.checking ? spinner() : null, S.checking ? t('health.running') : t('health.run')),
2122
+ prompt ? h('button', { class: 'btn', type: 'button', title: t('fix.copyAiTitle'), onclick: () => copyText(prompt, t('fix.promptShown')) }, icon('copy'), t('fix.copyAi')) : null,
1820
2123
  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
2124
  S.fixing ? spinner() : null, S.fixing ? t('health.fixing') : t('fix.all') + (fixable ? ' (' + fixable + ')' : ''))))]
1822
2125
  if (ref) out.push(h('div', { class: 'filter-bar' },
@@ -2011,10 +2314,12 @@ const MAIN = String.raw `
2011
2314
  if (g.outOfRange) rows.push(h('p', { class: 'note note-warn' }, t('tune.outOfRange', { size: tokens(g.current) })))
2012
2315
  if (!BOOT.readOnly) {
2013
2316
  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' },
2317
+ // With a suggestion there is one main button; choosing a size by hand waits behind a click.
2318
+ const custom = h('div', { class: 'tune-setting' },
2015
2319
  h('b', { class: 'small' }, t('tune.setting')),
2016
2320
  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')))))
2321
+ h('button', { class: 'btn btn-sm', type: 'button', disabled: Boolean(busy) || value === g.current, onclick: () => useAsk(g, value) }, t('tune.useThis'))))
2322
+ 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
2323
  rows.push(useBox(g, false))
2019
2324
  if (g.undo && !(mine && (useUi.step === 'done' || useUi.step === 'undone')))
2020
2325
  rows.push(h('p', { class: 'small tune-undo' }, t('tune.applied', { prev: tuneSize(g.undo.previous) }), ' ',
@@ -2109,7 +2414,9 @@ const MAIN = String.raw `
2109
2414
  setKids(nav, h('div', { class: 'seg', role: 'presentation' }, tabs.map(([id, label]) =>
2110
2415
  h('button', { class: 'seg-btn', type: 'button', role: 'tab', 'aria-selected': curPage === id ? 'true' : 'false', onclick: () => setPage(id) }, label))))
2111
2416
  const away = curPage !== 'accounts' && tabs.some(([id]) => id === curPage)
2112
- for (const id of ['summary', 'accounts-page', 'checkup']) $(id).hidden = away
2417
+ // With no account yet the empty state is the whole page: no summary, no health check.
2418
+ const none = Boolean(S.state && !S.state.accounts.length)
2419
+ for (const id of ['summary', 'accounts-page', 'checkup']) $(id).hidden = away || (none && id !== 'accounts-page')
2113
2420
  const onTune = away && curPage === 'tuning'
2114
2421
  const onHandoff = away && curPage === 'handoff'
2115
2422
  $('tuning-page').hidden = !onTune
@@ -2254,9 +2561,12 @@ const MAIN = String.raw `
2254
2561
  h('p', { class: 'muted small' }, t('nf.intro')),
2255
2562
  h('fieldset', { class: 'field fam-field' }, h('legend', null, t('nf.tool')), nf.fams.length ? h('div', { class: 'fam-grid' }, cards) : h('p', { class: 'muted small' }, t('empty.noFamilies'))),
2256
2563
  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,
2564
+ // The first screen asks only for the tool and a name; the rest waits behind "More options".
2565
+ h('details', { class: 'more-opts', open: nf.api || Boolean(nf.alias) || Boolean(nf.error) || undefined },
2566
+ h('summary', null, t('nf.more')),
2567
+ 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),
2568
+ h('div', { class: 'check-row' }, h('label', { class: 'check' }, e.api, h('span', null, h('b', null, t('nf.api')), h('span', { class: 'muted small' }, t('nf.apiHint')))), tip('api')),
2569
+ e.preview),
2260
2570
  e.error,
2261
2571
  e.errNote = nf.error ? errorNote(nf.error, create, nf.error.code === 'create-refused' && !nf.api ? t('nf.refusedApi') : null) : null)
2262
2572
  setKids(el, dialogFrame(t('nf.title'), null, body, [
@@ -3019,6 +3329,8 @@ const MAIN = String.raw `
3019
3329
  const out = []
3020
3330
  if (!LANG_FIXED) out.push('lang')
3021
3331
  if (BOOT.colorScheme === 'auto') out.push('theme')
3332
+ if (S.state && S.state.accounts.length && !BOOT.readOnly) out.push('order')
3333
+ out.push('ai')
3022
3334
  if (S.state && rcSummary()) out.push('rc')
3023
3335
  if (S.state && ahState()) out.push('ah')
3024
3336
  if (S.state && (S.state.quotaChecks || []).length) out.push('checks')
@@ -3044,7 +3356,19 @@ const MAIN = String.raw `
3044
3356
  h('span', { class: 'menu-label' }, label), h('span', { class: 'chip chip-sm ' + cls }, state), icon('chevron'))
3045
3357
  if (secs.includes('lang')) out.push(seg(t('set.lang'), [['en', 'English'], ['zh', '中文']], lang, setLang))
3046
3358
  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' }))
3359
+ if (out.length && (secs.includes('order') || secs.includes('rc') || secs.includes('ah'))) out.push(h('div', { class: 'menu-sep', role: 'separator' }))
3360
+ // Everything here can also be asked of an AI assistant through the sideby skill (spec §3.8).
3361
+ if (secs.includes('ai')) out.push(link(t('ai.title'), t(skillInstalled() ? 'ai.installed' : 'ai.notInstalled'), skillInstalled() ? 'chip-ok' : '', openAi))
3362
+ // The arrange mode (spec §3.25) has a place here too, where people look for "how do I change this".
3363
+ if (secs.includes('order')) {
3364
+ const pins = Object.values(displayOf().pinned || {}).some((x) => x.length)
3365
+ out.push(link(t('set.order'), t(ordered() || pins ? 'set.orderMine' : 'set.orderDefault'), '', () => {
3366
+ setPage('accounts')
3367
+ startArrange()
3368
+ const bar = document.querySelector('#accounts .arrange-bar')
3369
+ if (bar) bar.scrollIntoView({ behavior: REDUCED ? 'auto' : 'smooth', block: 'center' })
3370
+ }))
3371
+ }
3048
3372
  if (secs.includes('rc')) {
3049
3373
  const sum = rcSummary()
3050
3374
  out.push(link(t('rc.button'), t('rc.state.' + sum), sum === 'on' ? 'chip-warn' : sum === 'off' ? 'chip-ok' : '', openRc))
@@ -3089,6 +3413,25 @@ const MAIN = String.raw `
3089
3413
  applyTheme()
3090
3414
  refreshPopover('settings')
3091
3415
  }
3416
+ // The sideby Agent Skill: what to say to an AI assistant instead of clicking, and how to install it when needed.
3417
+ function skillInstalled() {
3418
+ const u = S.state && S.state.update
3419
+ return Boolean(u && u.skillUpdate && u.skillUpdate.installed && u.skillUpdate.installed.length)
3420
+ }
3421
+ const AI_ASKS = ['ai.ask.quota', 'ai.ask.new', 'ai.ask.pin', 'ai.ask.fix', 'ai.ask.handoff', 'ai.ask.rc']
3422
+ function openAi() {
3423
+ showDialog('ai')
3424
+ const asks = AI_ASKS.map((k) => {
3425
+ const text = t(k)
3426
+ return h('li', null, h('button', { class: 'ai-ask', type: 'button', title: t('ai.copy'), onclick: () => copyText(text) }, icon('copy'), h('span', null, text)))
3427
+ })
3428
+ setKids(dlg.el, dialogFrame(t('ai.title'), null, h('div', { class: 'form' },
3429
+ h('p', null, t('ai.intro')),
3430
+ skillInstalled() ? h('p', { class: 'note' }, t('ai.ready')) : h('div', { class: 'field' }, h('p', { class: 'small' }, t('ai.install')), codeCopy(BOOT.skillInstall)),
3431
+ h('p', { class: 'small muted' }, t('ai.examples')),
3432
+ h('ul', { class: 'ai-asks' }, asks)),
3433
+ [h('button', { class: 'btn', type: 'button', onclick: () => dlg.el.close() }, t('btn.close'))]))
3434
+ }
3092
3435
  function openRc() {
3093
3436
  if (!S.state || !rcSummary()) return
3094
3437
  rc.target = null
@@ -3226,6 +3569,17 @@ const MAIN = String.raw `
3226
3569
  // build and reloads (a background Panel restarts itself, a Portal loads the new code by itself).
3227
3570
  let up = null
3228
3571
  function updState() { return BOOT.updates && S.state ? S.state.update || null : null }
3572
+ // The skill agents read is part of the version (spec §3.23): behind a current CLI it is the one thing to update.
3573
+ const isLocal = (u) => u.method.kind === 'local'
3574
+ const skillOld = (u) => !!(u && !u.available && !isLocal(u) && u.skillUpdate && u.skillUpdate.outdated)
3575
+ const skillDue = (u) => skillOld(u) && u.skillUpdate.canRun
3576
+ function skillFor(u) {
3577
+ const v = u.skillUpdate.installed.find((x) => x !== u.current)
3578
+ return v ? v : t('upd.skillNoVer')
3579
+ }
3580
+ // Why sideby cannot do something itself, in the viewer's language; the English reason when the code is unknown.
3581
+ const why = (o) => (o && o.reasonCode ? t('why.' + o.reasonCode, { at: o.reasonAt || '' }) : (o && o.reason) || '')
3582
+ const offText = (u) => (u.checksOffCode ? t('off.' + u.checksOffCode, { why: u.checksOff }) : t('upd.checksOff', { why: u.checksOff }))
3229
3583
  function renderUpdateButton() {
3230
3584
  const b = $('update')
3231
3585
  const u = updState()
@@ -3234,27 +3588,29 @@ const MAIN = String.raw `
3234
3588
  const v = $('ver-top')
3235
3589
  v.hidden = !BOOT.version
3236
3590
  if (!v.hidden) {
3237
- const newer = !!(u && u.available)
3591
+ const newer = !!(u && (u.available || skillDue(u)))
3238
3592
  v.textContent = verLabel()
3239
3593
  v.classList.toggle('has-update', newer)
3240
3594
  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 })
3595
+ const tip = !BOOT.updates ? '' : u && u.available ? t('upd.labelLong', { v: u.latest, cur: u.current })
3596
+ : skillDue(u) ? t('upd.skillLabelLong', { s: skillFor(u), cur: u.current }) : t('upd.verTitle', { v: BOOT.version })
3242
3597
  v.title = tip
3243
3598
  if (tip) v.setAttribute('aria-label', tip)
3244
3599
  else v.removeAttribute('aria-label')
3245
3600
  }
3246
- b.hidden = !(u && u.available)
3601
+ b.hidden = !(u && (u.available || skillDue(u)))
3247
3602
  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 })
3603
+ const long = u.available ? t('upd.labelLong', { v: u.latest, cur: u.current }) : t('upd.skillLabelLong', { s: skillFor(u), cur: u.current })
3604
+ setKids(b, [icon('arrowUp'), h('span', null, u.available ? t('upd.label', { v: u.latest }) : t('upd.skillLabel'))])
3605
+ b.setAttribute('aria-label', long)
3606
+ b.title = long
3251
3607
  }
3252
3608
  if (dlg.kind === 'update' && up && !up.busy) renderUpdate()
3253
3609
  if (!vp.checking) renderVerPop()
3254
3610
  }
3255
3611
  function openUpdate() {
3256
3612
  if (!BOOT.updates) return
3257
- up = { busy: false, error: null, done: null, changed: false, autoPlan: null, autoBusy: false, waitUntil: 0, checking: false, checked: false }
3613
+ up = { busy: false, error: null, done: null, changed: false, skillDone: false, skillChanged: false, autoPlan: null, autoBusy: false, waitUntil: 0, checking: false, checked: false }
3258
3614
  showDialog('update')
3259
3615
  renderUpdate()
3260
3616
  }
@@ -3281,6 +3637,27 @@ const MAIN = String.raw `
3281
3637
  dlg.busy = false
3282
3638
  renderUpdate()
3283
3639
  }
3640
+ // The CLI is current and the skill is behind: update the skill alone, as sideby update --yes does.
3641
+ async function updSkillRun() {
3642
+ up.busy = true
3643
+ dlg.busy = true
3644
+ up.error = null
3645
+ up.skillChanged = false
3646
+ renderUpdate()
3647
+ try {
3648
+ const r = await api('/api/update', { confirm: true, skill: true })
3649
+ if (r.skillUpdated) {
3650
+ up.skillDone = true
3651
+ toast(t('upd.skillDoneOnly'))
3652
+ // The health check still lists the old skill: check again so its Findings go.
3653
+ if ((findingsNow() || []).some((f) => f.code === 'skill.outdated')) runCheck()
3654
+ } else up.skillChanged = true
3655
+ await load({ silent: true })
3656
+ } catch (e) { up.error = e }
3657
+ up.busy = false
3658
+ dlg.busy = false
3659
+ renderUpdate()
3660
+ }
3284
3661
  // The version pill opens a small popover, not the dialog: the version, whether it is the newest, "Check for
3285
3662
  // updates" in place, and the way into the dialog when there is something to do (update, Auto Update).
3286
3663
  const vp = { checking: false, checked: false, error: null }
@@ -3304,9 +3681,9 @@ const MAIN = String.raw `
3304
3681
  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
3682
  let status
3306
3683
  if (!u) status = t('upd.loading')
3307
- else if (u.available) status = null
3684
+ else if (u.available || skillOld(u)) status = null
3308
3685
  else if (vp.checked && u.latest) status = t('upd.latestNow')
3309
- else if (u.checksOff) status = t('upd.checksOff', { why: u.checksOff })
3686
+ else if (u.checksOff) status = offText(u)
3310
3687
  else if (u.latest) status = t('upd.latest') + (u.checkedAt ? ' · ' + t('upd.checked', { ago: ago(u.checkedAt) }) : '')
3311
3688
  else status = t('upd.notYet')
3312
3689
  if (status) kids.push(h('p', { class: 'muted small' }, status))
@@ -3314,16 +3691,63 @@ const MAIN = String.raw `
3314
3691
  kids.push(h('p', { class: 'small' }, t('upd.available', { v: u.latest }), ' ',
3315
3692
  h('a', { href: u.releaseNotes, target: '_blank', rel: 'noopener noreferrer' }, t('upd.notes'))))
3316
3693
  kids.push(h('div', { class: 'popover-actions' }, h('button', { class: 'btn btn-sm btn-primary', type: 'button', onclick: toDialog }, t('upd.open'))))
3694
+ } else if (skillOld(u)) {
3695
+ kids.push(h('p', { class: 'small' }, t('upd.skillBehind', { s: skillFor(u) })))
3696
+ 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
3697
  } else if (!BOOT.readOnly) {
3318
3698
  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
3699
  vp.checking ? spinner() : icon('refresh'), vp.checking ? t('upd.checking') : t('upd.check'))))
3320
3700
  }
3321
3701
  if (vp.error) kids.push(errorNote(vp.error, null))
3322
- if (u && !BOOT.readOnly)
3702
+ const sw = localSwitchKids(u)
3703
+ if (sw) kids.push(sw)
3704
+ if (u && !BOOT.readOnly && !isLocal(u))
3323
3705
  kids.push(h('p', { class: 'muted small popover-foot' }, t(u.auto ? 'upd.autoIsOn' : 'upd.autoIsOff'), ' · ',
3324
3706
  h('button', { class: 'link-btn', type: 'button', onclick: toDialog }, t('upd.settings'))))
3325
3707
  return kids
3326
3708
  }
3709
+ // The switch between a Local Build and the npm package (ADR-0024), only where the state offers it: the other side,
3710
+ // one button. After the switch the page waits for the server to run the new build and reloads into it.
3711
+ const vs = { busy: false, to: null, waitUntil: 0, stuck: false, error: null }
3712
+ function localSwitchKids(u) {
3713
+ const ls = u && u.localSwitch
3714
+ if (!ls || BOOT.readOnly) return null
3715
+ // While it switches the state already shows the other side; keep the words of the side clicked.
3716
+ const toNpm = vs.busy ? vs.to === 'npm' : ls.running === 'local'
3717
+ if (!toNpm && !ls.local) return null
3718
+ const l = ls.local
3719
+ const build = l ? l.version + '-dev · ' + (l.commit || '?') + (l.dirty ? '+' : '') : ''
3720
+ const kids = [h('p', { class: 'muted small' }, toNpm ? t('loc.toNpmHint') : t('loc.toLocalHint', { b: build, at: l.builtAt ? clock(l.builtAt) : '' }))]
3721
+ if (vs.stuck) {
3722
+ kids.push(h('p', { class: 'small' }, t('loc.stuck')))
3723
+ kids.push(h('div', { class: 'popover-actions' }, h('button', { class: 'btn btn-sm btn-primary', type: 'button', onclick: () => location.reload() }, icon('refresh'), t('loc.reload'))))
3724
+ } else {
3725
+ kids.push(h('div', { class: 'popover-actions' }, h('button', { class: 'btn btn-sm', type: 'button', disabled: vs.busy, 'aria-busy': vs.busy ? 'true' : null, onclick: () => localSwitchTo(toNpm ? 'npm' : 'local') },
3726
+ vs.busy ? spinner() : icon('swap'), vs.busy ? t('loc.switching') : t(toNpm ? 'loc.toNpm' : 'loc.toLocal'))))
3727
+ }
3728
+ if (vs.error) kids.push(errorNote(vs.error, null))
3729
+ return h('div', { class: 'popover-foot' }, kids)
3730
+ }
3731
+ async function localSwitchTo(to) {
3732
+ vs.busy = true
3733
+ vs.to = to
3734
+ vs.error = null
3735
+ vs.stuck = false
3736
+ renderVerPop()
3737
+ try {
3738
+ await api('/api/local', { to, confirm: true })
3739
+ } catch (e) {
3740
+ // A background Panel restarting from the new code may drop this answer: wait for the build all the same.
3741
+ if (!e.network) { vs.busy = false; vs.error = e; renderVerPop(); return }
3742
+ }
3743
+ vs.waitUntil = Date.now() + 20000
3744
+ localWait()
3745
+ }
3746
+ function localWait() {
3747
+ if (S.newBuild) { location.reload(); return }
3748
+ if (Date.now() > vs.waitUntil) { vs.busy = false; vs.stuck = true; renderVerPop(); return }
3749
+ setTimeout(() => { void load({ silent: true }).then(localWait, localWait) }, 1500)
3750
+ }
3327
3751
  async function verPopCheck() {
3328
3752
  vp.checking = true
3329
3753
  vp.error = null
@@ -3381,13 +3805,17 @@ const MAIN = String.raw `
3381
3805
  up.autoBusy = false
3382
3806
  renderUpdate()
3383
3807
  }
3384
- // The installed sideby skill follows the CLI (spec §3.23): said before the Update, or what to run when sideby may not.
3808
+ // The installed sideby skill follows the CLI (spec §3.23): one quiet line when the Update brings it along, the
3809
+ // reason and the command only when sideby may not.
3385
3810
  function updSkill(u) {
3386
3811
  const s = u.skillUpdate
3387
- if (!s) return [h('p', { class: 'small' }, t('upd.skill')), codeCopy(u.skill)]
3388
- if (!s.outdated) return []
3812
+ if (!s || !s.outdated) return []
3389
3813
  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)]
3814
+ return [h('p', { class: 'small' }, t('upd.skillManaged', { why: why(s) })), codeCopy(s.command)]
3815
+ }
3816
+ // What a person runs by hand, behind a click: the default path is the button.
3817
+ function updManual(cmds) {
3818
+ return h('details', { class: 'tune-details' }, h('summary', null, t('upd.manual')), cmds)
3391
3819
  }
3392
3820
  function renderUpdate() {
3393
3821
  if (!up || dlg.kind !== 'update') return
@@ -3404,30 +3832,50 @@ const MAIN = String.raw `
3404
3832
  body.push(h('p', { class: 'muted small' }, Date.now() > up.waitUntil ? t('upd.restartHint') : [spinner(), ' ', t('upd.reloading')]))
3405
3833
  if (up.done.skillUpdated) body.push(h('p', { class: 'small' }, t('upd.skillDone')))
3406
3834
  else body.push(...updSkill(u))
3835
+ } else if (up.skillDone && !skillOld(u)) {
3836
+ body.push(h('p', { class: 'note' }, t('upd.skillDoneOnly')))
3837
+ } else if (u.available && isLocal(u)) {
3838
+ // A Local Build (ADR-0024): one way back to npm, which brings the skill along.
3839
+ body.push(h('p', null, t('upd.localAvail', { v: u.latest }), ' ',
3840
+ h('a', { href: u.releaseNotes, target: '_blank', rel: 'noopener noreferrer' }, t('upd.notes'))))
3841
+ body.push(codeCopy(u.command))
3407
3842
  } else if (u.available) {
3408
3843
  if (up.changed) body.push(h('p', { class: 'note note-warn' }, t('upd.changed', { v: u.latest })))
3409
3844
  body.push(h('p', null, h('b', null, t('upd.available', { v: u.latest })), ' ',
3410
3845
  h('a', { href: u.releaseNotes, target: '_blank', rel: 'noopener noreferrer' }, t('upd.notes'))))
3411
3846
  if (u.canRun) {
3412
- body.push(h('p', { class: 'small' }, t('upd.runs')), codeCopy(u.command))
3847
+ body.push(...updSkill(u))
3413
3848
  body.push(h('p', { class: 'muted small' }, t('upd.after')))
3849
+ body.push(updManual([h('p', { class: 'small' }, t('upd.runs')), codeCopy(u.command)]))
3414
3850
  } else {
3415
- body.push(h('p', { class: 'note note-warn' }, t('upd.cannot', { why: u.method.reason || u.method.kind })))
3851
+ body.push(h('p', { class: 'note note-warn' }, t('upd.cannot', { why: why(u.method) || u.method.kind })))
3416
3852
  body.push(codeCopy(u.command))
3853
+ // An install by hand leaves the skill behind; sideby update (or Auto Update) brings it along afterwards.
3854
+ const s = u.skillUpdate
3855
+ if (s && s.outdated && s.canRun) body.push(h('p', { class: 'muted small' }, t('upd.skillLater')))
3856
+ else body.push(...updSkill(u))
3417
3857
  }
3418
- body.push(...updSkill(u))
3858
+ } else if (skillOld(u)) {
3859
+ if (up.skillChanged) body.push(h('p', { class: 'note note-warn' }, t('upd.skillChanged')))
3860
+ const s = u.skillUpdate
3861
+ body.push(h('p', null, h('b', null, t('upd.skillBehind', { s: skillFor(u) }))))
3862
+ if (s.canRun) {
3863
+ body.push(h('p', { class: 'muted small' }, t('upd.skillWhat', { cur: u.current })))
3864
+ body.push(updManual([codeCopy(s.command)]))
3865
+ } else body.push(h('p', { class: 'small' }, t('upd.skillManaged', { why: why(s) })), codeCopy(s.command))
3419
3866
  } else if (up.checked && u.latest) {
3420
3867
  body.push(h('p', { class: 'note' }, t('upd.latestNow')))
3421
3868
  } else if (u.checksOff) {
3422
- body.push(h('p', { class: 'muted small' }, t('upd.checksOff', { why: u.checksOff })))
3869
+ body.push(h('p', { class: 'muted small' }, offText(u)))
3423
3870
  } else if (u.latest) {
3424
3871
  body.push(h('p', { class: 'muted small' }, t('upd.latest') + (u.checkedAt ? ' · ' + t('upd.checked', { ago: ago(u.checkedAt) }) : '')))
3425
3872
  } else body.push(h('p', { class: 'muted small' }, t('upd.notYet')))
3426
3873
  // 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)
3874
+ if (!BOOT.readOnly && !up.done && !u.available && !skillDue(u))
3428
3875
  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
3876
  up.checking ? spinner() : icon('refresh'), up.checking ? t('upd.checking') : t('upd.check'))))
3430
- if (!BOOT.readOnly && !up.done) {
3877
+ if (!BOOT.readOnly && !up.done && isLocal(u)) body.push(h('p', { class: 'muted small' }, t('upd.autoLocal')))
3878
+ else if (!BOOT.readOnly && !up.done) {
3431
3879
  const box = h('input', { type: 'checkbox', disabled: up.autoBusy || !u.method.canUpdate })
3432
3880
  box.checked = up.autoPlan ? up.autoPlan.auto : u.auto
3433
3881
  box.addEventListener('change', () => updAuto(box.checked))
@@ -3439,8 +3887,9 @@ const MAIN = String.raw `
3439
3887
  if (BOOT.readOnly) body.push(h('p', { class: 'note' }, t('banner.readOnly')))
3440
3888
  if (up.error) body.push(errorNote(up.error, null))
3441
3889
  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 })))
3890
+ const primary = (label, run) => h('button', { class: 'btn btn-primary', type: 'button', disabled: up.busy, 'aria-busy': up.busy ? 'true' : null, onclick: run }, up.busy ? spinner() : null, up.busy ? t('upd.updating') : label)
3891
+ if (u.available && u.canRun && !BOOT.readOnly && !up.done) foot.push(primary(t('upd.now', { v: u.latest }), updRun))
3892
+ else if (skillDue(u) && !BOOT.readOnly && !up.done) foot.push(primary(t('upd.skillNow'), updSkillRun))
3444
3893
  setKids(dlg.el, dialogFrame(t('upd.title'), null, body, foot))
3445
3894
  }
3446
3895
 
@@ -3453,6 +3902,11 @@ const MAIN = String.raw `
3453
3902
  $('refresh').addEventListener('click', refreshAll)
3454
3903
  $('accounts').addEventListener('dragover', onDragOver)
3455
3904
  $('accounts').addEventListener('drop', onDrop)
3905
+ // Escape leaves the arrange mode, as "Done" does, unless a menu or dialog takes it first.
3906
+ document.addEventListener('keydown', (e) => {
3907
+ if (e.key !== 'Escape' || e.defaultPrevented || !ui.arrange || menu.el || (dlg.el && dlg.el.open) || e.target.closest('input, select, textarea')) return
3908
+ endArrange()
3909
+ })
3456
3910
  $('settings').addEventListener('click', () => openPopover($('settings'), 'settings', t('set.button'), settingsPop, 'end'))
3457
3911
  applyTheme()
3458
3912
  renderStatic()