sideby 0.12.1 → 0.13.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 (60) hide show
  1. package/README.md +1 -1
  2. package/dist/build.json +1 -1
  3. package/dist/src/cli/commands.d.ts +4 -2
  4. package/dist/src/cli/commands.js +117 -17
  5. package/dist/src/cli/json-schemas.js +52 -1
  6. package/dist/src/cli/main.js +34 -3
  7. package/dist/src/core/brand-mark.d.ts +5 -7
  8. package/dist/src/core/brand-mark.js +18 -5
  9. package/dist/src/core/browser-profiles.d.ts +71 -0
  10. package/dist/src/core/browser-profiles.js +361 -0
  11. package/dist/src/core/config.d.ts +15 -0
  12. package/dist/src/core/config.js +13 -1
  13. package/dist/src/core/create.js +2 -1
  14. package/dist/src/core/desktop-app.d.ts +3 -1
  15. package/dist/src/core/desktop-app.js +29 -8
  16. package/dist/src/core/launch.d.ts +7 -0
  17. package/dist/src/core/launch.js +22 -2
  18. package/dist/src/core/leveldb-keys.d.ts +14 -0
  19. package/dist/src/core/leveldb-keys.js +291 -0
  20. package/dist/src/core/remote-control.d.ts +17 -6
  21. package/dist/src/core/remote-control.js +40 -17
  22. package/dist/src/core/update.d.ts +10 -3
  23. package/dist/src/core/update.js +38 -3
  24. package/dist/src/core/version.d.ts +11 -0
  25. package/dist/src/core/version.js +26 -0
  26. package/dist/src/desktop/app.d.ts +2 -0
  27. package/dist/src/desktop/app.js +39 -3
  28. package/dist/src/families/browser.d.ts +20 -0
  29. package/dist/src/families/browser.js +23 -0
  30. package/dist/src/families/claude/browser.d.ts +3 -0
  31. package/dist/src/families/claude/browser.js +14 -0
  32. package/dist/src/families/claude/desktop.js +1 -0
  33. package/dist/src/families/codex/browser.d.ts +7 -0
  34. package/dist/src/families/codex/browser.js +26 -0
  35. package/dist/src/families/desktop.d.ts +5 -1
  36. package/dist/src/families/pi/index.js +2 -0
  37. package/dist/src/panel/handler.d.ts +6 -0
  38. package/dist/src/panel/handler.js +49 -6
  39. package/dist/src/panel/i18n.d.ts +71 -4
  40. package/dist/src/panel/i18n.js +152 -18
  41. package/dist/src/panel/page-logic.d.ts +23 -0
  42. package/dist/src/panel/page-logic.js +44 -0
  43. package/dist/src/panel/page-script.js +576 -181
  44. package/dist/src/panel/page-styles.js +61 -18
  45. package/dist/src/panel/page.d.ts +5 -0
  46. package/dist/src/panel/page.js +10 -9
  47. package/dist/src/runtime.d.ts +6 -1
  48. package/dist/src/runtime.js +22 -11
  49. package/dist/src/types.d.ts +2 -0
  50. package/package.json +5 -3
  51. package/schemas/browser-open.json +47 -0
  52. package/schemas/browser-switch.json +42 -0
  53. package/schemas/browser.json +99 -0
  54. package/schemas/config.json +11 -0
  55. package/schemas/families.json +6 -0
  56. package/schemas/list.json +6 -0
  57. package/schemas/remote-control.json +4 -0
  58. package/schemas/update.json +4 -0
  59. package/skills/sideby/SKILL.md +5 -3
  60. package/skills/sideby/references/setup.md +1 -1
@@ -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, maskEmail, matchesQuery, mergeDaily, newAccountDir, nextReset, panelHint, panelLang, passedWindow, quotaPressure, sortAccounts, suggestAlias, suggestName, updatedAge, windowState, } from './page-logic.js';
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';
4
4
  const LOGIC = [
5
5
  aliasArgsOf,
6
6
  aliasIssue,
@@ -10,6 +10,8 @@ const LOGIC = [
10
10
  formatDuration,
11
11
  handoffChangeOf,
12
12
  handoffHintAccount,
13
+ loginClusters,
14
+ loginKey,
13
15
  maskEmail,
14
16
  matchesQuery,
15
17
  mergeDaily,
@@ -19,6 +21,8 @@ const LOGIC = [
19
21
  panelLang,
20
22
  passedWindow,
21
23
  quotaPressure,
24
+ remoteControlSummary,
25
+ remoteControlTodo,
22
26
  sortAccounts,
23
27
  suggestAlias,
24
28
  suggestName,
@@ -53,7 +57,7 @@ const MAIN = String.raw `
53
57
  silent: false, lastTry: 0, pending: false, fastUntil: 0, fastFamily: null,
54
58
  }
55
59
  // View state that is not saved: the search text, the attention filter, the Health check filter.
56
- const ui = { query: '', attention: false, allFindings: false, healthRef: null, checkingRef: null, desktopRef: null, appBusy: null, appRemove: null, appNew: null, highlight: null, fade: true }
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() }
57
61
  const $ = (id) => document.getElementById(id)
58
62
 
59
63
  // ---------- preferences ----------
@@ -65,7 +69,7 @@ const MAIN = String.raw `
65
69
  function loadPrefs() {
66
70
  let raw = {}
67
71
  try { raw = JSON.parse(localStorage.getItem(PREFS_KEY) || '{}') || {} } catch {}
68
- const p = { view: 'list', sort: 'pressure', quota: 'used', theme: 'system', lang: '', family: '', collapsed: {}, rows: {}, hideEmail: false }
72
+ const p = { view: 'list', sort: 'pressure', quota: 'used', theme: 'system', lang: '', family: '', collapsed: {}, open: {}, hideEmail: false }
69
73
  for (const k of Object.keys(CHOICES)) if (CHOICES[k].includes(raw[k])) p[k] = raw[k]
70
74
  if (raw.hideEmail === true) p.hideEmail = true
71
75
  if (typeof raw.ahHintAt === 'number') p.ahHintAt = raw.ahHintAt
@@ -73,8 +77,8 @@ const MAIN = String.raw `
73
77
  if (typeof raw.family === 'string') p.family = raw.family
74
78
  if (raw.collapsed && typeof raw.collapsed === 'object')
75
79
  for (const k of Object.keys(raw.collapsed)) if (raw.collapsed[k] === true) p.collapsed[k] = true
76
- if (raw.rows && typeof raw.rows === 'object')
77
- for (const k of Object.keys(raw.rows)) if (raw.rows[k] === true) p.rows[k] = true
80
+ if (raw.open && typeof raw.open === 'object')
81
+ for (const k of Object.keys(raw.open)) if (raw.open[k] === true) p.open[k] = true
78
82
  return p
79
83
  }
80
84
  function savePrefs() { try { localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)) } catch {} }
@@ -229,6 +233,8 @@ const MAIN = String.raw `
229
233
  // Whether the viewer is in the middle of something a background render would disturb: an open menu, dialog or
230
234
  // tooltip, a focused field, or a text selection in the account list.
231
235
  function uiBusy() {
236
+ // A row or group in the middle of folding; a render now would cut the motion short.
237
+ for (const el of folding) if (el.isConnected) return true
232
238
  if (menu.el || (dlg.el && dlg.el.open) || document.querySelector('.tip.is-open')) return true
233
239
  // A plan on the Auto Handoff page, and the change a person opened to read, stay as they are.
234
240
  if (!$('handoff-page').hidden && (hp.preview || hp.askSame || document.querySelector('#handoff-page details[open]'))) return true
@@ -247,6 +253,8 @@ const MAIN = String.raw `
247
253
  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',
248
254
  moon: 'M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5z',
249
255
  monitor: 'M3 5h18v11H3zM8 20h8M12 16v4',
256
+ // Lucide "settings" (ISC license)
257
+ gear: 'M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z',
250
258
  copy: 'M9 9h11v11H9zM5 15H4V4h11v1',
251
259
  terminal: 'M4 17l6-5-6-5M12 19h8',
252
260
  key: 'M15 9a3 3 0 1 0-.01 0M12.9 11.1L4 20M7 17l2 2M9.5 14.5l2 2',
@@ -258,8 +266,10 @@ const MAIN = String.raw `
258
266
  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',
259
267
  grid: 'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z',
260
268
  list: 'M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01',
269
+ sliders: 'M4 7h10M18 7h2M4 17h4M12 17h8M16 5v4M10 15v4',
261
270
  clock: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2',
262
271
  mail: 'M3 6h18v12H3zM3 7l9 6 9-6',
272
+ globe: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3 12h18M12 3c2.5 2.5 3.8 5.5 3.8 9s-1.3 6.5-3.8 9c-2.5-2.5-3.8-5.5-3.8-9S9.5 5.5 12 3z',
263
273
  eye: 'M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z',
264
274
  eyeOff: 'M3 3l18 18M10.6 5.1A10.8 10.8 0 0 1 12 5c6.5 0 10 7 10 7a17.6 17.6 0 0 1-3.2 4.2M6.6 6.6A17.4 17.4 0 0 0 2 12s3.5 7 10 7a10.4 10.4 0 0 0 5.4-1.6M9.9 9.9a3 3 0 0 0 4.2 4.2',
265
275
  }
@@ -526,29 +536,51 @@ const MAIN = String.raw `
526
536
  document.addEventListener('focusin', (e) => { if (e.target.classList && e.target.classList.contains('tip-btn')) showTip(e.target) })
527
537
  document.addEventListener('focusout', (e) => { if (e.target.classList && e.target.classList.contains('tip-btn')) hideTip(e.target) })
528
538
 
529
- // ---------- row menu ----------
530
- const menu = { el: null, anchor: null }
531
- function openMenu(anchor, items, label) {
539
+ // ---------- floating panels: menus and popovers ----------
540
+ // One shared base for everything that floats next to a button: it is placed under (or above) its anchor, kept
541
+ // inside the window, closed by a click outside, Escape, a resize or a scroll, and only one is open at a time.
542
+ // openMenu() fills it with menu items; openPopover() with any content that render() returns (re-run by refreshPopover()).
543
+ const menu = { el: null, anchor: null, kind: null, render: null }
544
+ function openFloat(anchor, el, kind, align) {
532
545
  closeMenu()
533
- const el = h('div', { class: 'menu', role: 'menu', 'aria-label': label },
534
- items.filter(Boolean).map((it) => it === '-' ? h('div', { class: 'menu-sep', role: 'separator' })
535
- : h('button', { class: 'menu-item', type: 'button', role: 'menuitem', tabindex: '-1', disabled: it.disabled, onclick: () => { closeMenu(true); it.run() } },
536
- 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)))
537
- el.addEventListener('keydown', menuKeys)
538
546
  document.body.append(el)
539
547
  const r = anchor.getBoundingClientRect()
540
548
  const w = el.offsetWidth
541
549
  const hgt = el.offsetHeight
542
- const left = Math.max(8, Math.min(window.innerWidth - w - 8, r.right - w))
550
+ const x = align === 'start' ? r.left : r.right - w
551
+ const left = Math.max(8, Math.min(window.innerWidth - w - 8, x))
543
552
  const top = r.bottom + 4 + hgt > window.innerHeight - 8 && r.top - hgt - 4 > 8 ? r.top - hgt - 4 : r.bottom + 4
544
553
  el.style.setProperty('left', left + 'px')
545
554
  el.style.setProperty('top', top + 'px')
546
555
  anchor.setAttribute('aria-expanded', 'true')
547
556
  menu.el = el
548
557
  menu.anchor = anchor
558
+ menu.kind = kind
559
+ }
560
+ function openMenu(anchor, items, label) {
561
+ const el = h('div', { class: 'menu', role: 'menu', 'aria-label': label },
562
+ items.filter(Boolean).map((it) => it === '-' ? h('div', { class: 'menu-sep', role: 'separator' })
563
+ : h('button', { class: 'menu-item', type: 'button', role: 'menuitem', tabindex: '-1', disabled: it.disabled, onclick: () => { closeMenu(true); it.run() } },
564
+ icon(it.icon), h('span', { class: 'menu-label' }, it.label), it.hint ? h('code', { class: 'menu-hint', title: it.hint }, it.hint.length > 24 ? '…' + it.hint.slice(-23) : it.hint) : null)))
565
+ el.addEventListener('keydown', menuKeys)
566
+ openFloat(anchor, el, 'menu', 'end')
549
567
  const first = el.querySelector('.menu-item:not(:disabled)')
550
568
  if (first) first.focus()
551
569
  }
570
+ // A popover: a small panel of text and buttons, lighter than a dialog. A second click on its anchor closes it.
571
+ function openPopover(anchor, kind, label, render, align = 'start') {
572
+ if (menu.anchor === anchor) { closeMenu(true); return }
573
+ const el = h('div', { class: 'menu popover', role: 'dialog', 'aria-label': label })
574
+ el.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); closeMenu(true) } })
575
+ setKids(el, render())
576
+ openFloat(anchor, el, kind, align)
577
+ menu.render = render
578
+ const first = el.querySelector('button:not(:disabled), a[href]')
579
+ if (first) first.focus()
580
+ }
581
+ function refreshPopover(kind) {
582
+ if (menu.el && menu.kind === kind && menu.render) setKids(menu.el, menu.render())
583
+ }
552
584
  function closeMenu(restore) {
553
585
  if (!menu.el) return
554
586
  menu.el.remove()
@@ -556,6 +588,8 @@ const MAIN = String.raw `
556
588
  if (restore && menu.anchor.isConnected) menu.anchor.focus()
557
589
  menu.el = null
558
590
  menu.anchor = null
591
+ menu.kind = null
592
+ menu.render = null
559
593
  }
560
594
  function menuKeys(e) {
561
595
  const items = Array.from(menu.el.querySelectorAll('.menu-item:not(:disabled)'))
@@ -579,6 +613,7 @@ const MAIN = String.raw `
579
613
  { icon: 'terminal', label: t('menu.copyRun'), hint: run, run: () => copyText(run) },
580
614
  ...(a.aliases || []).slice(0, 2).map((x) => ({ icon: 'terminal', label: t('menu.copyAlias'), hint: x, run: () => copyText(x) })),
581
615
  a.desktopApp && a.desktopApp.installed && a.kind !== 'api' && !BOOT.readOnly ? { icon: 'terminal', label: t(a.desktopApp.running ? 'desk.show' : 'desk.open', { app: a.desktopApp.app }), disabled: Boolean(ui.desktopRef), run: () => openDesktop(a) } : null,
616
+ browserFor(a) ? { icon: 'terminal', label: t('br.open'), disabled: Boolean(ui.browserRef), run: () => openBrowserDialog(a) } : null,
582
617
  a.kind !== 'api' ? { icon: 'key', label: t('menu.copyLogin'), hint: 'sideby login ' + a.ref, run: () => copyText('sideby login ' + a.ref) } : null,
583
618
  a.dir ? { icon: 'folder', label: t('menu.copyDir'), hint: a.dir, run: () => copyText(a.dir) } : null,
584
619
  a.identity && a.identity.email ? { icon: 'mail', label: t('menu.copyEmail'), hint: emailText(a.identity.email), run: () => copyText(a.identity.email) } : null,
@@ -620,20 +655,29 @@ const MAIN = String.raw `
620
655
  }
621
656
  function reasonsOf(a, now) { return attentionReasons(a, healthOf(a.ref), now, WARN) }
622
657
  // One line on why an Account needs a look, such as "7d at 62% (warns from 60%)".
623
- function whyOf(a, now) { const m = attentionWhy(a, healthOf(a.ref), now, WARN); return m ? t(m.key, m.vars) : '' }
658
+ function whyOf(a, now) {
659
+ const m = attentionWhy(a, healthOf(a.ref), now, WARN)
660
+ if (m && m.key === 'why.not-installed' && family(a.family).brokenBin) return t('why.broken')
661
+ return m ? t(m.key, m.vars) : ''
662
+ }
624
663
  function setupFor(fam) { return S.state && S.state.quotaSetups.find((s) => s.family === fam) }
625
664
 
626
665
  // ---------- header and summary ----------
627
666
  function renderUpdated() {
628
667
  const busy = S.loading && !S.silent
629
- const text = S.loading && !S.state ? t('top.loading') : busy ? t('top.refreshing') : S.loadedAt ? t('top.updated', { ago: agoShort(Date.now() - S.loadedAt) }) : ''
668
+ const every = Math.round((S.fastUntil > Date.now() ? FAST_MS : AUTO_MS) / 1000)
669
+ const text = S.loading && !S.state ? t('top.loading') : busy ? t('top.refreshing') : S.loadedAt ? t('top.updated', { ago: agoShort(Date.now() - S.loadedAt), s: every }) : ''
630
670
  const el = $('updated')
631
671
  if (el.textContent !== text) el.textContent = text
632
- el.title = S.loadedAt ? t('top.updatedTitle', { at: clock(new Date(S.loadedAt).toISOString()), s: Math.round((S.fastUntil > Date.now() ? FAST_MS : AUTO_MS) / 1000) }) : ''
672
+ // A dot says whether the numbers are live: green while they refresh on their own, amber when they stopped.
673
+ const stale = conn.lost || Boolean(S.loadError)
674
+ el.classList.toggle('is-live', Boolean(S.loadedAt) && !stale)
675
+ el.classList.toggle('is-stale', Boolean(S.loadedAt) && stale)
676
+ el.title = !S.loadedAt ? '' : (stale ? t('top.stale') + ' ' : '') + t('top.updatedTitle', { at: clock(new Date(S.loadedAt).toISOString()), s: Math.round((S.fastUntil > Date.now() ? FAST_MS : AUTO_MS) / 1000) })
633
677
  }
634
678
  function renderTop() {
635
679
  renderUpdated()
636
- renderHandoffButton()
680
+ renderSettingsButton()
637
681
  renderUpdateButton()
638
682
  const u = updState()
639
683
  $('foot-text').textContent = t(u && !u.checksOff ? 'foot.textChecks' : 'foot.text')
@@ -648,16 +692,7 @@ const MAIN = String.raw `
648
692
  document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en'
649
693
  for (const el of document.querySelectorAll('[data-i18n]')) el.textContent = t(el.getAttribute('data-i18n'))
650
694
  $('refresh').setAttribute('aria-label', t('top.refresh'))
651
- const lb = $('lang')
652
- lb.hidden = LANG_FIXED
653
- lb.textContent = lang === 'zh' ? 'EN' : '中文'
654
- lb.setAttribute('aria-label', t('top.language'))
655
- lb.title = t('top.language')
656
- const tb = $('theme')
657
- tb.hidden = BOOT.colorScheme !== 'auto'
658
- setKids(tb, icon(prefs.theme === 'light' ? 'sun' : prefs.theme === 'dark' ? 'moon' : 'monitor'))
659
- tb.title = t('theme.' + prefs.theme)
660
- tb.setAttribute('aria-label', t('theme.' + prefs.theme))
695
+ renderSettingsButton()
661
696
  }
662
697
  function applyTheme() {
663
698
  if (BOOT.colorScheme !== 'auto') return
@@ -744,34 +779,30 @@ const MAIN = String.raw `
744
779
  CHOICES.sort.map((v) => h('option', { value: v }, t('sort.' + v))))
745
780
  tb.sort.value = prefs.sort
746
781
  tb.sort.addEventListener('change', () => { prefs.sort = tb.sort.value; savePrefs(); renderAccounts() })
747
- const quota = seg(t('tb.quota'), [['used', t('tb.used')], ['left', t('tb.left')]], prefs.quota, (v) => { prefs.quota = v; savePrefs(); renderAccounts() })
748
- const view = seg(t('tb.view'), [['list', icon('list'), t('tb.list')], ['cards', icon('grid'), t('tb.cards')]], prefs.view, (v) => { prefs.view = v; savePrefs(); ui.fade = true; renderAccounts() })
749
- tb.hideEmail = h('button', { class: 'icon-btn tb-icon', type: 'button', 'aria-pressed': String(prefs.hideEmail), onclick: () => {
750
- prefs.hideEmail = !prefs.hideEmail
751
- savePrefs()
752
- renderEmailToggle()
753
- renderAccounts()
754
- } })
782
+ 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
+ display.addEventListener('click', () => openPopover(display, 'display', t('tb.display'), displayPanel, 'end'))
755
784
  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')))
756
785
  setKids($('toolbar'), [
757
786
  h('label', { class: 'search' }, icon('search', 'search-ic'), tb.search, h('kbd', { class: 'kbd', 'aria-hidden': 'true' }, '/')),
758
- h('div', { class: 'tools' }, tb.family, tb.sort, quota, view, tb.hideEmail),
787
+ h('div', { class: 'tools' }, tb.family, tb.sort, display),
759
788
  create,
760
789
  ])
761
790
  renderFamilyFilter()
762
- renderEmailToggle()
763
791
  if (hadFocus) tb.search.focus()
764
792
  }
765
- // "Hide emails": shown once any account has one; its icon and label say what a click does.
766
- function renderEmailToggle() {
767
- const b = tb.hideEmail
768
- if (!b) return
769
- b.hidden = !(S.state && S.state.accounts.some((a) => a.identity && a.identity.email))
770
- b.setAttribute('aria-pressed', String(prefs.hideEmail))
771
- const label = t(prefs.hideEmail ? 'tb.showEmails' : 'tb.hideEmails')
772
- b.setAttribute('aria-label', label)
773
- b.title = label
774
- setKids(b, icon(prefs.hideEmail ? 'eyeOff' : 'eye'))
793
+ // "Display": the view settings people change rarely, one popover instead of three buttons in the toolbar.
794
+ // "Hide emails" shows once any account has one.
795
+ function displayPanel() {
796
+ const redo = () => { savePrefs(); renderAccounts(); refreshPopover('display') }
797
+ const emails = S.state && S.state.accounts.some((a) => a.identity && a.identity.email)
798
+ return h('div', { class: 'display-panel' },
799
+ h('div', { class: 'dp-row' }, h('span', { class: 'dp-label' }, t('tb.quota')),
800
+ seg(t('tb.quota'), [['used', t('tb.used')], ['left', t('tb.left')]], prefs.quota, (v) => { prefs.quota = v; redo() })),
801
+ h('div', { class: 'dp-row' }, h('span', { class: 'dp-label' }, t('tb.view')),
802
+ seg(t('tb.view'), [['list', icon('list'), t('tb.list')], ['cards', icon('grid'), t('tb.cards')]], prefs.view, (v) => { prefs.view = v; ui.fade = true; redo() })),
803
+ emails ? h('label', { class: 'dp-row dp-check' },
804
+ h('input', { type: 'checkbox', checked: prefs.hideEmail, onchange: (e) => { prefs.hideEmail = e.target.checked; redo() } }),
805
+ h('span', null, t('tb.hideEmails'))) : null)
775
806
  }
776
807
  function renderFamilyFilter() {
777
808
  if (!tb.family) return
@@ -839,14 +870,16 @@ const MAIN = String.raw `
839
870
  const text = { 'no-session': 'quota.noSession', 'no-source': 'quota.noSource', unrecognized: 'quota.unrecognized' }[q.reason]
840
871
  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 }))
841
872
  }
842
- function quotaBlock(a, now, cols) {
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) {
843
875
  const q = a.quota
844
876
  if (!q) return h('div', { class: 'quota' }, h('div', { class: 'q-note' }, '—'))
845
877
  if (q.status !== 'ok') return h('div', { class: 'quota' }, quotaNote(a, q))
846
878
  const stale = isStale(q.observedAt, now)
847
879
  const wins = q.windows || []
848
880
  // A reading borrowed from another Account of the same login says where it came from (spec §3.21).
849
- const from = q.from ? t('quota.sharedFrom', { name: refName(q.from), ago: ago(q.observedAt) }) : null
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
850
883
  return h('div', { class: 'quota' + (stale ? ' is-stale' : ''), title: from || (stale ? null : t('quota.dataFrom', { ago: ago(q.observedAt) })) },
851
884
  wins.length ? cols.map((c) => {
852
885
  const w = wins.find((x) => x.label === c)
@@ -886,34 +919,43 @@ const MAIN = String.raw `
886
919
  return h('button', { class: 'chip chip-btn health' + (lvl ? ' chip-' + lvl : ''), type: 'button', title: t('health.filterHint'), 'aria-label': t('health.chipLabel', { ref: a.ref, status: text }), onclick: () => filterHealth(a.ref) },
887
920
  h('span', { class: 'dot' + (lvl ? ' dot-' + lvl : ''), 'aria-hidden': 'true' }), text)
888
921
  }
922
+ // A dot after the name when the account needs a look that no tag shows: a fuller quota window or a Health
923
+ // 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.
925
+ function attentionDot(a) {
926
+ const subject = inLogin(a) ? Object.assign({}, a, { quota: undefined }) : a
927
+ const r = reasonsOf(subject, Date.now())
928
+ const lvl = r.includes('health-fail') ? 'fail' : r.includes('quota-high') || r.includes('health-warn') ? 'warn' : ''
929
+ if (!lvl) return null
930
+ const why = whyOf(subject, Date.now())
931
+ return h('span', { class: 'dot dot-' + lvl + ' att-dot', title: why, role: 'img', 'aria-label': why })
932
+ }
889
933
  function nameLine(a) {
890
934
  return h('div', { class: 'ident-name' },
891
935
  h('span', { class: 'name', title: a.name }, a.name),
936
+ attentionDot(a),
892
937
  (a.aliases || []).slice(0, 3).map((x) => {
893
938
  const args = aliasArgsOf(a, x)
894
939
  return h('span', { class: 'tag tag-alias', title: args ? t('acct.aliasArgs', { args: args.join(' ') }) : t('acct.alias') }, x)
895
940
  }),
896
941
  a.kind === 'api' ? h('span', { class: 'tag tag-warn', title: t('tip.api.body') }, t('acct.api')) : null,
897
- a.sameLogin && a.sameLogin.length
898
- ? h('span', { class: 'tag', title: t('acct.sameLoginHint', { names: a.sameLogin.map(refName).join(', ') }) }, t('acct.sameLogin', { names: a.sameLogin.map(refName).join(', ') }))
899
- : null,
900
942
  isNext(a)
901
943
  ? h('button', { class: 'tag tag-ok tag-btn', type: 'button', title: t('acct.nextHint', { family: a.family }), 'aria-label': t('acct.nextLabel', { ref: a.ref, family: a.family }), onclick: () => copyText('sideby next ' + a.family) }, t('acct.next'))
902
944
  : null,
903
945
  a.login === 'logged-out' && a.kind !== 'api'
904
946
  ? 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'))
905
947
  : null,
906
- a.hostInstalled === false ? h('span', { class: 'tag tag-warn' }, t('acct.notInstalled')) : null)
948
+ a.hostInstalled === false ? h('span', { class: 'tag tag-warn' }, t(family(a.family).brokenBin ? 'acct.broken' : 'acct.notInstalled')) : null)
907
949
  }
908
950
  // The name part of a ref (claude:001 gives 001), as rows show other Accounts of the same Family.
909
951
  function refName(ref) { return String(ref).slice(String(ref).indexOf(':') + 1) }
910
952
  // Who the account is signed in as, under its name; masked while "Hide emails" is on.
911
953
  function emailLine(a) {
912
954
  const id = a.identity
913
- if (!id || (!id.email && !id.org)) return null
955
+ if (!id || (!id.email && !browserButton(a))) return null
914
956
  const email = id.email ? emailText(id.email) : ''
915
957
  return h('div', { class: 'ident-email', title: [email, id.org].filter(Boolean).join(' · ') },
916
- 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, id.org ? h('span', { class: 'org' }, id.org) : null]))
958
+ 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)]))
917
959
  }
918
960
  function subLine(a) {
919
961
  const plan = a.quota && a.quota.status === 'ok' && a.quota.plan
@@ -922,19 +964,8 @@ const MAIN = String.raw `
922
964
  a.kind === 'api' && a.model ? h('span', { class: 'mono', title: t('acct.model') }, a.model) : null,
923
965
  a.kind !== 'api' ? modelChip(a) : null,
924
966
  a.kind !== 'api' && plan ? h('span', { class: 'plan' }, plan) : null,
925
- desktopButton(a),
926
967
  ]))
927
968
  }
928
- // The family's desktop app as this account (spec §3.20): only where it is installed, never for API accounts or a
929
- // read-only panel. A running window shows as "open"; the click brings it to the front instead of a second one.
930
- function desktopButton(a) {
931
- const d = a.desktopApp
932
- if (!d || !d.installed || a.kind === 'api' || BOOT.readOnly) return null
933
- const busy = ui.desktopRef === a.ref
934
- const label = busy ? t('desk.opening', { app: d.app }) : t(d.running ? 'desk.show' : 'desk.open', { app: d.app })
935
- return h('button', { class: 'tag tag-btn' + (d.running ? ' tag-ok' : ''), type: 'button', disabled: busy, title: t(d.running ? 'desk.showHint' : 'desk.openHint', { app: d.app, ref: a.ref }), 'aria-label': label + ' (' + a.ref + ')', onclick: (e) => { e.stopPropagation(); openDesktop(a) } },
936
- d.running ? h('span', { class: 'dot dot-ok', 'aria-hidden': 'true' }) : null, label)
937
- }
938
969
  async function openDesktop(a) {
939
970
  if (ui.desktopRef) return
940
971
  ui.desktopRef = a.ref
@@ -950,91 +981,326 @@ const MAIN = String.raw `
950
981
  renderAccounts()
951
982
  load({ silent: true })
952
983
  }
984
+ // Browser Sign-ins (spec §3.24): which Chrome browser user signed in to the account's website, read on this
985
+ // computer only. Off: the button explains what turning it on reads. On: it names the browser user, and opens it.
986
+ const BR_URL = { claude: 'claude.ai', codex: 'chatgpt.com', grok: 'grok.com' }
987
+ function browserInfo() { return S.state && S.state.browser }
988
+ function browserFor(a) {
989
+ const b = browserInfo()
990
+ if (!b || a.kind === 'api' || BOOT.readOnly) return null
991
+ if (!b.families.matched.includes(a.family) && !b.families.openOnly.includes(a.family)) return null
992
+ return { b, found: b.enabled ? b.profiles.filter((p) => p.signIns.includes(a.ref)) : [] }
993
+ }
994
+ // A browser user's name may be an email; it hides with the others.
995
+ function profileName(name) { return String(name).includes('@') ? emailText(name) : name }
996
+ // A browser user shows as the Google account it uses in Chrome, else its name. Two that would show the same get
997
+ // their folder after it.
998
+ function profileLabel(p) {
999
+ const b = browserInfo()
1000
+ const full = (b && b.profiles.find((o) => o.dir === p.dir)) || p
1001
+ const key = (x) => x.email || x.name
1002
+ const twin = b && b.profiles.some((o) => o.dir !== full.dir && key(o) === key(full))
1003
+ return (full.email ? emailText(full.email) : profileName(full.name)) + (twin ? ' (' + full.dir + ')' : '')
1004
+ }
1005
+ function browserButton(a) {
1006
+ const info = browserFor(a)
1007
+ if (!info) return null
1008
+ const busy = ui.browserRef === a.ref
1009
+ const first = info.found[0]
1010
+ if (!first) return null
1011
+ const label = t(info.found.length > 1 ? 'br.signedInMore' : 'br.signedIn', { name: profileLabel(first), n: info.found.length })
1012
+ return h('button', { class: 'email-btn br-btn', type: 'button', disabled: busy, title: t('br.hint', { url: BR_URL[a.family], ref: a.ref }), 'aria-label': 'Chrome ' + label + ' (' + a.ref + ')', onclick: (e) => { e.stopPropagation(); if (info.found.length === 1) openBrowser(a, first); else openBrowserDialog(a) } },
1013
+ busy ? spinner() : icon('globe', 'br-ico'), label)
1014
+ }
1015
+ async function openBrowser(a, p) {
1016
+ if (ui.browserRef) return
1017
+ ui.browserRef = a.ref
1018
+ renderAccounts()
1019
+ if (dlg.kind === 'browser') renderBrowser()
1020
+ try {
1021
+ const r = await api('/api/browser/open', { account: a.ref, profile: p.dir })
1022
+ toast(t('br.opened', { url: r.url.replace(/^https:\/\//, ''), name: profileLabel(r.profile) }))
1023
+ if (dlg.kind === 'browser') dlg.el.close()
1024
+ } catch (e) { toast(t('br.failed', { ref: a.ref, msg: e.message }), { kind: 'fail' }) }
1025
+ ui.browserRef = null
1026
+ renderAccounts()
1027
+ if (dlg.kind === 'browser') renderBrowser()
1028
+ }
1029
+ let br = null
1030
+ function openBrowserDialog(a) {
1031
+ br = { ref: a.ref, busy: false, error: null }
1032
+ showDialog('browser')
1033
+ // Rows skip background renders while a dialog is open; turning the finding on changes what they show.
1034
+ dlg.onClose = () => renderAccounts()
1035
+ renderBrowser()
1036
+ }
1037
+ async function brSwitch(on) {
1038
+ br.busy = true
1039
+ dlg.busy = true
1040
+ br.error = null
1041
+ renderBrowser()
1042
+ try {
1043
+ await api('/api/browser/settings', { enabled: on, confirm: true })
1044
+ toast(t(on ? 'br.enabled' : 'br.disabled'))
1045
+ await load({ silent: true })
1046
+ } catch (e) { br.error = e }
1047
+ br.busy = false
1048
+ dlg.busy = false
1049
+ if (!on && !br.error) { dlg.el.close(); return }
1050
+ renderBrowser()
1051
+ }
1052
+ function brProfileRow(a, p) {
1053
+ const busy = ui.browserRef === a.ref
1054
+ return h('li', { class: 'br-row' },
1055
+ h('span', null, h('b', null, profileName(p.name)), p.email ? h('span', { class: 'muted small' }, ' · ' + emailText(p.email)) : null, ' ', h('span', { class: 'muted small mono' }, p.dir), p.changedAt ? h('span', { class: 'muted small' }, ' · ' + t('br.changed', { when: ago(p.changedAt) })) : null),
1056
+ h('button', { class: 'btn btn-sm', type: 'button', disabled: Boolean(ui.browserRef), 'aria-busy': busy ? 'true' : null, onclick: () => openBrowser(a, p) }, t('br.openIt')))
1057
+ }
1058
+ function renderBrowser() {
1059
+ if (!br || dlg.kind !== 'browser') return
1060
+ const a = S.state && S.state.accounts.find((x) => x.ref === br.ref)
1061
+ const info = a && browserFor(a)
1062
+ if (!info) { dlg.el.close(); return }
1063
+ const url = BR_URL[a.family]
1064
+ const body = []
1065
+ const foot = [h('button', { class: 'btn', type: 'button', disabled: br.busy, onclick: () => dlg.el.close() }, t('btn.close'))]
1066
+ if (!info.b.enabled) {
1067
+ body.push(h('p', null, t('br.offIntro', { ref: a.ref, url })))
1068
+ body.push(h('p', { class: 'small' }, t('br.reads')), h('ul', { class: 'small' }, ['br.read1', 'br.read2', 'br.read3'].map((k) => h('li', null, t(k)))))
1069
+ body.push(h('p', { class: 'muted small' }, t('br.never')))
1070
+ foot.push(h('button', { class: 'btn btn-primary', type: 'button', disabled: br.busy, 'aria-busy': br.busy ? 'true' : null, onclick: () => brSwitch(true) }, br.busy ? spinner() : null, t('br.enable')))
1071
+ } else if (!info.b.found) {
1072
+ body.push(h('p', { class: 'note' }, t('br.noChrome')))
1073
+ } else {
1074
+ const others = info.b.profiles.filter((p) => !info.found.includes(p))
1075
+ if (info.found.length) body.push(h('p', { class: 'small' }, t('br.found', { ref: a.ref })), h('ul', { class: 'br-list' }, info.found.map((p) => brProfileRow(a, p))))
1076
+ else body.push(h('p', { class: 'muted small' }, t(info.b.families.matched.includes(a.family) ? 'br.none' : 'br.noMatch', { ref: a.ref, url })))
1077
+ if (others.length) body.push(h('p', { class: 'small' }, t(info.found.length ? 'br.others' : 'br.all')), h('ul', { class: 'br-list' }, others.map((p) => brProfileRow(a, p))))
1078
+ body.push(h('p', { class: 'muted small' }, t('br.local')))
1079
+ foot.unshift(h('button', { class: 'btn', type: 'button', disabled: br.busy, onclick: () => brSwitch(false) }, t('br.disable')))
1080
+ }
1081
+ if (br.error) body.push(errorNote(br.error, null))
1082
+ setKids(dlg.el, dialogFrame(t('br.title') + ' · ' + a.ref, a.family, body, foot))
1083
+ }
1084
+ // How to get a Host that cannot run: the sentence, then on its own line the install command to copy (with the
1085
+ // install page next to it) when the Family has one; without a command the sentence ends with the page's link.
1086
+ function hostNote(fam) {
1087
+ const cmdLine = () => h('div', { class: 'note-line note-cmd' }, codeCopy(fam.installCommand), link(t('acct.installDocs'), fam.installUrl))
1088
+ if (fam.brokenBin) {
1089
+ const text = tx('acct.brokenHost', { host: fam.title, path: h('code', null, fam.brokenBin) })
1090
+ return fam.installCommand ? [h('div', { class: 'note-line' }, text), cmdLine()] : [h('div', { class: 'note-line' }, text, ' ', link(t('acct.installIt'), fam.installUrl) || '')]
1091
+ }
1092
+ if (fam.installCommand) return [h('div', { class: 'note-line' }, tx('acct.installHostCmd', { host: fam.title })), cmdLine()]
1093
+ return tx('acct.installHost', { host: fam.title, link: link(t('acct.installIt'), fam.installUrl) || '' })
1094
+ }
953
1095
  function notes(a) {
954
1096
  const fam = family(a.family)
955
1097
  const out = []
956
1098
  if (a.error) out.push(h('div', { class: 'note note-fail' }, h('b', null, t('acct.readError')), ' ', h('span', null, a.error)))
957
- if (a.hostInstalled === false) out.push(h('div', { class: 'note note-warn' }, tx('acct.installHost', { host: fam.title, link: link(t('acct.installIt'), fam.installUrl) || '' })))
958
- // The other reasons already show in the row (read error, missing tool, sign-in, Health); a full window does not.
959
- const why = attentionWhy(a, healthOf(a.ref), Date.now(), WARN)
960
- if (why && why.key === 'why.quota-high') out.push(h('div', { class: 'note note-warn' }, t('acct.nearLimit', why.vars)))
1099
+ if (a.hostInstalled === false) out.push(h('div', { class: 'note note-warn' }, hostNote(fam)))
961
1100
  return out.length ? h('div', { class: 'row-notes' }, out) : null
962
1101
  }
963
1102
  // What a row or card shows depends on more than the account (health, view preferences, language); the
964
1103
  // fingerprint covers what its click handlers use.
965
- function rowSig(a) { return sig([a, healthOf(a.ref), isNext(a), ui.desktopRef === a.ref, folded(a)]) }
966
- // Each account can be folded to one line (its name) and opened again, like a family group; the choice is kept in
967
- // this browser's preferences.
968
- function folded(a) { return prefs.rows[a.ref] === true }
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.
1106
+ function inLogin(a) { return ui.loginRefs.has(a.ref) }
1107
+ function rowEmail(a) { return inLogin(a) ? null : emailLine(a) }
1108
+ // A row shows who the account is, its quota and its last use; a click opens the details under it (model, tokens,
1109
+ // Health) and closes them again. The choice is kept in this browser's preferences.
1110
+ function isOpen(a) { return prefs.open[a.ref] === true }
969
1111
  function rowToggle(a) {
970
- const open = !folded(a)
1112
+ const open = isOpen(a)
971
1113
  const label = t(open ? 'row.collapse' : 'row.expand', { name: a.name })
972
1114
  return h('button', { class: 'icon-btn row-toggle', type: 'button', 'aria-expanded': String(open), 'aria-label': label, title: label, onclick: (e) => { e.stopPropagation(); toggleRow(a) } }, icon('chevron', 'chev'))
973
1115
  }
974
1116
  // The whole account cell (and a folded row anywhere) folds and opens it, not only the arrow; the buttons and links
975
1117
  // inside keep their own action, and a click that ends a text selection does nothing.
976
- function foldClick(a) {
1118
+ // A click folds the row unless it ended a drag that selects text; a selection left from before does not block it.
1119
+ let downAt = null
1120
+ document.addEventListener('pointerdown', (e) => { downAt = { x: e.clientX, y: e.clientY } }, true)
1121
+ // A double click would select a word under the pointer as well as fold the row twice; keep the word unselected.
1122
+ document.addEventListener('mousedown', (e) => {
1123
+ if (e.detail > 1 && e.target.closest && e.target.closest('.fold-hit') && !e.target.closest('input, select, textarea')) e.preventDefault()
1124
+ })
1125
+ function foldClick(a) { return foldHit(() => toggleRow(a)) }
1126
+ function foldHit(run) {
977
1127
  return (e) => {
978
1128
  if (e.target.closest('button, a, input, select, textarea, .tip')) return
979
- if (window.getSelection && String(window.getSelection())) return
980
- toggleRow(a)
1129
+ const dragged = downAt && Math.hypot(e.clientX - downAt.x, e.clientY - downAt.y) > 4
1130
+ if (dragged && window.getSelection && String(window.getSelection())) return
1131
+ run()
981
1132
  }
982
1133
  }
983
- // Folding animates: the details fade out, then the row's height eases to one line; opening eases the height out
984
- // and fades the details in. The arrow turns with it. Reduced motion just switches.
1134
+ // Folding animates: the height eases while the details fade and the arrow turns. A click during the motion turns
1135
+ // it around from where it is. Reduced motion just switches.
985
1136
  const EASE = 'cubic-bezier(.2, 0, 0, 1)'
986
- function rowDetails(el) { return Array.from(el.querySelectorAll(':scope > :not(.ident):not(.more):not(.card-head), .ident-email, .ident-sub')) }
1137
+ const FOLD_MS = 220
1138
+ const folding = new Set()
1139
+ function rowDetails(el) { return Array.from(el.querySelectorAll(':scope > .row-detail')) }
1140
+ function moving() { return !(REDUCED || document.hidden || !Element.prototype.animate) }
1141
+ // Eases el's height from 'from' to its current height (measured with the 'hide' parts out of the layout),
1142
+ // fading 'parts' toward 'opacity'; 'done' runs at the end unless a newer fold took over.
1143
+ function ease(el, from, hide, parts, opacity, done) {
1144
+ const run = el._fold
1145
+ const fades = parts.map((p) => +getComputedStyle(p).opacity)
1146
+ if (run) run.cancel()
1147
+ for (const p of hide) p.style.display = 'none'
1148
+ const to = el.getBoundingClientRect().height
1149
+ for (const p of hide) p.style.display = ''
1150
+ el.style.overflow = 'hidden'
1151
+ const anims = [el.animate([{ height: from + 'px' }, { height: to + 'px' }], { duration: FOLD_MS, easing: EASE })]
1152
+ parts.forEach((p, i) => anims.push(p.animate([{ opacity: fades[i] }, { opacity }], { duration: FOLD_MS, easing: 'ease-out', fill: 'forwards' })))
1153
+ const me = { cancel: () => { for (const x of anims) x.cancel() } }
1154
+ el._fold = me
1155
+ folding.add(el)
1156
+ anims[0].finished.then(() => {
1157
+ if (el._fold !== me) return
1158
+ el._fold = null
1159
+ folding.delete(el)
1160
+ el.style.overflow = ''
1161
+ me.cancel()
1162
+ done()
1163
+ }, () => {})
1164
+ }
1165
+ // A row folds in place: only its arrow, class and details change, so the rest of the list is left alone.
987
1166
  function toggleRow(a) {
988
- const closing = !folded(a)
989
- const find = () => document.querySelector('[data-ref="' + CSS.escape(a.ref) + '"]')
990
- const apply = () => {
991
- if (closing) prefs.rows[a.ref] = true; else delete prefs.rows[a.ref]
992
- savePrefs()
993
- renderAccounts()
1167
+ const open = !isOpen(a)
1168
+ if (open) prefs.open[a.ref] = true; else delete prefs.open[a.ref]
1169
+ savePrefs()
1170
+ const el = document.querySelector('#accounts [data-ref="' + CSS.escape(a.ref) + '"]')
1171
+ if (!el) { renderAccounts(); return }
1172
+ const from = el.getBoundingClientRect().height
1173
+ const btn = el.querySelector('.row-toggle')
1174
+ if (btn) syncAttrs(btn, rowToggle(a))
1175
+ el.classList.toggle('row-open', open)
1176
+ el.dataset.sig = rowSig(a)
1177
+ let detail = rowDetails(el)[0]
1178
+ if (open && !detail) {
1179
+ detail = rowDetail(a, Boolean(rowEmail(a)))
1180
+ const more = el.querySelector(':scope > .more')
1181
+ el.insertBefore(detail, more ? more.nextSibling : null)
1182
+ detail.style.opacity = '0'
994
1183
  }
995
- const old = find()
996
- // A hidden page delays animation events; switch at once there too.
997
- if (REDUCED || document.hidden || !old || !old.animate) { apply(); return }
998
- const from = old.getBoundingClientRect().height
999
- const fade = closing ? rowDetails(old).map((d) => d.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 110, easing: 'ease-out', fill: 'forwards' }).finished) : []
1000
- Promise.all(fade).catch(() => {}).then(() => {
1001
- apply()
1002
- const el = find()
1003
- if (!el) return
1004
- const to = el.getBoundingClientRect().height
1005
- el.style.overflow = 'hidden'
1006
- const done = () => { el.style.overflow = '' }
1007
- el.animate([{ height: from + 'px' }, { height: to + 'px' }], { duration: 220, easing: EASE }).finished.then(done, done)
1008
- const chev = el.querySelector('.row-toggle .chev')
1009
- if (chev) chev.animate([{ transform: closing ? 'rotate(90deg)' : 'rotate(0deg)' }, { transform: closing ? 'rotate(0deg)' : 'rotate(90deg)' }], { duration: 220, easing: EASE })
1010
- if (!closing) for (const d of rowDetails(el)) d.animate([{ opacity: 0, transform: 'translateY(-4px)' }, { opacity: 1, transform: 'none' }], { duration: 200, delay: 60, easing: 'ease-out', fill: 'backwards' })
1011
- })
1184
+ if (!detail) return
1185
+ if (!moving()) { if (!open) detail.remove(); return }
1186
+ ease(el, from, open ? [] : [detail], [detail], open ? 1 : 0, () => { if (!open) detail.remove() })
1187
+ detail.style.opacity = ''
1188
+ }
1189
+ function groupParts(el) { return Array.from(el.querySelectorAll(':scope > :not(.group-head)')) }
1190
+ // A group closes on the page as it is and renders when the motion ends; it opens by rendering its body first.
1191
+ function toggleGroup(key, domKey) {
1192
+ const find = () => document.querySelector('#accounts > [data-key="' + CSS.escape(domKey) + '"]')
1193
+ const el = find()
1194
+ const btn = el && el.querySelector('.group-head .group-toggle')
1195
+ const open = btn ? btn.getAttribute('aria-expanded') !== 'true' : prefs.collapsed[key] === true
1196
+ if (open) delete prefs.collapsed[key]; else prefs.collapsed[key] = true
1197
+ savePrefs()
1198
+ if (!el || !moving()) { renderAccounts(); return }
1199
+ const from = el.getBoundingClientRect().height
1200
+ if (!open) {
1201
+ btn.setAttribute('aria-expanded', 'false')
1202
+ const parts = groupParts(el)
1203
+ ease(el, from, parts, parts, 0, renderAccounts)
1204
+ return
1205
+ }
1206
+ // Turned around while closing: the body is still there.
1207
+ if (el._fold) { btn.setAttribute('aria-expanded', 'true'); ease(el, from, [], groupParts(el), 1, renderAccounts); return }
1208
+ renderAccounts()
1209
+ const now = find()
1210
+ if (!now) return
1211
+ const parts = groupParts(now)
1212
+ for (const p of parts) p.style.opacity = '0'
1213
+ const chev = now.querySelector('.group-head .chev')
1214
+ if (chev) chev.animate([{ transform: 'rotate(0deg)' }, { transform: 'rotate(90deg)' }], { duration: FOLD_MS, easing: EASE })
1215
+ ease(now, from, [], parts, 1, () => {})
1216
+ for (const p of parts) p.style.opacity = ''
1217
+ }
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 = ''
1012
1242
  }
1013
1243
  // The Account 'sideby next <family>' would pick; the server ranks it with the CLI's rule (spec §3.13).
1014
1244
  function isNext(a) { return Boolean(S.state && S.state.handoff && S.state.handoff[a.family] === a.ref) }
1015
1245
  function row(a, now, cols) {
1016
- if (folded(a))
1017
- return h('div', { class: 'row row-folded fold-hit' + (a.error ? ' row-error' : ''), role: 'listitem', 'data-ref': a.ref, 'data-key': a.ref, 'data-sig': rowSig(a), onclick: foldClick(a) },
1018
- h('div', { class: 'ident' }, rowToggle(a), badge(a.family, 'sm'), h('div', { class: 'ident-text' }, nameLine(a))),
1019
- moreButton(a))
1020
- return h('div', { class: 'row' + (a.error ? ' row-error' : ''), role: 'listitem', 'data-ref': a.ref, 'data-key': a.ref, 'data-sig': rowSig(a) },
1021
- h('div', { class: 'ident fold-hit', onclick: foldClick(a) }, rowToggle(a), badge(a.family, 'sm'), h('div', { class: 'ident-text' }, nameLine(a), emailLine(a), subLine(a))),
1022
- quotaBlock(a, now, cols),
1023
- usageBlock(a),
1246
+ const open = isOpen(a)
1247
+ const email = rowEmail(a)
1248
+ 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),
1024
1252
  lastUsed(a),
1025
- h('div', { class: 'cell-health' }, healthChip(a)),
1026
1253
  moreButton(a),
1254
+ open ? rowDetail(a, Boolean(email)) : null,
1027
1255
  notes(a))
1028
1256
  }
1257
+ // Directories signed in as one login (spec §3.21): the vendor counts one quota, so the head shows the email and
1258
+ // that quota once, and each directory keeps a row of its own for what is its own (name, model, tokens, Health).
1259
+ function loginGroup(members, now, cols) {
1260
+ const first = members[0]
1261
+ const source = members.find((a) => a.quota && a.quota.status === 'ok' && !a.quota.from) || first
1262
+ const id = first.identity || {}
1263
+ const names = members.map((a) => a.name).join(', ')
1264
+ const hint = t('login.hint', { names })
1265
+ const m = source.quota && source.quota.status === 'ok' ? attentionWhy({ family: source.family, name: source.name, ref: source.ref, quota: source.quota }, null, now, WARN) : null
1266
+ const why = m ? t(m.key, m.vars) : ''
1267
+ const email = id.email ? emailText(id.email) : ''
1268
+ const key = loginKey(members.map((a) => a.ref))
1269
+ const collapsed = prefs.collapsed[key] === true
1270
+ const label = t(collapsed ? 'login.expand' : 'login.collapse', { names })
1271
+ const toggle = h('button', { class: 'icon-btn row-toggle', type: 'button', 'aria-expanded': String(!collapsed), 'aria-label': label, title: label, onclick: (e) => { e.stopPropagation(); toggleLogin(key) } }, icon('chevron', 'chev'))
1272
+ return h('div', { class: 'login' + (collapsed ? ' is-collapsed' : ''), role: 'listitem', 'aria-label': t('login.label', { names }), 'data-key': key },
1273
+ h('div', { class: 'row login-head fold-hit', onclick: foldHit(() => toggleLogin(key)) },
1274
+ h('div', { class: 'ident' }, toggle, badge(first.family, 'sm'),
1275
+ h('div', { class: 'ident-text' },
1276
+ h('div', { class: 'ident-name' },
1277
+ email ? h('button', { class: 'name email-btn', type: 'button', title: t('menu.copyEmail'), 'aria-label': t('menu.copyEmail') + ' ' + email, onclick: () => copyText(id.email) }, email) : h('span', { class: 'name' }, names),
1278
+ why ? h('span', { class: 'dot dot-warn att-dot', title: why, role: 'img', 'aria-label': why }) : null),
1279
+ h('div', { class: 'ident-sub', title: hint }, dots([id.org ? h('span', null, id.org) : null, h('span', null, t('login.count', { n: members.length })), collapsed ? h('span', null, names) : null])))),
1280
+ quotaBlock(source, now, cols, source.quota && source.quota.status === 'ok' ? source.ref : null),
1281
+ h('div', { class: 'last' }),
1282
+ h('div', { class: 'more' })),
1283
+ collapsed ? null : h('div', { class: 'login-rows', role: 'list', 'data-key': 'rows', 'data-patch': '' }, members.map((a) => row(a, now, cols))))
1284
+ }
1285
+ // What a click on a row opens: how it runs (ref, model and effort, a shared login), its tokens and its Health.
1286
+ function sameLoginNote(a) {
1287
+ if (!a.sameLogin || !a.sameLogin.length || inLogin(a)) return null
1288
+ const names = a.sameLogin.map(refName).join(', ')
1289
+ return h('div', { class: 'rd-note', title: t('acct.sameLoginHint', { names }) }, t('acct.sameLogin', { names }))
1290
+ }
1291
+ function rowDetail(a, withSub) {
1292
+ const same = sameLoginNote(a)
1293
+ return h('div', { class: 'row-detail' },
1294
+ withSub || same ? h('div', { class: 'rd-item rd-run' }, h('span', { class: 'rd-label' }, t('row.runs')), withSub ? subLine(a) : null, same) : null,
1295
+ h('div', { class: 'rd-item rd-usage' }, h('span', { class: 'rd-label' }, t('col.tokens', { n: BOOT.usageDays })), usageBlock(a)),
1296
+ h('div', { class: 'rd-item rd-health' }, h('span', { class: 'rd-label' }, t('col.health')), healthChip(a)))
1297
+ }
1298
+ // A card shows everything at once; it has room for it.
1029
1299
  function card(a, now, cols) {
1030
- if (folded(a))
1031
- return h('article', { class: 'card card-folded fold-hit' + (a.error ? ' card-error' : ''), 'data-ref': a.ref, 'data-key': a.ref, 'data-sig': rowSig(a), 'aria-label': a.ref, onclick: foldClick(a) },
1032
- h('header', { class: 'card-head' }, rowToggle(a), badge(a.family, 'sm'), h('div', { class: 'ident-text' }, nameLine(a)), moreButton(a)))
1033
1300
  return h('article', { class: 'card' + (a.error ? ' card-error' : ''), 'data-ref': a.ref, 'data-key': a.ref, 'data-sig': rowSig(a), 'aria-label': a.ref },
1034
- h('header', { class: 'card-head fold-hit', onclick: foldClick(a) },
1035
- rowToggle(a),
1301
+ h('header', { class: 'card-head' },
1036
1302
  badge(a.family, 'sm'),
1037
- h('div', { class: 'ident-text' }, nameLine(a), emailLine(a), subLine(a)),
1303
+ h('div', { class: 'ident-text' }, nameLine(a), emailLine(a), subLine(a), sameLoginNote(a)),
1038
1304
  moreButton(a)),
1039
1305
  notes(a),
1040
1306
  quotaBlock(a, now, cols),
@@ -1048,9 +1314,7 @@ const MAIN = String.raw `
1048
1314
  return h('div', { class: 'col-head', 'aria-hidden': 'true', 'data-key': 'cols' },
1049
1315
  h('span', { class: 'ch-id' }, t('col.account')),
1050
1316
  h('span', { class: 'quota' }, titled ? cols.map((c) => h('span', null, t(left ? 'col.left' : 'col.used', { label: c }))) : null),
1051
- h('span', { class: 'ch-usage' }, t('col.tokens', { n: BOOT.usageDays })),
1052
1317
  h('span', { class: 'ch-last' }, t('col.last')),
1053
- h('span', { class: 'ch-health' }, t('col.health')),
1054
1318
  h('span', { class: 'ch-more' }))
1055
1319
  }
1056
1320
  // Quota is on but no account of the Family has numbers yet: say what brings the first ones.
@@ -1153,11 +1417,7 @@ const MAIN = String.raw `
1153
1417
  const collapsed = prefs.collapsed['app:' + app] === true
1154
1418
  const bodyId = 'grp-app-' + app.replace(/[^a-zA-Z0-9_-]/g, '_')
1155
1419
  const head = h('div', { class: 'group-head', 'data-key': 'head' },
1156
- 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: () => {
1157
- if (collapsed) delete prefs.collapsed['app:' + app]; else prefs.collapsed['app:' + app] = true
1158
- savePrefs()
1159
- renderAccounts()
1160
- } },
1420
+ h('button', { class: 'group-toggle', type: 'button', 'aria-expanded': String(!collapsed), 'aria-controls': bodyId, title: t(collapsed ? 'group.expand' : 'group.collapse'), 'aria-label': t('group.toggle', { title: app, count: String(list.length), action: t(collapsed ? 'group.expand' : 'group.collapse') }), onclick: () => toggleGroup('app:' + app, 'app:' + app) },
1161
1421
  icon('chevron', 'chev'), appBadge(), h('span', { class: 'group-title' }, app),
1162
1422
  h('span', { class: 'group-count' }, String(list.length))),
1163
1423
  h('div', { class: 'group-meta' },
@@ -1225,11 +1485,7 @@ const MAIN = String.raw `
1225
1485
  const bodyId = 'grp-' + fam.replace(/[^a-zA-Z0-9_-]/g, '_')
1226
1486
  const cards = prefs.view === 'cards'
1227
1487
  const head = h('div', { class: 'group-head', 'data-key': 'head' },
1228
- 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: () => {
1229
- if (collapsed) delete prefs.collapsed[fam]; else prefs.collapsed[fam] = true
1230
- savePrefs()
1231
- renderAccounts()
1232
- } },
1488
+ h('button', { class: 'group-toggle', type: 'button', 'aria-expanded': String(!collapsed), 'aria-controls': bodyId, title: t(collapsed ? 'group.expand' : 'group.collapse'), 'aria-label': t('group.toggle', { title: info.title, count: list.length === total ? String(total) : list.length + ' / ' + total, action: t(collapsed ? 'group.expand' : 'group.collapse') }), onclick: () => toggleGroup(fam, 'g:' + fam) },
1233
1489
  icon('chevron', 'chev'), badge(fam, 'sm'), h('span', { class: 'group-title' }, info.title),
1234
1490
  h('span', { class: 'group-count' }, list.length === total ? String(total) : list.length + ' / ' + total)),
1235
1491
  h('div', { class: 'group-meta' },
@@ -1243,7 +1499,7 @@ const MAIN = String.raw `
1243
1499
  off && setup && setup.plan.status === 'blocked' ? h('span', { class: 'muted small label-tip' }, t('quota.blocked'), tipRaw(t('quota.blocked'), setup.plan.message)) : null))
1244
1500
  const body = collapsed ? null : cards
1245
1501
  ? h('div', { class: 'grid', id: bodyId, 'data-key': 'cards', 'data-patch': '' }, list.map((a) => card(a, now, cols)))
1246
- : h('div', { class: 'rows', id: bodyId, role: 'list', 'data-key': 'rows', 'data-patch': '' }, list.map((a) => row(a, now, cols)))
1502
+ : h('div', { class: 'rows', id: bodyId, role: 'list', 'data-key': 'rows', 'data-patch': '' }, loginClusters(list).map((x) => Array.isArray(x) ? loginGroup(x, now, cols) : row(x, now, cols)))
1247
1503
  return h('section', { class: 'group' + (cards ? ' group-cards' : ' group-list') + (collapsed ? ' is-collapsed' : ''), 'aria-label': info.title, 'data-key': 'g:' + fam, 'data-patch': '' },
1248
1504
  head, collapsed || cards ? null : colHead(cols, list), body)
1249
1505
  }
@@ -1255,7 +1511,7 @@ const MAIN = String.raw `
1255
1511
  h('h3', null, t('empty.title')),
1256
1512
  h('p', { class: 'muted' }, t('empty.body')),
1257
1513
  fams.length ? h('ul', { class: 'host-list' }, fams.map((f) => h('li', null, badge(f.id, 'sm'), h('span', null, f.title),
1258
- f.installed ? h('span', { class: 'muted small' }, tx('empty.runOnce', { bin: h('code', null, f.bin) })) : link(t('empty.install'), f.installUrl, 'btn btn-sm')))) : h('p', { class: 'muted small' }, t('empty.noFamilies')),
1514
+ 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')),
1259
1515
  BOOT.readOnly ? null : h('button', { class: 'btn', type: 'button', onclick: () => openCreate(null) }, icon('plus'), t('empty.create')))
1260
1516
  }
1261
1517
  function clearFilters() {
@@ -1302,6 +1558,9 @@ const MAIN = String.raw `
1302
1558
  if (!shown.length && !apps.length) { patch(box, noMatch()); return }
1303
1559
  const cols = quotaColumns(shown)
1304
1560
  box.style.setProperty('--qn', String(cols.length))
1561
+ ui.loginRefs = new Set()
1562
+ if (prefs.view !== 'cards')
1563
+ for (const x of loginClusters(shown)) if (Array.isArray(x)) for (const a of x) ui.loginRefs.add(a.ref)
1305
1564
  const ids = S.state.families.map((f) => f.id)
1306
1565
  for (const a of all) if (!ids.includes(a.family)) ids.push(a.family)
1307
1566
  const groups = []
@@ -1840,7 +2099,7 @@ const MAIN = String.raw `
1840
2099
  renderPreview()
1841
2100
  })
1842
2101
  const n = S.state.accounts.filter((a) => a.family === f.id).length
1843
- const sub = st === 'ready' ? tn('nf.accounts', n) : st === 'no-main' ? tx('nf.runOnce', { bin: h('code', null, f.bin) }) : t('nf.notInstalled')
2102
+ const sub = st === 'ready' ? tn('nf.accounts', n) : st === 'no-main' ? tx('nf.runOnce', { bin: h('code', null, f.bin) }) : t(f.brokenBin ? 'nf.broken' : 'nf.notInstalled')
1844
2103
  return h('label', { class: 'fam-card' + (st === 'ready' ? '' : ' is-off') }, input, badge(f.id, 'sm'),
1845
2104
  h('span', { class: 'fam-text' }, h('span', { class: 'fam-title' }, f.title), h('span', { class: 'fam-sub' }, sub)))
1846
2105
  })
@@ -2048,15 +2307,6 @@ const MAIN = String.raw `
2048
2307
  let ah = null
2049
2308
  const HINT_QUIET_MS = 24 * 3600000
2050
2309
  function ahState() { return S.state && S.state.autoHandoff }
2051
- function renderHandoffButton() {
2052
- const b = $('handoff')
2053
- const st = ahState()
2054
- b.hidden = !st
2055
- if (!st) return
2056
- const on = st.settings.auto
2057
- setKids(b, [h('span', { class: 'hide-sm' }, t('ah.button')), h('span', { class: 'chip chip-sm' + (on ? ' chip-ok' : '') }, on ? t('ah.on') : t('ah.off'))])
2058
- b.setAttribute('aria-label', t('ah.button') + ': ' + (on ? t('ah.on') : t('ah.off')))
2059
- }
2060
2310
  function handoffHint() {
2061
2311
  const st = ahState()
2062
2312
  if (!st || BOOT.readOnly) return null
@@ -2532,7 +2782,8 @@ const MAIN = String.raw `
2532
2782
  // ---------- Remote Control (spec §3.22) ----------
2533
2783
  // Shown while some Claude settings file turns Remote Control on. "Turn all off" first lists each file, the change
2534
2784
  // and the accounts it reaches, in the banner itself; the toast after it carries the undo.
2535
- const rc = { confirm: false, busy: false }
2785
+ // target: the side the dialog's switch was moved to, waiting for its confirmation; null while nothing is chosen.
2786
+ const rc = { confirm: false, busy: false, target: null }
2536
2787
  const shortRef = (ref) => ref.replace(/^claude:/, '')
2537
2788
  function remoteControlBanner() {
2538
2789
  const groups = S.state.remoteControl || []
@@ -2549,29 +2800,34 @@ const MAIN = String.raw `
2549
2800
  h('button', { class: 'btn btn-sm btn-primary', type: 'button', onclick: () => { rc.confirm = true; renderBanners() } }, t('rc.turnOff')),
2550
2801
  h('button', { class: 'btn btn-sm', type: 'button', onclick: () => { prefs.rcHide = true; savePrefs(); renderBanners(); toast(t('rc.hidden')) } }, t('rc.keep'))))
2551
2802
  const lines = todo.map((g) => h('li', null, h('code', null, g.file), ' ',
2552
- t('rc.change', { from: g.state === 'on' ? 'true' : t('rc.notSet') }), ' ',
2803
+ t('rc.change', { from: g.state === 'on' ? 'true' : t('rc.notSet'), to: 'false' }), ' ',
2553
2804
  h('span', { class: 'muted' }, t('rc.usedBy', { refs: g.accounts.map(shortRef).join(', ') }))))
2554
2805
  return h('div', { class: 'banner banner-warn', role: 'status' },
2555
2806
  h('div', null, h('b', null, t('rc.confirmTitle')), h('ul', null, lines), h('div', { class: 'small' }, t('rc.confirmNote'))),
2556
2807
  h('div', { class: 'banner-actions' },
2557
- h('button', { class: 'btn btn-sm btn-primary', type: 'button', disabled: rc.busy, onclick: remoteControlOff }, rc.busy ? spinner() : null, t('rc.confirm')),
2808
+ h('button', { class: 'btn btn-sm btn-primary', type: 'button', disabled: rc.busy, onclick: () => setRemoteControl('off') }, rc.busy ? spinner() : null, t('rc.confirm')),
2558
2809
  h('button', { class: 'btn btn-sm', type: 'button', disabled: rc.busy, onclick: () => { rc.confirm = false; renderBanners() } }, t('btn.cancel'))))
2559
2810
  }
2560
- async function remoteControlOff() {
2811
+ async function setRemoteControl(to) {
2561
2812
  rc.busy = true
2562
2813
  renderBanners()
2814
+ if (dlg.kind === 'rc') renderRc()
2815
+ const fail = to === 'on' ? 'rc.failedOn' : 'rc.failed'
2563
2816
  try {
2564
- const r = await api('/api/remote-control/off', {})
2817
+ const r = await api('/api/remote-control/' + to, {})
2565
2818
  if (r.ok) {
2566
- // Hide the banner now; the reload that follows confirms it.
2567
- S.state.remoteControl = (S.state.remoteControl || []).map((g) => g.state === 'on' || g.state === 'default' ? { ...g, state: 'off', undo: true } : g)
2568
- toast(t('rc.done'), { action: { label: t('toast.undo'), run: remoteControlUndo }, duration: 12000 })
2819
+ // Show the new state now; the reload that follows confirms it.
2820
+ const changed = new Set(remoteControlTodo(S.state.remoteControl || [], to).map((g) => g.file))
2821
+ S.state.remoteControl = (S.state.remoteControl || []).map((g) => changed.has(g.file) ? { ...g, state: to, undo: true } : g)
2822
+ toast(t(to === 'on' ? 'rc.doneOn' : 'rc.done'), { action: { label: t('toast.undo'), run: remoteControlUndo }, duration: 12000 })
2569
2823
  }
2570
- else toast(t('rc.failed', { msg: r.message }), { kind: 'fail' })
2571
- } catch (e) { toast(t('rc.failed', { msg: e.message }), { kind: 'fail' }) }
2824
+ else toast(t(fail, { msg: r.message }), { kind: 'fail' })
2825
+ } catch (e) { toast(t(fail, { msg: e.message }), { kind: 'fail' }) }
2572
2826
  rc.busy = false
2573
2827
  rc.confirm = false
2828
+ rc.target = null
2574
2829
  renderBanners()
2830
+ if (dlg.kind === 'rc') renderRc()
2575
2831
  await load({ silent: true })
2576
2832
  }
2577
2833
  async function remoteControlUndo() {
@@ -2581,6 +2837,109 @@ const MAIN = String.raw `
2581
2837
  } catch (e) { toast(t('toast.undoFailed', { msg: e.message }), { kind: 'fail' }) }
2582
2838
  await load({ silent: true })
2583
2839
  }
2840
+ // The header button keeps the setting in reach after the banner is gone (turned off, or "Keep it on").
2841
+ function rcSummary() {
2842
+ const api = S.state.accounts.filter((a) => a.kind === 'api').map((a) => a.ref)
2843
+ return remoteControlSummary(S.state.remoteControl || [], api)
2844
+ }
2845
+ // ---------- settings ----------
2846
+ // One header button holds what is set once and rarely changed: language, theme, Remote Control and Auto Handoff.
2847
+ // A dot on it says Remote Control starts by itself, so that state stays visible after its banner is gone.
2848
+ function settingsSections() {
2849
+ const out = []
2850
+ if (!LANG_FIXED) out.push('lang')
2851
+ if (BOOT.colorScheme === 'auto') out.push('theme')
2852
+ if (S.state && rcSummary()) out.push('rc')
2853
+ if (S.state && ahState()) out.push('ah')
2854
+ return out
2855
+ }
2856
+ function renderSettingsButton() {
2857
+ const b = $('settings')
2858
+ const secs = settingsSections()
2859
+ b.hidden = !secs.length
2860
+ const warn = secs.includes('rc') && rcSummary() === 'on'
2861
+ setKids(b, [icon('gear'), h('span', { class: 'hide-sm' }, t('set.button')), warn ? h('span', { class: 'dot set-dot', 'aria-hidden': 'true' }) : null])
2862
+ b.setAttribute('aria-label', t('set.button') + (warn ? ': ' + t('set.rcOn') : ''))
2863
+ refreshPopover('settings')
2864
+ if (dlg.kind === 'rc') renderRc()
2865
+ }
2866
+ function settingsPop() {
2867
+ const secs = settingsSections()
2868
+ const out = []
2869
+ const seg = (label, opts, cur, pick) => h('div', { class: 'set-row' }, h('span', { class: 'set-label' }, label),
2870
+ h('div', { class: 'seg', role: 'group', 'aria-label': label }, opts.map(([v, text]) =>
2871
+ h('button', { class: 'seg-btn', type: 'button', 'aria-pressed': String(v === cur), onclick: () => pick(v) }, text))))
2872
+ const link = (label, state, cls, run) => h('button', { class: 'menu-item set-link', type: 'button', onclick: () => { closeMenu(); run() } },
2873
+ h('span', { class: 'menu-label' }, label), h('span', { class: 'chip chip-sm ' + cls }, state), icon('chevron'))
2874
+ if (secs.includes('lang')) out.push(seg(t('set.lang'), [['en', 'English'], ['zh', '中文']], lang, setLang))
2875
+ if (secs.includes('theme')) out.push(seg(t('set.theme'), CHOICES.theme.map((v) => [v, t('set.theme.' + v)]), prefs.theme, setTheme))
2876
+ if (out.length && (secs.includes('rc') || secs.includes('ah'))) out.push(h('div', { class: 'menu-sep', role: 'separator' }))
2877
+ if (secs.includes('rc')) {
2878
+ const sum = rcSummary()
2879
+ out.push(link(t('rc.button'), t('rc.state.' + sum), sum === 'on' ? 'chip-warn' : sum === 'off' ? 'chip-ok' : '', openRc))
2880
+ }
2881
+ if (secs.includes('ah')) {
2882
+ const on = ahState().settings.auto
2883
+ out.push(link(t('ah.button'), on ? t('ah.on') : t('ah.off'), on ? 'chip-ok' : '', () => setPage('handoff')))
2884
+ }
2885
+ return out
2886
+ }
2887
+ function setLang(v) {
2888
+ if (LANG_FIXED || v === lang) return
2889
+ lang = v
2890
+ prefs.lang = lang
2891
+ savePrefs()
2892
+ renderStatic()
2893
+ buildToolbar()
2894
+ renderAll()
2895
+ if (dlg.kind === 'create' && nf && !nf.creating) renderCreate()
2896
+ else if (dlg.kind === 'setup' && qs) renderSetup()
2897
+ else if (dlg.kind === 'tune' && tuneState) renderTune()
2898
+ else if (dlg.kind === 'model' && md) { md.form = null; renderModel() }
2899
+ else if (dlg.kind === 'browser') renderBrowser()
2900
+ else if (dlg.kind === 'update' && up) renderUpdate()
2901
+ else if (dlg.kind === 'rc') renderRc()
2902
+ }
2903
+ function setTheme(v) {
2904
+ prefs.theme = v
2905
+ savePrefs()
2906
+ applyTheme()
2907
+ refreshPopover('settings')
2908
+ }
2909
+ function openRc() {
2910
+ if (!S.state || !rcSummary()) return
2911
+ rc.target = null
2912
+ showDialog('rc')
2913
+ renderRc()
2914
+ }
2915
+ // One switch for every Claude settings file: moving it lists what changes, and only the confirmation writes.
2916
+ function renderRc() {
2917
+ const groups = S.state.remoteControl || []
2918
+ const sum = rcSummary()
2919
+ const to = rc.target
2920
+ const todo = to ? remoteControlTodo(groups, to) : []
2921
+ const changing = new Set(todo.map((g) => g.file))
2922
+ const undo = groups.some((g) => g.undo)
2923
+ const pick = (v) => { rc.target = v === sum ? null : v; renderRc() }
2924
+ const sw = h('div', { class: 'set-row rc-switch' }, h('span', null, t('rc.switch')),
2925
+ h('div', { class: 'seg', role: 'group', 'aria-label': t('rc.switch') }, ['on', 'off'].map((v) =>
2926
+ h('button', { class: 'seg-btn', type: 'button', disabled: BOOT.readOnly || rc.busy, 'aria-pressed': String((to || sum) === v), onclick: () => pick(v) }, t('rc.state.' + v)))))
2927
+ const fromOf = (g) => g.state === 'on' ? 'true' : g.state === 'off' ? 'false' : t('rc.notSet')
2928
+ const rows = groups.map((g) => h('li', { class: 'rc-row' },
2929
+ h('div', null, h('code', null, g.file), ' ', h('span', { class: 'chip chip-sm ' + (g.state === 'on' ? 'chip-warn' : g.state === 'off' ? 'chip-ok' : g.state === 'blocked' ? 'chip-fail' : '') }, t('rc.state.' + g.state))),
2930
+ h('div', { class: 'muted small' }, t('rc.usedBy', { refs: g.accounts.map(shortRef).join(', ') })),
2931
+ g.reason ? h('div', { class: 'muted small' }, g.reason) : null,
2932
+ changing.has(g.file) ? h('div', { class: 'small rc-change' }, t('rc.change', { from: fromOf(g), to: to === 'on' ? 'true' : 'false' })) : null))
2933
+ const body = [h('p', { class: 'small' }, t('rc.explain')), sw, h('ul', { class: 'rc-list' }, rows)]
2934
+ if (to === 'on') body.push(h('div', { class: 'note note-warn small' }, t('rc.onWarn')))
2935
+ else if (to === 'off') body.push(h('p', { class: 'small muted' }, t('rc.confirmNote')))
2936
+ else if (sum === 'off') body.push(h('p', { class: 'small muted' }, t('rc.allOff')))
2937
+ if (BOOT.readOnly) body.push(h('p', { class: 'small' }, t('rc.readOnlyBoth')))
2938
+ const foot = [h('button', { class: 'btn', type: 'button', disabled: rc.busy, onclick: () => to ? pick(sum) : dlg.el.close() }, t(to ? 'btn.cancel' : 'btn.close'))]
2939
+ if (!BOOT.readOnly && !to && undo) foot.push(h('button', { class: 'btn', type: 'button', disabled: rc.busy, onclick: async () => { rc.busy = true; renderRc(); await remoteControlUndo(); rc.busy = false; if (dlg.kind === 'rc') renderRc() } }, t('rc.undo')))
2940
+ if (!BOOT.readOnly && to) foot.push(h('button', { class: 'btn btn-primary', type: 'button', disabled: rc.busy || !todo.length, 'aria-busy': rc.busy ? 'true' : null, onclick: () => setRemoteControl(to) }, rc.busy ? spinner() : null, t(to === 'on' ? 'rc.confirmOn' : 'rc.confirm')))
2941
+ setKids(dlg.el, dialogFrame(t('rc.dialogTitle'), null, body, foot))
2942
+ }
2584
2943
 
2585
2944
  // ---------- banners ----------
2586
2945
  function renderBanners() {
@@ -2607,7 +2966,7 @@ const MAIN = String.raw `
2607
2966
  }
2608
2967
 
2609
2968
  // ---------- loading ----------
2610
- function renderAll() { renderTop(); renderBanners(); renderSummary(); renderFamilyFilter(); renderEmailToggle(); renderAccounts(); renderCheckup(); renderPageChrome() }
2969
+ function renderAll() { renderTop(); renderBanners(); renderSummary(); renderFamilyFilter(); renderAccounts(); renderCheckup(); renderPageChrome() }
2611
2970
  // A load that starts while another runs is queued, never dropped, so a change made meanwhile (a new
2612
2971
  // account, a fix) always ends up on screen; and only the newest response is ever rendered. A silent load is a
2613
2972
  // background refresh: no spinner, and its render waits while the viewer is busy (see uiBusy).
@@ -2693,7 +3052,7 @@ const MAIN = String.raw `
2693
3052
  v.hidden = !BOOT.version
2694
3053
  if (!v.hidden) {
2695
3054
  const newer = !!(u && u.available)
2696
- v.textContent = 'v' + BOOT.version
3055
+ v.textContent = verLabel()
2697
3056
  v.classList.toggle('has-update', newer)
2698
3057
  v.disabled = !BOOT.updates
2699
3058
  const tip = !BOOT.updates ? '' : newer ? t('upd.labelLong', { v: u.latest, cur: u.current }) : t('upd.verTitle', { v: BOOT.version })
@@ -2708,6 +3067,7 @@ const MAIN = String.raw `
2708
3067
  b.title = t('upd.labelLong', { v: u.latest, cur: u.current })
2709
3068
  }
2710
3069
  if (dlg.kind === 'update' && up && !up.busy) renderUpdate()
3070
+ if (!vp.checking) renderVerPop()
2711
3071
  }
2712
3072
  function openUpdate() {
2713
3073
  if (!BOOT.updates) return
@@ -2738,6 +3098,63 @@ const MAIN = String.raw `
2738
3098
  dlg.busy = false
2739
3099
  renderUpdate()
2740
3100
  }
3101
+ // The version pill opens a small popover, not the dialog: the version, whether it is the newest, "Check for
3102
+ // updates" in place, and the way into the dialog when there is something to do (update, Auto Update).
3103
+ const vp = { checking: false, checked: false, error: null }
3104
+ function openVerPop(anchor) {
3105
+ if (!BOOT.updates) return
3106
+ vp.checked = false
3107
+ vp.error = null
3108
+ openPopover(anchor, 'version', t('upd.title'), verPopKids)
3109
+ }
3110
+ function renderVerPop() { refreshPopover('version') }
3111
+ // A Local Build (ADR-0024) names its commit, with + when the working tree had changes, so the viewer can tell
3112
+ // which code they are looking at.
3113
+ function verLabel() {
3114
+ const l = BOOT.local
3115
+ return 'v' + BOOT.version + (l ? '-dev' + (l.commit ? ' · ' + l.commit + (l.dirty ? '+' : '') : '') : '')
3116
+ }
3117
+ function verPopKids() {
3118
+ const u = updState()
3119
+ const toDialog = () => { closeMenu(); openUpdate() }
3120
+ const kids = [h('b', null, 'sideby ' + verLabel())]
3121
+ if (BOOT.local) kids.push(h('p', { class: 'muted small' }, t(BOOT.local.dirty ? 'upd.localDirty' : 'upd.local', { at: BOOT.local.builtAt ? clock(BOOT.local.builtAt) : '' })))
3122
+ let status
3123
+ if (!u) status = t('upd.loading')
3124
+ else if (u.available) status = null
3125
+ else if (vp.checked && u.latest) status = t('upd.latestNow')
3126
+ else if (u.checksOff) status = t('upd.checksOff', { why: u.checksOff })
3127
+ else if (u.latest) status = t('upd.latest') + (u.checkedAt ? ' · ' + t('upd.checked', { ago: ago(u.checkedAt) }) : '')
3128
+ else status = t('upd.notYet')
3129
+ if (status) kids.push(h('p', { class: 'muted small' }, status))
3130
+ if (u && u.available) {
3131
+ kids.push(h('p', { class: 'small' }, t('upd.available', { v: u.latest }), ' ',
3132
+ h('a', { href: u.releaseNotes, target: '_blank', rel: 'noopener noreferrer' }, t('upd.notes'))))
3133
+ kids.push(h('div', { class: 'popover-actions' }, h('button', { class: 'btn btn-sm btn-primary', type: 'button', onclick: toDialog }, t('upd.open'))))
3134
+ } else if (!BOOT.readOnly) {
3135
+ kids.push(h('div', { class: 'popover-actions' }, h('button', { class: 'btn btn-sm', type: 'button', disabled: vp.checking, 'aria-busy': vp.checking ? 'true' : null, onclick: verPopCheck },
3136
+ vp.checking ? spinner() : icon('refresh'), vp.checking ? t('upd.checking') : t('upd.check'))))
3137
+ }
3138
+ if (vp.error) kids.push(errorNote(vp.error, null))
3139
+ if (u && !BOOT.readOnly)
3140
+ kids.push(h('p', { class: 'muted small popover-foot' }, t(u.auto ? 'upd.autoIsOn' : 'upd.autoIsOff'), ' · ',
3141
+ h('button', { class: 'link-btn', type: 'button', onclick: toDialog }, t('upd.settings'))))
3142
+ return kids
3143
+ }
3144
+ async function verPopCheck() {
3145
+ vp.checking = true
3146
+ vp.error = null
3147
+ renderVerPop()
3148
+ try {
3149
+ await api('/api/update', {})
3150
+ await load({ silent: true })
3151
+ vp.checked = true
3152
+ } catch (e) { vp.error = e }
3153
+ vp.checking = false
3154
+ renderVerPop()
3155
+ const b = menu.el && menu.el.querySelector('.popover-actions button')
3156
+ if (b) b.focus()
3157
+ }
2741
3158
  // "Check for updates": asks the registry now (the daily check only runs once a day), then shows the answer.
2742
3159
  async function updCheck() {
2743
3160
  up.checking = true
@@ -2838,33 +3255,11 @@ const MAIN = String.raw `
2838
3255
  // ---------- start ----------
2839
3256
  $('ver').textContent = BOOT.version ? 'sideby v' + BOOT.version : 'sideby'
2840
3257
  $('ver').disabled = !BOOT.updates
2841
- $('ver').addEventListener('click', openUpdate)
3258
+ $('ver').addEventListener('click', () => openVerPop($('ver')))
2842
3259
  $('update').addEventListener('click', openUpdate)
2843
- $('ver-top').addEventListener('click', openUpdate)
3260
+ $('ver-top').addEventListener('click', () => openVerPop($('ver-top')))
2844
3261
  $('refresh').addEventListener('click', () => load({ check: true, announce: true }))
2845
- $('handoff').addEventListener('click', () => setPage('handoff'))
2846
- $('lang').addEventListener('click', () => {
2847
- if (LANG_FIXED) return
2848
- lang = lang === 'zh' ? 'en' : 'zh'
2849
- prefs.lang = lang
2850
- savePrefs()
2851
- renderStatic()
2852
- buildToolbar()
2853
- renderAll()
2854
- if (dlg.kind === 'create' && nf && !nf.creating) renderCreate()
2855
- else if (dlg.kind === 'setup' && qs) renderSetup()
2856
- else if (dlg.kind === 'tune' && tuneState) renderTune()
2857
- else if (dlg.kind === 'model' && md) { md.form = null; renderModel() }
2858
- else if (dlg.kind === 'update' && up) renderUpdate()
2859
- })
2860
- $('theme').addEventListener('click', () => {
2861
- const order = CHOICES.theme
2862
- prefs.theme = order[(order.indexOf(prefs.theme) + 1) % order.length]
2863
- savePrefs()
2864
- applyTheme()
2865
- renderStatic()
2866
- toast(t('theme.' + prefs.theme), { key: 'theme', kind: 'plain' })
2867
- })
3262
+ $('settings').addEventListener('click', () => openPopover($('settings'), 'settings', t('set.button'), settingsPop, 'end'))
2868
3263
  applyTheme()
2869
3264
  renderStatic()
2870
3265
  buildToolbar()