sideby 0.13.0 → 0.14.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 (78) hide show
  1. package/README.md +1 -1
  2. package/dist/build.json +1 -1
  3. package/dist/src/cli/commands.d.ts +3 -2
  4. package/dist/src/cli/commands.js +69 -8
  5. package/dist/src/cli/format.js +7 -3
  6. package/dist/src/cli/json-schemas.js +62 -2
  7. package/dist/src/cli/main.js +42 -4
  8. package/dist/src/core/config.d.ts +27 -0
  9. package/dist/src/core/config.js +48 -1
  10. package/dist/src/core/handoff-enable.js +10 -5
  11. package/dist/src/core/launch.d.ts +5 -0
  12. package/dist/src/core/launch.js +14 -3
  13. package/dist/src/core/quota-fetch.d.ts +22 -0
  14. package/dist/src/core/quota-fetch.js +98 -0
  15. package/dist/src/core/quota-levels.d.ts +3 -2
  16. package/dist/src/core/quota-levels.js +8 -4
  17. package/dist/src/core/quota-store.d.ts +19 -0
  18. package/dist/src/core/quota-store.js +62 -0
  19. package/dist/src/core/skill-check.d.ts +15 -0
  20. package/dist/src/core/skill-check.js +49 -3
  21. package/dist/src/core/update.d.ts +45 -0
  22. package/dist/src/core/update.js +72 -1
  23. package/dist/src/families/grok/handoff-hook.d.ts +6 -0
  24. package/dist/src/families/grok/handoff-hook.js +45 -2
  25. package/dist/src/families/grok/handoff.d.ts +6 -1
  26. package/dist/src/families/grok/handoff.js +30 -13
  27. package/dist/src/families/grok/index.js +5 -0
  28. package/dist/src/families/grok/quota-log.d.ts +30 -0
  29. package/dist/src/families/grok/quota-log.js +140 -0
  30. package/dist/src/families/grok/quota.d.ts +5 -0
  31. package/dist/src/families/grok/quota.js +112 -0
  32. package/dist/src/panel/handler.d.ts +9 -3
  33. package/dist/src/panel/handler.js +65 -3
  34. package/dist/src/panel/i18n.d.ts +25 -5
  35. package/dist/src/panel/i18n.js +52 -12
  36. package/dist/src/panel/page-logic.d.ts +18 -2
  37. package/dist/src/panel/page-logic.js +53 -6
  38. package/dist/src/panel/page-script.js +286 -92
  39. package/dist/src/panel/page-styles.js +30 -10
  40. package/dist/src/runtime.d.ts +30 -2
  41. package/dist/src/runtime.js +64 -21
  42. package/dist/src/types.d.ts +49 -1
  43. package/package.json +2 -2
  44. package/schemas/alias.json +62 -0
  45. package/schemas/app-install.json +62 -0
  46. package/schemas/app-uninstall.json +62 -0
  47. package/schemas/browser-open.json +62 -0
  48. package/schemas/browser-switch.json +62 -0
  49. package/schemas/browser.json +62 -0
  50. package/schemas/config.json +46 -0
  51. package/schemas/desktop-remove.json +62 -0
  52. package/schemas/desktop.json +62 -0
  53. package/schemas/doctor.json +62 -0
  54. package/schemas/error.json +62 -0
  55. package/schemas/families.json +62 -0
  56. package/schemas/handoff-disable.json +62 -0
  57. package/schemas/handoff-enable.json +62 -0
  58. package/schemas/handoff-ready.json +62 -0
  59. package/schemas/handoff-setup.json +62 -0
  60. package/schemas/handoff-status.json +62 -0
  61. package/schemas/handoff-teardown.json +62 -0
  62. package/schemas/list.json +62 -0
  63. package/schemas/model.json +62 -0
  64. package/schemas/new.json +62 -0
  65. package/schemas/next.json +62 -0
  66. package/schemas/plugins.json +62 -0
  67. package/schemas/quota-setup.json +62 -0
  68. package/schemas/quota-teardown.json +62 -0
  69. package/schemas/quota.json +76 -1
  70. package/schemas/remote-control.json +62 -0
  71. package/schemas/tune-change.json +62 -0
  72. package/schemas/tune.json +62 -0
  73. package/schemas/update.json +107 -1
  74. package/skills/sideby/SKILL.md +4 -2
  75. package/skills/sideby/references/diagnose.md +3 -2
  76. package/skills/sideby/references/handoff.md +1 -1
  77. package/skills/sideby/references/quota.md +5 -1
  78. package/skills/sideby/references/update.md +16 -0
@@ -1,6 +1,6 @@
1
1
  // The Panel page's inline script. Plain browser JavaScript in a string (no template literals inside, so String.raw
2
2
  // keeps it verbatim); the pure helpers from page-logic.ts are prepended so the tested code is the code that runs.
3
- import { aliasArgsOf, aliasIssue, attentionReasons, attentionWhy, cacheHitRate, formatDuration, handoffChangeOf, handoffHintAccount, loginClusters, loginKey, maskEmail, matchesQuery, mergeDaily, newAccountDir, nextReset, panelHint, panelLang, passedWindow, quotaPressure, remoteControlSummary, remoteControlTodo, sortAccounts, suggestAlias, suggestName, updatedAge, windowState, } from './page-logic.js';
3
+ import { aliasArgsOf, aliasIssue, attentionReasons, attentionWhy, cacheHitRate, 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';
4
4
  const LOGIC = [
5
5
  aliasArgsOf,
6
6
  aliasIssue,
@@ -10,6 +10,7 @@ const LOGIC = [
10
10
  formatDuration,
11
11
  handoffChangeOf,
12
12
  handoffHintAccount,
13
+ inDisplayOrder,
13
14
  loginClusters,
14
15
  loginKey,
15
16
  maskEmail,
@@ -23,6 +24,7 @@ const LOGIC = [
23
24
  quotaPressure,
24
25
  remoteControlSummary,
25
26
  remoteControlTodo,
27
+ reorder,
26
28
  sortAccounts,
27
29
  suggestAlias,
28
30
  suggestName,
@@ -57,14 +59,14 @@ const MAIN = String.raw `
57
59
  silent: false, lastTry: 0, pending: false, fastUntil: 0, fastFamily: null,
58
60
  }
59
61
  // View state that is not saved: the search text, the attention filter, the Health check filter.
60
- const ui = { query: '', attention: false, allFindings: false, healthRef: null, checkingRef: null, desktopRef: null, browserRef: null, appBusy: null, appRemove: null, appNew: null, highlight: null, fade: true, loginRefs: new Set() }
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 }
61
63
  const $ = (id) => document.getElementById(id)
62
64
 
63
65
  // ---------- preferences ----------
64
66
  // Kept in localStorage, which can be missing or throw (private windows, blocked storage): every access is guarded
65
67
  // and the defaults apply.
66
68
  const PREFS_KEY = 'sideby.panel.v2'
67
- const CHOICES = { view: ['list', 'cards'], sort: ['pressure', 'name', 'recent'], quota: ['used', 'left'], theme: ['system', 'light', 'dark'], lang: ['en', 'zh'] }
69
+ const CHOICES = { view: ['list', 'cards'], sort: ['pressure', 'name', 'recent', 'manual'], quota: ['used', 'left'], theme: ['system', 'light', 'dark'], lang: ['en', 'zh'] }
68
70
  const prefs = loadPrefs()
69
71
  function loadPrefs() {
70
72
  let raw = {}
@@ -235,6 +237,8 @@ const MAIN = String.raw `
235
237
  function uiBusy() {
236
238
  // A row or group in the middle of folding; a render now would cut the motion short.
237
239
  for (const el of folding) if (el.isConnected) return true
240
+ // A row or group being dragged into a new place (spec §3.25).
241
+ if (ui.drag) return true
238
242
  if (menu.el || (dlg.el && dlg.el.open) || document.querySelector('.tip.is-open')) return true
239
243
  // A plan on the Auto Handoff page, and the change a person opened to read, stay as they are.
240
244
  if (!$('handoff-page').hidden && (hp.preview || hp.askSame || document.querySelector('#handoff-page details[open]'))) return true
@@ -249,6 +253,7 @@ const MAIN = String.raw `
249
253
  search: 'M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM20 20l-4.2-4.2',
250
254
  chevron: 'M9 6l6 6-6 6',
251
255
  more: 'M5 12h.01M12 12h.01M19 12h.01',
256
+ grip: 'M9 6h.01M9 12h.01M9 18h.01M15 6h.01M15 12h.01M15 18h.01',
252
257
  plus: 'M12 5v14M5 12h14',
253
258
  sun: 'M12 16a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4',
254
259
  moon: 'M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5z',
@@ -779,15 +784,17 @@ const MAIN = String.raw `
779
784
  CHOICES.sort.map((v) => h('option', { value: v }, t('sort.' + v))))
780
785
  tb.sort.value = prefs.sort
781
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'))
782
788
  const display = h('button', { class: 'btn tb-display', type: 'button', 'aria-haspopup': 'dialog', 'aria-expanded': 'false', title: t('tb.display') }, icon('sliders'), h('span', null, t('tb.display')))
783
789
  display.addEventListener('click', () => openPopover(display, 'display', t('tb.display'), displayPanel, 'end'))
784
790
  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')))
785
791
  setKids($('toolbar'), [
786
792
  h('label', { class: 'search' }, icon('search', 'search-ic'), tb.search, h('kbd', { class: 'kbd', 'aria-hidden': 'true' }, '/')),
787
- h('div', { class: 'tools' }, tb.family, tb.sort, display),
793
+ h('div', { class: 'tools' }, tb.family, tb.sort, tb.reset, display),
788
794
  create,
789
795
  ])
790
796
  renderFamilyFilter()
797
+ renderOrderReset()
791
798
  if (hadFocus) tb.search.focus()
792
799
  }
793
800
  // "Display": the view settings people change rarely, one popover instead of three buttons in the toolbar.
@@ -829,12 +836,16 @@ const MAIN = String.raw `
829
836
  // One column per Quota window label. 5h and 7d always have one, so rows of every group line up under the same
830
837
  // header; a label only some account reports (such as 60m) adds a column, at most three in all.
831
838
  const KNOWN_WINDOWS = { '5h': 300, '7d': 10080 }
839
+ // A window's column: the models it covers when it covers only some (Cursor's "Other models"), else its length.
840
+ function winKey(w) { return w.name || w.label }
841
+ // One group's columns: the windows its accounts report, shortest first; 5h and 7d when none reports any yet.
832
842
  function quotaColumns(list) {
833
- const mins = Object.assign({}, KNOWN_WINDOWS)
843
+ const mins = {}
834
844
  for (const a of list)
835
845
  if (a.quota && a.quota.status === 'ok')
836
- for (const w of a.quota.windows || []) if (!Object.hasOwn(mins, w.label)) mins[w.label] = Number(w.windowMinutes) || 0
837
- return Object.keys(mins).sort((x, y) => mins[x] - mins[y]).slice(0, 3)
846
+ for (const w of a.quota.windows || []) if (!Object.hasOwn(mins, winKey(w))) mins[winKey(w)] = (Number(w.windowMinutes) || 0) + (w.name ? -0.5 : 0)
847
+ if (!Object.keys(mins).length) Object.assign(mins, KNOWN_WINDOWS)
848
+ return Object.keys(mins).sort((x, y) => mins[x] - mins[y]).slice(0, 4)
838
849
  }
839
850
  // A window's cell: percent and countdown on one line, the bar under them. The label shows where no column
840
851
  // header does (cards, narrow screens).
@@ -843,13 +854,15 @@ const MAIN = String.raw `
843
854
  const passed = passedWindow(w, observedAt, now)
844
855
  const left = prefs.quota === 'left'
845
856
  const shown = left ? 100 - st.pct : st.pct
846
- const title = passed ? t('quota.resetNoData', { date: day(passed.since) }) : t('quota.resetsAt', { at: clock(w.resetsAt) })
857
+ const asOf = stale ? t('quota.asOf', { ago: ago(observedAt) }) : ''
858
+ const title = [passed ? t('quota.resetNoData', { date: day(passed.since) }) : t('quota.resetsAt', { at: clock(w.resetsAt) }), asOf].filter(Boolean).join(' · ')
847
859
  return h('div', { class: 'meter lvl-' + st.level + (stale || passed ? ' lvl-stale' : ''), title },
848
860
  h('div', { class: 'm-top' },
849
- h('span', { class: 'm-label' }, w.label),
850
- h('span', { class: 'm-pct' }, shown + '%', h('span', { class: 'm-unit' }, ' ' + t(left ? 'quota.unitLeft' : 'quota.unitUsed'))),
861
+ h('span', { class: 'm-label' }, winKey(w)),
862
+ 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),
851
864
  h('span', { class: 'm-reset' + (passed ? ' m-passed' : '') }, passed ? t('quota.resetShort') : countdown(w.resetsAt, now))),
852
- h('div', { class: 'bar', role: 'meter', 'aria-label': t(left ? 'quota.meterLeft' : 'quota.meterUsed', { label: w.label }), 'aria-valuemin': '0', 'aria-valuemax': '100', 'aria-valuenow': String(shown), 'aria-valuetext': t(left ? 'quota.pctLeft' : 'quota.pctUsed', { n: shown }) }, h('span', { css: { width: shown + '%' } })))
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 + '%' } })))
853
866
  }
854
867
  // A column whose window this account does not report right now, such as a 5h window that has not started.
855
868
  function noMeter(label) {
@@ -859,6 +872,9 @@ const MAIN = String.raw `
859
872
  function quotaNote(a, q) {
860
873
  const setup = setupFor(a.family)
861
874
  // One setup turns quota on for every account of the Family, so the switch lives in the group header only.
875
+ // A check that asks the Host (Grok) and is turned off, or failed (spec §3.26): say why and what to do.
876
+ if (q.reason === 'fetch-failed') return h('div', { class: 'q-note q-off' }, h('span', { class: 'muted' }, t('quota.fetchFailed')), tipRaw(t('quota.fetchFailed'), q.detail || ''))
877
+ if (q.reason === 'not-enabled' && checkOf(a.family)) return h('div', { class: 'q-note q-off' }, h('span', { class: 'muted' }, t('quota.checkOff')), tipRaw(t('quota.checkOff'), q.detail || t('quota.checkOffHint')))
862
878
  if (q.reason === 'not-enabled') {
863
879
  const host = famTitle(a)
864
880
  const body = setup && setup.plan.status === 'ready' && !BOOT.readOnly ? t('tip.quotaOff.body', { host })
@@ -870,22 +886,23 @@ const MAIN = String.raw `
870
886
  const text = { 'no-session': 'quota.noSession', 'no-source': 'quota.noSource', unrecognized: 'quota.unrecognized' }[q.reason]
871
887
  return h('div', { class: 'q-note', title: q.reason === 'no-session' ? t('quota.noSessionHint') : q.detail || null }, text ? t(text) : t('quota.unavailable', { reason: q.reason }))
872
888
  }
873
- // readFrom: the Account a login group's head shows the quota of, named on hover instead of "same login as".
874
- function quotaBlock(a, now, cols, readFrom) {
889
+ function quotaBlock(a, now, cols) {
875
890
  const q = a.quota
876
891
  if (!q) return h('div', { class: 'quota' }, h('div', { class: 'q-note' }, '—'))
877
892
  if (q.status !== 'ok') return h('div', { class: 'quota' }, quotaNote(a, q))
878
893
  const stale = isStale(q.observedAt, now)
879
894
  const wins = q.windows || []
880
895
  // A reading borrowed from another Account of the same login says where it came from (spec §3.21).
881
- const from = readFrom ? t('quota.readFrom', { name: refName(readFrom), ago: ago(q.observedAt) })
882
- : q.from ? t('quota.sharedFrom', { name: refName(q.from), ago: ago(q.observedAt) }) : null
883
- return h('div', { class: 'quota' + (stale ? ' is-stale' : ''), title: from || (stale ? null : t('quota.dataFrom', { ago: ago(q.observedAt) })) },
896
+ const from = q.from ? t('quota.sharedFrom', { name: refName(q.from), ago: ago(q.observedAt) }) : null
897
+ // Where the numbers came from when the Host was asked (spec §3.26), and why the latest ask failed.
898
+ const via = q.fetched ? t('quota.fetchedFrom', { host: famTitle(a), ago: ago(q.observedAt) }) : null
899
+ const failed = q.fetchError ? t('quota.lastCheckFailed', { why: q.fetchError }) : null
900
+ const title = [from || via || (stale ? null : t('quota.dataFrom', { ago: ago(q.observedAt) })), failed].filter(Boolean).join('\n') || null
901
+ return h('div', { class: 'quota' + (stale ? ' is-stale' : ''), title },
884
902
  wins.length ? cols.map((c) => {
885
- const w = wins.find((x) => x.label === c)
903
+ const w = wins.find((x) => winKey(x) === c)
886
904
  return w ? meter(w, now, stale, q.observedAt) : noMeter(c)
887
- }) : h('div', { class: 'q-note' }, t('quota.noWindows')),
888
- stale ? h('div', { class: 'q-asof' }, icon('clock'), t('quota.asOf', { ago: ago(q.observedAt) })) : null)
905
+ }) : h('div', { class: 'q-note' }, t('quota.noWindows')))
889
906
  }
890
907
  function usageBlock(a) {
891
908
  const u = a.usage
@@ -921,9 +938,12 @@ const MAIN = String.raw `
921
938
  }
922
939
  // A dot after the name when the account needs a look that no tag shows: a fuller quota window or a Health
923
940
  // finding. Its reason is on hover; the details say more.
924
- // Inside a login group the quota warning is the head's, so a row's dot is only about the row itself.
941
+ // Inside a login group the quota warning is the first row's, so another row's dot is only about the row itself.
925
942
  function attentionDot(a) {
926
- const subject = inLogin(a) ? Object.assign({}, a, { quota: undefined }) : a
943
+ const lead = loginLead(a)
944
+ const subject = !lead ? a
945
+ : lead !== a.ref ? Object.assign({}, a, { quota: undefined })
946
+ : Object.assign({}, a, { quota: a.quota && Object.assign({}, a.quota, { from: undefined }) })
927
947
  const r = reasonsOf(subject, Date.now())
928
948
  const lvl = r.includes('health-fail') ? 'fail' : r.includes('quota-high') || r.includes('health-warn') ? 'warn' : ''
929
949
  if (!lvl) return null
@@ -947,6 +967,8 @@ const MAIN = String.raw `
947
967
  : null,
948
968
  a.hostInstalled === false ? h('span', { class: 'tag tag-warn' }, t(family(a.family).brokenBin ? 'acct.broken' : 'acct.notInstalled')) : null)
949
969
  }
970
+ // An organization name as the vendor wrote it, without a trailing comma or full stop it sometimes carries.
971
+ function orgText(org) { return org ? String(org).replace(/[\s,,;;、。]+$/u, '') : '' }
950
972
  // The name part of a ref (claude:001 gives 001), as rows show other Accounts of the same Family.
951
973
  function refName(ref) { return String(ref).slice(String(ref).indexOf(':') + 1) }
952
974
  // Who the account is signed in as, under its name; masked while "Hide emails" is on.
@@ -954,7 +976,7 @@ const MAIN = String.raw `
954
976
  const id = a.identity
955
977
  if (!id || (!id.email && !browserButton(a))) return null
956
978
  const email = id.email ? emailText(id.email) : ''
957
- return h('div', { class: 'ident-email', title: [email, id.org].filter(Boolean).join(' · ') },
979
+ return h('div', { class: 'ident-email', title: [email, orgText(id.org)].filter(Boolean).join(' · ') },
958
980
  dots([email ? h('button', { class: 'email email-btn', type: 'button', title: t('menu.copyEmail'), 'aria-label': t('menu.copyEmail') + ' ' + email, onclick: (e) => { e.stopPropagation(); copyText(id.email) } }, email) : null, browserButton(a)]))
959
981
  }
960
982
  function subLine(a) {
@@ -1101,10 +1123,11 @@ const MAIN = String.raw `
1101
1123
  }
1102
1124
  // What a row or card shows depends on more than the account (health, view preferences, language); the
1103
1125
  // fingerprint covers what its click handlers use.
1104
- function rowSig(a) { return sig([a, healthOf(a.ref), isNext(a), ui.desktopRef === a.ref, ui.browserRef === a.ref, browserFor(a), prefs.hideEmail, isOpen(a), inLogin(a)]) }
1105
- // Whether the row sits in a login group (spec §3.21): the group's head then shows the email and the quota.
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)]) }
1127
+ // Whether the row sits in a login group (spec §3.21): its first row then shows the quota for all of them.
1106
1128
  function inLogin(a) { return ui.loginRefs.has(a.ref) }
1107
- function rowEmail(a) { return inLogin(a) ? null : emailLine(a) }
1129
+ // The group's first row, which shows the quota for all of them.
1130
+ function loginLead(a) { return ui.loginRefs.get(a.ref) || null }
1108
1131
  // A row shows who the account is, its quota and its last use; a click opens the details under it (model, tokens,
1109
1132
  // Health) and closes them again. The choice is kept in this browser's preferences.
1110
1133
  function isOpen(a) { return prefs.open[a.ref] === true }
@@ -1176,7 +1199,7 @@ const MAIN = String.raw `
1176
1199
  el.dataset.sig = rowSig(a)
1177
1200
  let detail = rowDetails(el)[0]
1178
1201
  if (open && !detail) {
1179
- detail = rowDetail(a, Boolean(rowEmail(a)))
1202
+ detail = rowDetail(a, Boolean(emailLine(a)))
1180
1203
  const more = el.querySelector(':scope > .more')
1181
1204
  el.insertBefore(detail, more ? more.nextSibling : null)
1182
1205
  detail.style.opacity = '0'
@@ -1215,72 +1238,34 @@ const MAIN = String.raw `
1215
1238
  ease(now, from, [], parts, 1, () => {})
1216
1239
  for (const p of parts) p.style.opacity = ''
1217
1240
  }
1218
- // A Same Login group folds its directory rows like a family group, and remembers it the same way.
1219
- function toggleLogin(key) {
1220
- const find = () => document.querySelector('#accounts .login[data-key="' + CSS.escape(key) + '"]')
1221
- const el = find()
1222
- const open = prefs.collapsed[key] === true
1223
- if (open) delete prefs.collapsed[key]; else prefs.collapsed[key] = true
1224
- savePrefs()
1225
- if (!el || !moving()) { renderAccounts(); return }
1226
- const from = el.getBoundingClientRect().height
1227
- const rowsOf = (x) => Array.from(x.querySelectorAll(':scope > .login-rows'))
1228
- if (!open) {
1229
- const btn = el.querySelector('.login-head .row-toggle')
1230
- if (btn) btn.setAttribute('aria-expanded', 'false')
1231
- const parts = rowsOf(el)
1232
- ease(el, from, parts, parts, 0, renderAccounts)
1233
- return
1234
- }
1235
- renderAccounts()
1236
- const now = find()
1237
- if (!now) return
1238
- const parts = rowsOf(now)
1239
- for (const p of parts) p.style.opacity = '0'
1240
- ease(now, from, [], parts, 1, () => {})
1241
- for (const p of parts) p.style.opacity = ''
1242
- }
1243
1241
  // The Account 'sideby next <family>' would pick; the server ranks it with the CLI's rule (spec §3.13).
1244
1242
  function isNext(a) { return Boolean(S.state && S.state.handoff && S.state.handoff[a.family] === a.ref) }
1245
1243
  function row(a, now, cols) {
1246
1244
  const open = isOpen(a)
1247
- const email = rowEmail(a)
1245
+ const email = emailLine(a)
1246
+ const lead = loginLead(a)
1248
1247
  return h('div', { class: 'row fold-hit' + (open ? ' row-open' : '') + (a.error ? ' row-error' : ''), role: 'listitem', 'data-ref': a.ref, 'data-key': a.ref, 'data-sig': rowSig(a), onclick: foldClick(a) },
1249
- h('div', { class: 'ident' }, rowToggle(a), badge(a.family, 'sm'), h('div', { class: 'ident-text' }, nameLine(a), email || subLine(a),
1250
- inLogin(a) ? h('span', { class: 'sr-only' }, t('login.sharedAbove')) : null)),
1251
- inLogin(a) ? null : quotaBlock(a, now, cols),
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),
1252
1250
  lastUsed(a),
1253
1251
  moreButton(a),
1254
1252
  open ? rowDetail(a, Boolean(email)) : null,
1255
1253
  notes(a))
1256
1254
  }
1257
- // Directories signed in as one login (spec §3.21): the vendor counts one quota, so the head shows the email and
1258
- // that quota once, and each directory keeps a row of its own for what is its own (name, model, tokens, Health).
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.
1259
1258
  function loginGroup(members, now, cols) {
1260
- const first = members[0]
1261
- const source = members.find((a) => a.quota && a.quota.status === 'ok' && !a.quota.from) || first
1262
- const id = first.identity || {}
1263
1259
  const names = members.map((a) => a.name).join(', ')
1264
- const hint = t('login.hint', { names })
1265
- const m = source.quota && source.quota.status === 'ok' ? attentionWhy({ family: source.family, name: source.name, ref: source.ref, quota: source.quota }, null, now, WARN) : null
1266
- const why = m ? t(m.key, m.vars) : ''
1267
- const email = id.email ? emailText(id.email) : ''
1268
- const key = loginKey(members.map((a) => a.ref))
1269
- const collapsed = prefs.collapsed[key] === true
1270
- const label = t(collapsed ? 'login.expand' : 'login.collapse', { names })
1271
- const toggle = h('button', { class: 'icon-btn row-toggle', type: 'button', 'aria-expanded': String(!collapsed), 'aria-label': label, title: label, onclick: (e) => { e.stopPropagation(); toggleLogin(key) } }, icon('chevron', 'chev'))
1272
- return h('div', { class: 'login' + (collapsed ? ' is-collapsed' : ''), role: 'listitem', 'aria-label': t('login.label', { names }), 'data-key': key },
1273
- h('div', { class: 'row login-head fold-hit', onclick: foldHit(() => toggleLogin(key)) },
1274
- h('div', { class: 'ident' }, toggle, badge(first.family, 'sm'),
1275
- h('div', { class: 'ident-text' },
1276
- h('div', { class: 'ident-name' },
1277
- email ? h('button', { class: 'name email-btn', type: 'button', title: t('menu.copyEmail'), 'aria-label': t('menu.copyEmail') + ' ' + email, onclick: () => copyText(id.email) }, email) : h('span', { class: 'name' }, names),
1278
- why ? h('span', { class: 'dot dot-warn att-dot', title: why, role: 'img', 'aria-label': why }) : null),
1279
- h('div', { class: 'ident-sub', title: hint }, dots([id.org ? h('span', null, id.org) : null, h('span', null, t('login.count', { n: members.length })), collapsed ? h('span', null, names) : null])))),
1280
- quotaBlock(source, now, cols, source.quota && source.quota.status === 'ok' ? source.ref : null),
1281
- h('div', { class: 'last' }),
1282
- h('div', { class: 'more' })),
1283
- collapsed ? null : h('div', { class: 'login-rows', role: 'list', 'data-key': 'rows', 'data-patch': '' }, members.map((a) => row(a, now, cols))))
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 })))
1284
1269
  }
1285
1270
  // What a click on a row opens: how it runs (ref, model and effort, a shared login), its tokens and its Health.
1286
1271
  function sameLoginNote(a) {
@@ -1484,7 +1469,9 @@ const MAIN = String.raw `
1484
1469
  const off = list.some((a) => a.quota && a.quota.status === 'unavailable' && a.quota.reason === 'not-enabled')
1485
1470
  const bodyId = 'grp-' + fam.replace(/[^a-zA-Z0-9_-]/g, '_')
1486
1471
  const cards = prefs.view === 'cards'
1472
+ if (!cards) ui.order.units[fam] = loginClusters(list).map((x) => Array.isArray(x) ? x.map((a) => a.name) : [x.name])
1487
1473
  const head = h('div', { class: 'group-head', 'data-key': 'head' },
1474
+ dragHandle('family', fam, fam, info.title),
1488
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) },
1489
1476
  icon('chevron', 'chev'), badge(fam, 'sm'), h('span', { class: 'group-title' }, info.title),
1490
1477
  h('span', { class: 'group-count' }, list.length === total ? String(total) : list.length + ' / ' + total)),
@@ -1500,7 +1487,7 @@ const MAIN = String.raw `
1500
1487
  const body = collapsed ? null : cards
1501
1488
  ? h('div', { class: 'grid', id: bodyId, 'data-key': 'cards', 'data-patch': '' }, list.map((a) => card(a, now, cols)))
1502
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)))
1503
- return h('section', { class: 'group' + (cards ? ' group-cards' : ' group-list') + (collapsed ? ' is-collapsed' : ''), 'aria-label': info.title, 'data-key': 'g:' + fam, 'data-patch': '' },
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': '' },
1504
1491
  head, collapsed || cards ? null : colHead(cols, list), body)
1505
1492
  }
1506
1493
  function emptyState() {
@@ -1536,6 +1523,159 @@ const MAIN = String.raw `
1536
1523
  h('p', null, S.loadError.message),
1537
1524
  h('button', { class: 'btn', type: 'button', onclick: () => load({ check: !S.report }) }, icon('refresh'), t('btn.retry')))
1538
1525
  }
1526
+ // ---------- Display Order (spec §3.25) ----------
1527
+ function displayOf() { return (S.state && S.state.display) || {} }
1528
+ // Every Family id in the Display Order, the ones only accounts name (a plugin that failed) last.
1529
+ function familyOrder() {
1530
+ const ids = S.state.families.map((f) => f.id)
1531
+ for (const a of S.state.accounts) if (!ids.includes(a.family)) ids.push(a.family)
1532
+ return inDisplayOrder(ids, (x) => x, displayOf().families)
1533
+ }
1534
+ function accountOrder(fam) { return (displayOf().accounts || {})[fam] }
1535
+ // Every account name of a Family, hidden ones too, in the Display Order.
1536
+ function allNames(fam) {
1537
+ return sortAccounts(S.state.accounts.filter((a) => a.family === fam), 'manual', Date.now(), accountOrder(fam)).map((a) => a.name)
1538
+ }
1539
+ 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.
1542
+ function dragHandle(kind, fam, key, name) {
1543
+ if (BOOT.readOnly || prefs.view === 'cards') return null
1544
+ const label = t(kind === 'family' ? 'order.moveFamily' : 'order.moveAccount', { name })
1545
+ 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
+ onclick: (e) => e.stopPropagation(),
1547
+ onkeydown: (e) => {
1548
+ const keys = { ArrowUp: -1, ArrowDown: 1, Home: -Infinity, End: Infinity }
1549
+ if (!Object.hasOwn(keys, e.key)) return
1550
+ e.preventDefault()
1551
+ e.stopPropagation()
1552
+ const units = unitsOf(kind, fam)
1553
+ const from = units.findIndex((u) => u.includes(key))
1554
+ const step = keys[e.key]
1555
+ const to = Math.max(0, Math.min(units.length - 1, Number.isFinite(step) ? from + step : step < 0 ? 0 : units.length - 1))
1556
+ moveUnit(kind, fam, from, to, { focus: key, label: name })
1557
+ },
1558
+ ondragstart: (e) => {
1559
+ const units = unitsOf(kind, fam)
1560
+ ui.drag = { kind, fam, from: units.findIndex((u) => u.includes(key)), to: null }
1561
+ e.dataTransfer.effectAllowed = 'move'
1562
+ try { e.dataTransfer.setData('text/plain', name) } catch {}
1563
+ const el = unitElement(kind, fam, key)
1564
+ if (el) { e.dataTransfer.setDragImage(el, 16, 16); requestAnimationFrame(() => el.classList.add('is-dragging')) }
1565
+ },
1566
+ ondragend: () => endDrag(),
1567
+ }, icon('grip'))
1568
+ }
1569
+ 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.
1571
+ function unitElement(kind, fam, key) {
1572
+ 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)
1575
+ }
1576
+ // Where a drop at the pointer lands: the list of places it can go and the index among them, or null outside.
1577
+ function dropTarget(e) {
1578
+ const d = ui.drag
1579
+ if (!d) return null
1580
+ if (d.kind === 'family') {
1581
+ const els = Array.from(document.querySelectorAll('#accounts > section[data-fam]'))
1582
+ return els.length ? { els, at: slotAt(els, e.clientY) } : null
1583
+ }
1584
+ const sec = e.target.closest && e.target.closest('#accounts > section[data-fam]')
1585
+ if (!sec || sec.getAttribute('data-fam') !== d.fam) return null
1586
+ const box = sec.querySelector('.rows')
1587
+ if (!box) return null
1588
+ const els = Array.from(box.children)
1589
+ return { els, at: slotAt(els, e.clientY) }
1590
+ }
1591
+ function slotAt(els, y) {
1592
+ for (let i = 0; i < els.length; i++) {
1593
+ const r = els[i].getBoundingClientRect()
1594
+ if (y < r.top + r.height / 2) return i
1595
+ }
1596
+ return els.length
1597
+ }
1598
+ function clearDropMarks() { for (const el of document.querySelectorAll('.drop-before, .drop-after')) el.classList.remove('drop-before', 'drop-after') }
1599
+ function endDrag() {
1600
+ clearDropMarks()
1601
+ for (const el of document.querySelectorAll('.is-dragging')) el.classList.remove('is-dragging')
1602
+ ui.drag = null
1603
+ if (S.pending) { S.pending = false; renderAll() }
1604
+ }
1605
+ function onDragOver(e) {
1606
+ const tgt = dropTarget(e)
1607
+ if (!tgt) return
1608
+ e.preventDefault()
1609
+ e.dataTransfer.dropEffect = 'move'
1610
+ clearDropMarks()
1611
+ if (tgt.at < tgt.els.length) tgt.els[tgt.at].classList.add('drop-before')
1612
+ else tgt.els[tgt.els.length - 1].classList.add('drop-after')
1613
+ // Dropped after itself means one place earlier once it is taken out.
1614
+ ui.drag.to = tgt.at > ui.drag.from ? tgt.at - 1 : tgt.at
1615
+ }
1616
+ function onDrop(e) {
1617
+ const d = ui.drag
1618
+ if (!d || !dropTarget(e)) return
1619
+ e.preventDefault()
1620
+ const to = d.to
1621
+ endDrag()
1622
+ if (to !== null && to !== d.from) moveUnit(d.kind, d.fam, d.from, to)
1623
+ }
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) {
1626
+ opts = opts || {}
1627
+ const units = unitsOf(kind, fam)
1628
+ 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
+ const before = displayOf()
1631
+ const next = { families: before.families ? before.families.slice() : [], accounts: Object.assign({}, before.accounts || {}) }
1632
+ 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()
1638
+ 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) + '"]')
1640
+ 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' })
1650
+ }
1651
+ }
1652
+ async function resetOrder() {
1653
+ 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()
1671
+ }
1672
+ // Words for screen readers only, such as the new place of something moved with the keyboard.
1673
+ let live = null
1674
+ function say(text) {
1675
+ if (!live) { live = h('div', { class: 'sr-only', 'aria-live': 'polite' }); document.body.append(live) }
1676
+ live.textContent = ''
1677
+ setTimeout(() => { live.textContent = text }, 30)
1678
+ }
1539
1679
  function renderAccounts() {
1540
1680
  closeMenu()
1541
1681
  const box = $('accounts')
@@ -1556,18 +1696,25 @@ const MAIN = String.raw `
1556
1696
  : tn('count.accounts', all.length))
1557
1697
  const apps = appGroups()
1558
1698
  if (!shown.length && !apps.length) { patch(box, noMatch()); return }
1559
- const cols = quotaColumns(shown)
1560
- box.style.setProperty('--qn', String(cols.length))
1561
- ui.loginRefs = new Set()
1699
+ ui.loginRefs = new Map()
1562
1700
  if (prefs.view !== 'cards')
1563
- for (const x of loginClusters(shown)) if (Array.isArray(x)) for (const a of x) ui.loginRefs.add(a.ref)
1564
- const ids = S.state.families.map((f) => f.id)
1565
- for (const a of all) if (!ids.includes(a.family)) ids.push(a.family)
1701
+ for (const x of loginClusters(shown)) if (Array.isArray(x)) for (const a of x) ui.loginRefs.set(a.ref, x[0].ref)
1702
+ // Families in the Display Order (spec §3.25) whatever the sort; each group gets the quota columns it uses.
1703
+ // Every group is as wide as the one with the most windows, so the columns after them line up.
1704
+ ui.order = { families: [], units: {} }
1566
1705
  const groups = []
1567
- for (const fam of ids) {
1706
+ let width = 1
1707
+ for (const fam of familyOrder()) {
1568
1708
  const list = shown.filter((a) => a.family === fam)
1569
- if (list.length) groups.push(group(fam, sortAccounts(list, prefs.sort, now), all.filter((a) => a.family === fam).length, now, cols))
1709
+ if (!list.length) continue
1710
+ ui.order.families.push(fam)
1711
+ const sorted = sortAccounts(list, prefs.sort, now, accountOrder(fam))
1712
+ const cols = quotaColumns(sorted)
1713
+ width = Math.max(width, cols.length)
1714
+ groups.push(group(fam, sorted, all.filter((a) => a.family === fam).length, now, cols))
1570
1715
  }
1716
+ box.style.setProperty('--qn', String(width))
1717
+ renderOrderReset()
1571
1718
  patch(box, groups.concat(apps))
1572
1719
  if (ui.fade && !REDUCED) {
1573
1720
  box.classList.add('view-in')
@@ -2842,6 +2989,29 @@ const MAIN = String.raw `
2842
2989
  const api = S.state.accounts.filter((a) => a.kind === 'api').map((a) => a.ref)
2843
2990
  return remoteControlSummary(S.state.remoteControl || [], api)
2844
2991
  }
2992
+ // The refresh button reads everything again; Families that ask their Host for Quota (Grok, spec §3.26) are asked
2993
+ // again first, so the numbers are new and not the kept ones.
2994
+ function asking() { return !BOOT.readOnly && S.state && (S.state.quotaChecks || []).some((c) => c.on && !c.blocked) }
2995
+ async function refreshAll() {
2996
+ if (asking()) {
2997
+ const btn = $('refresh')
2998
+ btn.disabled = true
2999
+ btn.classList.add('is-spinning')
3000
+ try { await api('/api/quota/refresh', {}) } catch (e) { toast(t('quota.refreshFailed', { msg: e.message }), { kind: 'fail' }) }
3001
+ btn.classList.remove('is-spinning')
3002
+ btn.disabled = false
3003
+ }
3004
+ load({ check: true, announce: true })
3005
+ }
3006
+ function checkOf(fam) { return S.state && (S.state.quotaChecks || []).find((c) => c.family === fam) }
3007
+ async function setCheck(fam, on) {
3008
+ try {
3009
+ await api('/api/quota/check', { family: fam, on })
3010
+ toast(t(on ? 'quota.checkOnToast' : 'quota.checkOffToast', { host: famTitleOf(fam) }))
3011
+ } catch (e) { toast(t('quota.checkFailed', { msg: e.message }), { kind: 'fail' }) }
3012
+ load({ silent: true })
3013
+ }
3014
+ function famTitleOf(fam) { const f = S.state && S.state.families.find((x) => x.id === fam); return f ? f.title : fam }
2845
3015
  // ---------- settings ----------
2846
3016
  // One header button holds what is set once and rarely changed: language, theme, Remote Control and Auto Handoff.
2847
3017
  // A dot on it says Remote Control starts by itself, so that state stays visible after its banner is gone.
@@ -2851,6 +3021,7 @@ const MAIN = String.raw `
2851
3021
  if (BOOT.colorScheme === 'auto') out.push('theme')
2852
3022
  if (S.state && rcSummary()) out.push('rc')
2853
3023
  if (S.state && ahState()) out.push('ah')
3024
+ if (S.state && (S.state.quotaChecks || []).length) out.push('checks')
2854
3025
  return out
2855
3026
  }
2856
3027
  function renderSettingsButton() {
@@ -2882,6 +3053,18 @@ const MAIN = String.raw `
2882
3053
  const on = ahState().settings.auto
2883
3054
  out.push(link(t('ah.button'), on ? t('ah.on') : t('ah.off'), on ? 'chip-ok' : '', () => setPage('handoff')))
2884
3055
  }
3056
+ // Quota checks that ask the Host (spec §3.26): one switch per Family, with what it does in plain words.
3057
+ if (secs.includes('checks')) {
3058
+ if (out.length) out.push(h('div', { class: 'menu-sep', role: 'separator' }))
3059
+ for (const c of S.state.quotaChecks) {
3060
+ const label = t('set.quotaCheck', { host: c.title })
3061
+ out.push(h('div', { class: 'set-row set-check' },
3062
+ h('div', { class: 'set-text' }, h('span', { class: 'set-label' }, label), h('span', { class: 'set-hint' }, c.blocked ? c.blocked : t('set.quotaCheckHint', { host: c.title, n: c.everyMinutes }))),
3063
+ BOOT.readOnly ? h('span', { class: 'chip chip-sm' + (c.on ? ' chip-ok' : '') }, c.on ? t('ah.on') : t('ah.off'))
3064
+ : h('div', { class: 'seg', role: 'group', 'aria-label': label }, [[true, t('ah.on')], [false, t('ah.off')]].map(([v, text]) =>
3065
+ h('button', { class: 'seg-btn', type: 'button', 'aria-pressed': String(v === c.on), onclick: () => { if (v !== c.on) { closeMenu(); setCheck(c.family, v) } } }, text)))))
3066
+ }
3067
+ }
2885
3068
  return out
2886
3069
  }
2887
3070
  function setLang(v) {
@@ -3198,6 +3381,14 @@ const MAIN = String.raw `
3198
3381
  up.autoBusy = false
3199
3382
  renderUpdate()
3200
3383
  }
3384
+ // The installed sideby skill follows the CLI (spec §3.23): said before the Update, or what to run when sideby may not.
3385
+ function updSkill(u) {
3386
+ const s = u.skillUpdate
3387
+ if (!s) return [h('p', { class: 'small' }, t('upd.skill')), codeCopy(u.skill)]
3388
+ if (!s.outdated) return []
3389
+ 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)]
3391
+ }
3201
3392
  function renderUpdate() {
3202
3393
  if (!up || dlg.kind !== 'update') return
3203
3394
  const u = updState()
@@ -3211,7 +3402,8 @@ const MAIN = String.raw `
3211
3402
  if (up.done) {
3212
3403
  body.push(h('p', { class: 'note' }, t('upd.done', { v: up.done.latest })))
3213
3404
  body.push(h('p', { class: 'muted small' }, Date.now() > up.waitUntil ? t('upd.restartHint') : [spinner(), ' ', t('upd.reloading')]))
3214
- body.push(h('p', { class: 'small' }, t('upd.skill')), codeCopy(u.skill))
3405
+ if (up.done.skillUpdated) body.push(h('p', { class: 'small' }, t('upd.skillDone')))
3406
+ else body.push(...updSkill(u))
3215
3407
  } else if (u.available) {
3216
3408
  if (up.changed) body.push(h('p', { class: 'note note-warn' }, t('upd.changed', { v: u.latest })))
3217
3409
  body.push(h('p', null, h('b', null, t('upd.available', { v: u.latest })), ' ',
@@ -3223,7 +3415,7 @@ const MAIN = String.raw `
3223
3415
  body.push(h('p', { class: 'note note-warn' }, t('upd.cannot', { why: u.method.reason || u.method.kind })))
3224
3416
  body.push(codeCopy(u.command))
3225
3417
  }
3226
- body.push(h('p', { class: 'small' }, t('upd.skill')), codeCopy(u.skill))
3418
+ body.push(...updSkill(u))
3227
3419
  } else if (up.checked && u.latest) {
3228
3420
  body.push(h('p', { class: 'note' }, t('upd.latestNow')))
3229
3421
  } else if (u.checksOff) {
@@ -3258,7 +3450,9 @@ const MAIN = String.raw `
3258
3450
  $('ver').addEventListener('click', () => openVerPop($('ver')))
3259
3451
  $('update').addEventListener('click', openUpdate)
3260
3452
  $('ver-top').addEventListener('click', () => openVerPop($('ver-top')))
3261
- $('refresh').addEventListener('click', () => load({ check: true, announce: true }))
3453
+ $('refresh').addEventListener('click', refreshAll)
3454
+ $('accounts').addEventListener('dragover', onDragOver)
3455
+ $('accounts').addEventListener('drop', onDrop)
3262
3456
  $('settings').addEventListener('click', () => openPopover($('settings'), 'settings', t('set.button'), settingsPop, 'end'))
3263
3457
  applyTheme()
3264
3458
  renderStatic()