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