sideby 0.3.0 → 0.4.1
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 +27 -10
- package/dist/build.json +1 -1
- package/dist/src/cli/commands.d.ts +8 -1
- package/dist/src/cli/commands.js +332 -9
- package/dist/src/cli/format.d.ts +3 -1
- package/dist/src/cli/format.js +25 -0
- package/dist/src/cli/json-schemas.js +114 -0
- package/dist/src/cli/main.d.ts +5 -0
- package/dist/src/cli/main.js +40 -8
- package/dist/src/core/config.d.ts +40 -0
- package/dist/src/core/config.js +83 -11
- package/dist/src/core/file-memo.d.ts +23 -0
- package/dist/src/core/file-memo.js +37 -0
- package/dist/src/core/handoff-brief.d.ts +17 -0
- package/dist/src/core/handoff-brief.js +75 -0
- package/dist/src/core/handoff-chain.d.ts +74 -0
- package/dist/src/core/handoff-chain.js +74 -0
- package/dist/src/core/handoff-doctor.d.ts +13 -0
- package/dist/src/core/handoff-doctor.js +35 -0
- package/dist/src/core/handoff-run.d.ts +31 -0
- package/dist/src/core/handoff-run.js +346 -0
- package/dist/src/core/handoff-select.d.ts +42 -0
- package/dist/src/core/handoff-select.js +88 -0
- package/dist/src/core/handoff-status.d.ts +66 -0
- package/dist/src/core/handoff-status.js +198 -0
- package/dist/src/core/launch.d.ts +15 -0
- package/dist/src/core/launch.js +25 -2
- package/dist/src/core/memo-store.d.ts +20 -0
- package/dist/src/core/memo-store.js +92 -0
- package/dist/src/core/resume.d.ts +7 -2
- package/dist/src/core/resume.js +22 -9
- package/dist/src/core/tuning-apply.d.ts +63 -0
- package/dist/src/core/tuning-apply.js +264 -0
- package/dist/src/core/tuning.d.ts +113 -0
- package/dist/src/core/tuning.js +192 -0
- package/dist/src/families/claude/brief.d.ts +5 -0
- package/dist/src/families/claude/brief.js +63 -0
- package/dist/src/families/claude/handoff-hook.d.ts +2 -0
- package/dist/src/families/claude/handoff-hook.js +31 -0
- package/dist/src/families/claude/handoff.d.ts +5 -0
- package/dist/src/families/claude/handoff.js +115 -0
- package/dist/src/families/claude/index.js +8 -1
- package/dist/src/families/claude/tuning.d.ts +22 -0
- package/dist/src/families/claude/tuning.js +203 -0
- package/dist/src/families/claude/usage.d.ts +1 -0
- package/dist/src/families/claude/usage.js +2 -2
- package/dist/src/families/codex/brief.d.ts +5 -0
- package/dist/src/families/codex/brief.js +49 -0
- package/dist/src/families/codex/handoff-hook.d.ts +2 -0
- package/dist/src/families/codex/handoff-hook.js +16 -0
- package/dist/src/families/codex/handoff.d.ts +5 -0
- package/dist/src/families/codex/handoff.js +121 -0
- package/dist/src/families/codex/index.js +4 -0
- package/dist/src/families/codex/rate-limits.d.ts +16 -0
- package/dist/src/families/codex/rate-limits.js +92 -0
- package/dist/src/families/codex/rollout.d.ts +1 -2
- package/dist/src/families/codex/rollout.js +4 -48
- package/dist/src/families/codex/tuning.d.ts +16 -0
- package/dist/src/families/codex/tuning.js +145 -0
- package/dist/src/families/grok/brief.d.ts +5 -0
- package/dist/src/families/grok/brief.js +74 -0
- package/dist/src/families/grok/handoff-hook.d.ts +4 -0
- package/dist/src/families/grok/handoff-hook.js +26 -0
- package/dist/src/families/grok/handoff.d.ts +47 -0
- package/dist/src/families/grok/handoff.js +219 -0
- package/dist/src/families/grok/index.js +2 -0
- package/dist/src/families/pi/handoff.d.ts +2 -0
- package/dist/src/families/pi/handoff.js +13 -0
- package/dist/src/families/pi/index.js +2 -0
- package/dist/src/families/shared/brief.d.ts +24 -0
- package/dist/src/families/shared/brief.js +42 -0
- package/dist/src/families/shared/handoff-args.d.ts +18 -0
- package/dist/src/families/shared/handoff-args.js +57 -0
- package/dist/src/families/shared/sessions.d.ts +4 -0
- package/dist/src/families/shared/sessions.js +19 -0
- package/dist/src/families/shared/toml.d.ts +5 -0
- package/dist/src/families/shared/toml.js +30 -0
- package/dist/src/handoff/hook.d.ts +16 -0
- package/dist/src/handoff/hook.js +166 -0
- package/dist/src/handoff/messages.d.ts +7 -0
- package/dist/src/handoff/messages.js +27 -0
- package/dist/src/handoff/reader.d.ts +11 -0
- package/dist/src/handoff/reader.js +9 -0
- package/dist/src/panel/handler.d.ts +6 -0
- package/dist/src/panel/handler.js +110 -0
- package/dist/src/panel/i18n.d.ts +98 -0
- package/dist/src/panel/i18n.js +196 -0
- package/dist/src/panel/page-logic.d.ts +49 -0
- package/dist/src/panel/page-logic.js +67 -0
- package/dist/src/panel/page-script.js +461 -4
- package/dist/src/panel/page-styles.js +28 -2
- package/dist/src/panel/page.js +4 -1
- package/dist/src/quota/claude-statusline.js +4 -7
- package/dist/src/runtime.d.ts +40 -3
- package/dist/src/runtime.js +212 -41
- package/dist/src/types.d.ts +93 -0
- package/dist/testing/index.d.ts +5 -0
- package/dist/testing/index.js +1 -0
- package/package.json +16 -4
- package/schemas/config.json +73 -0
- package/schemas/families.json +5 -1
- package/schemas/handoff-disable.json +106 -0
- package/schemas/handoff-enable.json +106 -0
- package/schemas/handoff-ready.json +23 -0
- package/schemas/handoff-setup.json +60 -0
- package/schemas/handoff-status.json +137 -0
- package/schemas/handoff-teardown.json +30 -0
- package/schemas/list.json +5 -1
- package/schemas/tune-change.json +126 -0
- package/schemas/tune.json +243 -0
- package/skills/sideby/SKILL.md +5 -3
- package/skills/sideby/references/badge.md +1 -1
- package/skills/sideby/references/diagnose.md +4 -0
- package/skills/sideby/references/handoff.md +24 -2
- package/README.zh-CN.md +0 -112
|
@@ -1,12 +1,15 @@
|
|
|
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, cacheHitRate, formatDuration, maskEmail, matchesQuery, mergeDaily, newAccountDir, nextReset, panelHint, panelLang, passedWindow, quotaPressure, sortAccounts, suggestAlias, suggestName, updatedAge, windowState, } from './page-logic.js';
|
|
3
|
+
import { aliasArgsOf, aliasIssue, attentionReasons, attentionWhy, cacheHitRate, formatDuration, handoffChangeOf, handoffHintAccount, maskEmail, matchesQuery, mergeDaily, newAccountDir, nextReset, panelHint, panelLang, passedWindow, quotaPressure, sortAccounts, suggestAlias, suggestName, updatedAge, windowState, } from './page-logic.js';
|
|
4
4
|
const LOGIC = [
|
|
5
5
|
aliasArgsOf,
|
|
6
6
|
aliasIssue,
|
|
7
7
|
attentionReasons,
|
|
8
|
+
attentionWhy,
|
|
8
9
|
cacheHitRate,
|
|
9
10
|
formatDuration,
|
|
11
|
+
handoffChangeOf,
|
|
12
|
+
handoffHintAccount,
|
|
10
13
|
maskEmail,
|
|
11
14
|
matchesQuery,
|
|
12
15
|
mergeDaily,
|
|
@@ -65,6 +68,7 @@ const MAIN = String.raw `
|
|
|
65
68
|
const p = { view: 'list', sort: 'pressure', quota: 'used', theme: 'system', lang: '', family: '', collapsed: {}, hideEmail: false }
|
|
66
69
|
for (const k of Object.keys(CHOICES)) if (CHOICES[k].includes(raw[k])) p[k] = raw[k]
|
|
67
70
|
if (raw.hideEmail === true) p.hideEmail = true
|
|
71
|
+
if (typeof raw.ahHintAt === 'number') p.ahHintAt = raw.ahHintAt
|
|
68
72
|
if (typeof raw.family === 'string') p.family = raw.family
|
|
69
73
|
if (raw.collapsed && typeof raw.collapsed === 'object')
|
|
70
74
|
for (const k of Object.keys(raw.collapsed)) if (raw.collapsed[k] === true) p.collapsed[k] = true
|
|
@@ -571,6 +575,7 @@ const MAIN = String.raw `
|
|
|
571
575
|
a.dir ? { icon: 'folder', label: t('menu.copyDir'), hint: a.dir, run: () => copyText(a.dir) } : null,
|
|
572
576
|
a.identity && a.identity.email ? { icon: 'mail', label: t('menu.copyEmail'), hint: emailText(a.identity.email), run: () => copyText(a.identity.email) } : null,
|
|
573
577
|
'-',
|
|
578
|
+
family(a.family).tuning ? { icon: 'check', label: t('menu.tune'), run: () => openTune(a) } : null,
|
|
574
579
|
{ icon: 'check', label: t('menu.check'), disabled: S.checking || S.fixing || Boolean(ui.checkingRef), run: () => checkAccount(a.ref) },
|
|
575
580
|
BOOT.readOnly ? null : { icon: 'userPlus', label: t('menu.like'), run: () => openCreate({ family: a.family, api: a.kind === 'api' }) },
|
|
576
581
|
]
|
|
@@ -605,6 +610,8 @@ const MAIN = String.raw `
|
|
|
605
610
|
return { fail, warn }
|
|
606
611
|
}
|
|
607
612
|
function reasonsOf(a, now) { return attentionReasons(a, healthOf(a.ref), now, WARN) }
|
|
613
|
+
// One line on why an Account needs a look, such as "7d at 62% (warns from 60%)".
|
|
614
|
+
function whyOf(a, now) { const m = attentionWhy(a, healthOf(a.ref), now, WARN); return m ? t(m.key, m.vars) : '' }
|
|
608
615
|
function setupFor(fam) { return S.state && S.state.quotaSetups.find((s) => s.family === fam) }
|
|
609
616
|
|
|
610
617
|
// ---------- header and summary ----------
|
|
@@ -617,6 +624,7 @@ const MAIN = String.raw `
|
|
|
617
624
|
}
|
|
618
625
|
function renderTop() {
|
|
619
626
|
renderUpdated()
|
|
627
|
+
renderHandoffButton()
|
|
620
628
|
// A background refresh keeps the button still; only one the viewer asked for spins it.
|
|
621
629
|
const busy = S.loading && !S.silent
|
|
622
630
|
const refresh = $('refresh')
|
|
@@ -901,6 +909,9 @@ const MAIN = String.raw `
|
|
|
901
909
|
const out = []
|
|
902
910
|
if (a.error) out.push(h('div', { class: 'note note-fail' }, h('b', null, t('acct.readError')), ' ', h('span', null, a.error)))
|
|
903
911
|
if (a.hostInstalled === false) out.push(h('div', { class: 'note note-warn' }, tx('acct.installHost', { host: fam.title, link: link(t('acct.installIt'), fam.installUrl) || '' })))
|
|
912
|
+
// The other reasons already show in the row (read error, missing tool, sign-in, Health); a full window does not.
|
|
913
|
+
const why = attentionWhy(a, healthOf(a.ref), Date.now(), WARN)
|
|
914
|
+
if (why && why.key === 'why.quota-high') out.push(h('div', { class: 'note note-warn' }, t('acct.nearLimit', why.vars)))
|
|
904
915
|
return out.length ? h('div', { class: 'row-notes' }, out) : null
|
|
905
916
|
}
|
|
906
917
|
// What a row or card shows depends on more than the account (health, view preferences, language); the
|
|
@@ -950,11 +961,23 @@ const MAIN = String.raw `
|
|
|
950
961
|
const any = all.some((a) => a.quota && a.quota.status === 'ok')
|
|
951
962
|
return waiting && !any
|
|
952
963
|
}
|
|
964
|
+
// The group's attention tag says who and why: with one Account its name and reason, and a click shows its row;
|
|
965
|
+
// with more, the count, each one's reason on hover, and a click shows only them (as the summary card does).
|
|
966
|
+
function attentionTag(needing, now) {
|
|
967
|
+
if (needing.length === 1) {
|
|
968
|
+
const a = needing[0]
|
|
969
|
+
const label = a.name + ' · ' + whyOf(a, now)
|
|
970
|
+
return h('button', { class: 'tag tag-warn tag-btn', type: 'button', title: t('group.attentionShow', { name: a.name }), onclick: () => flashRow(a.ref) }, label)
|
|
971
|
+
}
|
|
972
|
+
const list = needing.map((a) => a.name + ': ' + whyOf(a, now)).join('\n')
|
|
973
|
+
return h('button', { class: 'tag tag-warn tag-btn', type: 'button', title: list, 'aria-pressed': String(ui.attention), onclick: () => { ui.attention = !ui.attention; renderSummary(); renderAccounts() } }, tn('group.attention', needing.length))
|
|
974
|
+
}
|
|
953
975
|
function group(fam, list, total, now, cols) {
|
|
954
976
|
const info = family(fam)
|
|
955
977
|
const filtering = Boolean(ui.query.trim()) || ui.attention
|
|
956
978
|
const collapsed = !filtering && prefs.collapsed[fam] === true
|
|
957
|
-
const
|
|
979
|
+
const needing = list.filter((a) => reasonsOf(a, now).length)
|
|
980
|
+
const need = needing.length
|
|
958
981
|
const setup = setupFor(fam)
|
|
959
982
|
const off = list.some((a) => a.quota && a.quota.status === 'unavailable' && a.quota.reason === 'not-enabled')
|
|
960
983
|
const bodyId = 'grp-' + fam.replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
@@ -969,7 +992,7 @@ const MAIN = String.raw `
|
|
|
969
992
|
h('span', { class: 'group-count' }, list.length === total ? String(total) : list.length + ' / ' + total)),
|
|
970
993
|
h('div', { class: 'group-meta' },
|
|
971
994
|
waitingForQuota(fam) ? h('span', { class: 'group-wait', role: 'status' }, h('span', { class: 'pulse', 'aria-hidden': 'true' }), t('quota.waiting', { host: info.title })) : null,
|
|
972
|
-
need ?
|
|
995
|
+
need ? attentionTag(needing, now) : null,
|
|
973
996
|
off && setup && setup.plan.status === 'ready' && !BOOT.readOnly
|
|
974
997
|
? h('span', { class: 'group-quota' },
|
|
975
998
|
h('span', { class: 'muted small' }, t('quota.turnOnFamilyNote', { host: info.title })),
|
|
@@ -1217,6 +1240,254 @@ const MAIN = String.raw `
|
|
|
1217
1240
|
if (!el.open) { el.showModal(); raiseToasts() }
|
|
1218
1241
|
}
|
|
1219
1242
|
|
|
1243
|
+
// Context tuning (spec §3.18, ADR-0012). The setting lives in one settings file, which Accounts often share, so
|
|
1244
|
+
// there is one card per file: what is suggested, one button that does it (after saying what it changes), one size
|
|
1245
|
+
// menu, and the way back. Each account's own numbers sit behind a click.
|
|
1246
|
+
const tuneState = { ref: null, family: null, loading: false, data: null, error: null }
|
|
1247
|
+
// The one change being made right now: step is asking (fetching the plan), confirm, busy, done or undone.
|
|
1248
|
+
let useUi = null
|
|
1249
|
+
// What each card's size menu shows while the user is choosing, by group id; dropped once a change is made.
|
|
1250
|
+
const tuneSel = {}
|
|
1251
|
+
const tp = { data: null, loading: false, error: null, at: 0 }
|
|
1252
|
+
let curPage = location.hash === '#tuning' ? 'tuning' : 'accounts'
|
|
1253
|
+
function tuneSize(n) { return n === null || n === undefined ? t('tune.hostDefault') : tokens(n) }
|
|
1254
|
+
function tuneRedraw() {
|
|
1255
|
+
if (dlg.kind === 'tune') renderTune()
|
|
1256
|
+
if (curPage === 'tuning') renderTuningPage()
|
|
1257
|
+
}
|
|
1258
|
+
async function tuneReload() {
|
|
1259
|
+
if (curPage === 'tuning') await loadTuningPage()
|
|
1260
|
+
if (dlg.kind === 'tune' && tuneState.ref) await loadTuneDialog()
|
|
1261
|
+
}
|
|
1262
|
+
async function useAsk(g, value) {
|
|
1263
|
+
useUi = { gid: g.id, target: g.target, value, step: 'asking', plan: null, error: null, message: '' }
|
|
1264
|
+
tuneRedraw()
|
|
1265
|
+
try {
|
|
1266
|
+
const r = await api('/api/tuning/apply', { account: g.target, value })
|
|
1267
|
+
useUi = { ...useUi, step: 'confirm', plan: r.plan }
|
|
1268
|
+
} catch (e) { useUi = { ...useUi, step: 'confirm', error: e } }
|
|
1269
|
+
tuneRedraw()
|
|
1270
|
+
}
|
|
1271
|
+
async function useConfirm() {
|
|
1272
|
+
const cur = useUi
|
|
1273
|
+
useUi = { ...cur, step: 'busy' }
|
|
1274
|
+
tuneRedraw()
|
|
1275
|
+
try {
|
|
1276
|
+
const r = await api('/api/tuning/apply', { account: cur.target, value: cur.value, confirm: true })
|
|
1277
|
+
useUi = { ...cur, step: 'done', plan: r.plan, error: null, message: r.applied ? '' : r.message, ok: r.applied }
|
|
1278
|
+
delete tuneSel[cur.gid]
|
|
1279
|
+
} catch (e) { useUi = { ...cur, step: 'confirm', error: e } }
|
|
1280
|
+
tuneRedraw()
|
|
1281
|
+
await tuneReload()
|
|
1282
|
+
}
|
|
1283
|
+
async function useUndo(g) {
|
|
1284
|
+
useUi = { gid: g.id, target: g.target, step: 'busy', plan: null, error: null, message: '' }
|
|
1285
|
+
tuneRedraw()
|
|
1286
|
+
try {
|
|
1287
|
+
const r = await api('/api/tuning/undo', { account: g.target })
|
|
1288
|
+
useUi = { ...useUi, step: 'undone', message: r.undone ? '' : r.message, ok: r.undone }
|
|
1289
|
+
delete tuneSel[g.id]
|
|
1290
|
+
} catch (e) { useUi = { ...useUi, step: 'undone', error: e, ok: false } }
|
|
1291
|
+
tuneRedraw()
|
|
1292
|
+
await tuneReload()
|
|
1293
|
+
}
|
|
1294
|
+
// What the change will do, in words a person can check before saying yes. ending picks the result of a finished
|
|
1295
|
+
// change; the rest (asking, the question, an error) is the part before it.
|
|
1296
|
+
function useBox(g, ending) {
|
|
1297
|
+
const u = useUi
|
|
1298
|
+
if (!u || u.gid !== g.id) return null
|
|
1299
|
+
const finished = u.step === 'done' || u.step === 'undone'
|
|
1300
|
+
if (finished !== Boolean(ending)) return null
|
|
1301
|
+
if (u.step === 'asking' || u.step === 'busy') return h('p', { class: 'muted small' }, spinner(), ' ', t('tune.working'))
|
|
1302
|
+
if (finished) {
|
|
1303
|
+
if (u.error) return errorNote(u.error, null)
|
|
1304
|
+
if (u.ok === false) return h('p', { class: 'note note-warn' }, u.message)
|
|
1305
|
+
return h('p', { class: 'note' }, u.step === 'done' ? t('tune.done', { size: tuneSize(u.value) }) : t('tune.undone'), ' ',
|
|
1306
|
+
u.step === 'done' && !BOOT.readOnly ? h('button', { class: 'btn btn-sm', type: 'button', onclick: () => useUndo(g) }, t('tune.undo')) : null)
|
|
1307
|
+
}
|
|
1308
|
+
if (u.error) return errorNote(u.error, null)
|
|
1309
|
+
const plan = u.plan
|
|
1310
|
+
if (plan.status === 'blocked') return h('p', { class: 'note note-warn' }, plan.reason)
|
|
1311
|
+
if (plan.status === 'unchanged') return h('p', { class: 'note' }, t('tune.already', { size: tuneSize(u.value) }))
|
|
1312
|
+
return h('div', { class: 'tune-confirm' },
|
|
1313
|
+
h('p', { class: 'small' }, h('b', null, t('tune.confirmTitle'))),
|
|
1314
|
+
h('ul', { class: 'small' },
|
|
1315
|
+
h('li', null, t('tune.change') + ': ' + tuneSize(plan.current) + ' → ' + tuneSize(plan.next)),
|
|
1316
|
+
h('li', null, t('tune.file') + ': ', h('code', null, plan.file + (plan.creates ? ' ' + t('tune.willCreate') : '')))),
|
|
1317
|
+
plan.affected.length > 1 ? h('p', { class: 'note note-warn' }, t('tune.shared', { list: plan.affected.join(', ') })) : null,
|
|
1318
|
+
plan.overrides && plan.overrides.length ? h('p', { class: 'note note-warn' }, t('tune.overrides', { list: plan.overrides.map((o) => o.model + ' (' + tokens(o.value) + ')').join(', ') })) : null,
|
|
1319
|
+
h('p', { class: 'muted small' }, t('tune.newSessions')),
|
|
1320
|
+
h('div', { class: 'row-actions' },
|
|
1321
|
+
h('button', { class: 'btn btn-primary btn-sm', type: 'button', onclick: useConfirm }, t('tune.confirm')),
|
|
1322
|
+
h('button', { class: 'btn btn-sm', type: 'button', onclick: () => { useUi = null; tuneRedraw() } }, t('btn.cancel'))))
|
|
1323
|
+
}
|
|
1324
|
+
// The card for one settings file.
|
|
1325
|
+
function tuneGroupCard(g, entries) {
|
|
1326
|
+
const pick = g.pick
|
|
1327
|
+
const sizes = new Set([100000, 150000, 200000, 300000, 500000, 800000, 1000000])
|
|
1328
|
+
let top = 200000
|
|
1329
|
+
for (const e of entries) if (e.tuning.status === 'ok') for (const m of e.tuning.models) top = Math.max(top, m.contextWindow)
|
|
1330
|
+
for (const v of [...sizes]) if (v > top) sizes.delete(v)
|
|
1331
|
+
if (pick) sizes.add(pick.threshold)
|
|
1332
|
+
if (g.current && !g.outOfRange) sizes.add(g.current)
|
|
1333
|
+
const key = (v) => (v === null ? 'default' : String(v))
|
|
1334
|
+
const chosen = tuneSel[g.id] !== undefined ? tuneSel[g.id] : key(g.current)
|
|
1335
|
+
const sel = h('select', { 'aria-label': t('tune.setting') },
|
|
1336
|
+
h('option', { value: 'default' }, t('tune.hostDefaultLong')),
|
|
1337
|
+
[...sizes].sort((x, y) => x - y).map((v) => h('option', { value: String(v) }, tokens(v) + (pick && v === pick.threshold ? ' (' + t('tune.suggestedTag') + ')' : ''))))
|
|
1338
|
+
sel.value = chosen
|
|
1339
|
+
sel.addEventListener('change', () => { tuneSel[g.id] = sel.value; tuneRedraw() })
|
|
1340
|
+
const value = chosen === 'default' ? null : Number(chosen)
|
|
1341
|
+
const mine = useUi && useUi.gid === g.id
|
|
1342
|
+
const busy = mine && (useUi.step === 'asking' || useUi.step === 'busy')
|
|
1343
|
+
const asking = mine && (useUi.step === 'confirm' || busy)
|
|
1344
|
+
const rows = [h('p', { class: 'small' }, g.members.length > 1 ? t('tune.groupShared', { list: g.members.join(', ') }) : h('b', { class: 'mono' }, g.members[0]))]
|
|
1345
|
+
rows.push(h('div', { class: 'tune-vals' },
|
|
1346
|
+
h('div', null, h('span', { class: 'muted small' }, t('tune.now')), h('div', { class: 'tune-num' }, tuneSize(g.current))),
|
|
1347
|
+
pick ? h('div', null, h('span', { class: 'muted small' }, t('tune.best')), h('div', { class: 'tune-num tune-best' }, tokens(pick.threshold))) : null))
|
|
1348
|
+
if (pick && pick.savedPercent !== null) rows.push(h('p', { class: 'small' }, t('tune.saved', { n: Math.round(pick.savedPercent) })))
|
|
1349
|
+
if (pick) rows.push(h('p', { class: 'muted small' }, t('tune.basis', { model: pick.model, ref: pick.ref })))
|
|
1350
|
+
else {
|
|
1351
|
+
// Say why there is no suggestion: when a model is already at its cheapest, that is the news, not "not enough use".
|
|
1352
|
+
const why = entries.flatMap((e) => (e.tuning.status === 'ok' ? e.tuning.models : [])).some((m) => m.reason === 'already-best') ? t('tune.reason.already-best') : t('tune.groupNone')
|
|
1353
|
+
rows.push(h('p', { class: 'note' }, why))
|
|
1354
|
+
}
|
|
1355
|
+
if (g.overrides && g.overrides.length) rows.push(h('p', { class: 'note note-warn' }, t('tune.overrides', { list: g.overrides.map((o) => o.model + ' (' + tokens(o.value) + ')').join(', ') })))
|
|
1356
|
+
if (g.outOfRange) rows.push(h('p', { class: 'note note-warn' }, t('tune.outOfRange', { size: tokens(g.current) })))
|
|
1357
|
+
if (!BOOT.readOnly) {
|
|
1358
|
+
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) }))))
|
|
1359
|
+
rows.push(h('div', { class: 'tune-setting' },
|
|
1360
|
+
h('b', { class: 'small' }, t('tune.setting')),
|
|
1361
|
+
h('div', { class: 'row-actions' }, sel,
|
|
1362
|
+
h('button', { class: 'btn btn-sm', type: 'button', disabled: Boolean(busy) || value === g.current, onclick: () => useAsk(g, value) }, t('tune.useThis')))))
|
|
1363
|
+
rows.push(useBox(g, false))
|
|
1364
|
+
if (g.undo && !(mine && (useUi.step === 'done' || useUi.step === 'undone')))
|
|
1365
|
+
rows.push(h('p', { class: 'small tune-undo' }, t('tune.applied', { prev: tuneSize(g.undo.previous) }), ' ',
|
|
1366
|
+
h('button', { class: 'btn btn-sm', type: 'button', onclick: () => useUndo(g) }, t('tune.undo'))))
|
|
1367
|
+
rows.push(useBox(g, true))
|
|
1368
|
+
}
|
|
1369
|
+
return h('div', { class: 'tune-model tune-group' }, rows)
|
|
1370
|
+
}
|
|
1371
|
+
// What was seen for one model: information only, behind a click.
|
|
1372
|
+
function tuneModel(m, minCalls) {
|
|
1373
|
+
const rows = [h('div', { class: 'tune-head' }, h('b', { class: 'mono' }, m.model), h('span', { class: 'muted small' }, t('tune.calls', { n: m.calls })))]
|
|
1374
|
+
rows.push(h('div', { class: 'tune-vals' },
|
|
1375
|
+
h('div', null, h('span', { class: 'muted small' }, t('tune.now')), h('div', { class: 'small' }, tuneSize(m.configured))),
|
|
1376
|
+
m.suggestion ? h('div', null, h('span', { class: 'muted small' }, t('tune.best')), h('div', { class: 'small' }, tokens(m.suggestion.threshold))) : null))
|
|
1377
|
+
if (!m.suggestion) rows.push(h('p', { class: 'muted small' }, t('tune.reason.' + m.reason, { min: minCalls })))
|
|
1378
|
+
rows.push(h('p', { class: 'muted small' }, m.compactions.total ? tn('tune.compactions', m.compactions.total, { size: tokens(m.compactions.preTokensP50 || 0) }) : t('tune.noCompactions')))
|
|
1379
|
+
return h('div', { class: 'tune-detail' }, rows)
|
|
1380
|
+
}
|
|
1381
|
+
function tuneDetails(entries, minCalls, label) {
|
|
1382
|
+
const kids = []
|
|
1383
|
+
for (const e of entries) {
|
|
1384
|
+
if (e.tuning.status !== 'ok' || !e.tuning.models.length) continue
|
|
1385
|
+
kids.push(h('div', { class: 'tune-acct-head title-logo' }, badge(e.family, 'sm'), h('b', { class: 'mono' }, e.ref)))
|
|
1386
|
+
for (const m of e.tuning.models) kids.push(tuneModel(m, minCalls))
|
|
1387
|
+
}
|
|
1388
|
+
return kids.length ? h('details', { class: 'tune-details' }, h('summary', null, label), kids) : null
|
|
1389
|
+
}
|
|
1390
|
+
// Everything for a response: a card per settings file, then what Codex accounts show (it only observes).
|
|
1391
|
+
function tuneBody(data) {
|
|
1392
|
+
const out = []
|
|
1393
|
+
const byRef = {}
|
|
1394
|
+
for (const e of data.accounts) byRef[e.ref] = e
|
|
1395
|
+
for (const g of data.groups) {
|
|
1396
|
+
const entries = g.members.map((r) => byRef[r]).filter(Boolean)
|
|
1397
|
+
out.push(h('div', { class: 'tune-acct' }, tuneGroupCard(g, entries), tuneDetails(entries, data.minCalls, t('tune.details'))))
|
|
1398
|
+
}
|
|
1399
|
+
const rest = data.accounts.filter((e) => !data.groups.some((g) => g.members.includes(e.ref)))
|
|
1400
|
+
const seen = rest.filter((e) => e.tuning.status === 'ok' && e.tuning.models.length)
|
|
1401
|
+
if (seen.length) out.push(h('div', { class: 'tune-acct' }, tuneDetails(seen, data.minCalls, t('tune.detailsObserved'))))
|
|
1402
|
+
return out
|
|
1403
|
+
}
|
|
1404
|
+
async function openTune(a) {
|
|
1405
|
+
tuneState.ref = a.ref; tuneState.family = a.family; tuneState.data = null; tuneState.error = null
|
|
1406
|
+
// A finished change is not shown again when the dialog opens next time.
|
|
1407
|
+
if (useUi && (useUi.step === 'done' || useUi.step === 'undone')) useUi = null
|
|
1408
|
+
showDialog('tune')
|
|
1409
|
+
await loadTuneDialog()
|
|
1410
|
+
}
|
|
1411
|
+
async function loadTuneDialog() {
|
|
1412
|
+
const ref = tuneState.ref
|
|
1413
|
+
tuneState.loading = true
|
|
1414
|
+
renderTune()
|
|
1415
|
+
try {
|
|
1416
|
+
const r = await api('/api/tuning', { account: ref })
|
|
1417
|
+
if (tuneState.ref === ref) { tuneState.data = r; tuneState.error = null }
|
|
1418
|
+
} catch (e) { if (tuneState.ref === ref) tuneState.error = e }
|
|
1419
|
+
tuneState.loading = false
|
|
1420
|
+
if (dlg.kind === 'tune' && tuneState.ref === ref) renderTune()
|
|
1421
|
+
}
|
|
1422
|
+
function renderTune() {
|
|
1423
|
+
if (!tuneState.ref) return
|
|
1424
|
+
const body = [h('p', { class: 'muted small' }, t('tune.intro', { days: BOOT.usageDays }))]
|
|
1425
|
+
const d = tuneState.data
|
|
1426
|
+
const one = d && d.accounts[0]
|
|
1427
|
+
if (tuneState.loading && !d) body.push(h('p', { class: 'muted' }, spinner(), ' ', t('tune.loading')))
|
|
1428
|
+
else if (tuneState.error) body.push(errorNote(tuneState.error, loadTuneDialog))
|
|
1429
|
+
else if (d && d.groups.length) body.push(tuneBody(d))
|
|
1430
|
+
else if (one && one.tuning.status === 'ok') body.push(tuneBody(d))
|
|
1431
|
+
else if (one) body.push(h('p', { class: 'note', title: one.tuning.detail || null }, t(one.tuning.reason === 'no-session' ? 'tune.none' : one.tuning.reason === 'no-source' ? 'tune.noSource' : 'tune.unreadable')))
|
|
1432
|
+
const foot = [h('button', { class: 'btn', type: 'button', onclick: () => dlg.el.close() }, t('btn.close'))]
|
|
1433
|
+
setKids(dlg.el, dialogFrame(t('tune.title') + ' · ' + tuneState.ref, tuneState.family, body, foot))
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
// The page: every account that can tune, on a page of its own. The address ending in #tuning opens it.
|
|
1437
|
+
function tuningAvailable() { return Boolean(S.state && S.state.families.some((f) => f.tuning)) }
|
|
1438
|
+
function setPage(next) {
|
|
1439
|
+
curPage = next
|
|
1440
|
+
try { history.replaceState(null, '', next === 'tuning' ? '#tuning' : location.pathname + location.search) } catch {}
|
|
1441
|
+
renderPageChrome()
|
|
1442
|
+
}
|
|
1443
|
+
// The page tabs, and which sections show; runs with every render, so a reload or a language switch keeps them.
|
|
1444
|
+
function renderPageChrome() {
|
|
1445
|
+
const nav = $('pages')
|
|
1446
|
+
const can = tuningAvailable()
|
|
1447
|
+
if (!can && curPage === 'tuning' && S.state) curPage = 'accounts'
|
|
1448
|
+
nav.hidden = !can
|
|
1449
|
+
const tabs = [['accounts', t('page.accounts')], ['tuning', t('page.tuning')]]
|
|
1450
|
+
setKids(nav, h('div', { class: 'seg', role: 'presentation' }, tabs.map(([id, label]) =>
|
|
1451
|
+
h('button', { class: 'seg-btn', type: 'button', role: 'tab', 'aria-selected': curPage === id ? 'true' : 'false', onclick: () => setPage(id) }, label))))
|
|
1452
|
+
const on = can && curPage === 'tuning'
|
|
1453
|
+
for (const id of ['summary', 'accounts-page', 'checkup']) $(id).hidden = on
|
|
1454
|
+
$('tuning-page').hidden = !on
|
|
1455
|
+
if (on) {
|
|
1456
|
+
if (!tp.data && !tp.loading && !tp.error) loadTuningPage()
|
|
1457
|
+
else renderTuningPage()
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
async function loadTuningPage() {
|
|
1461
|
+
tp.loading = true
|
|
1462
|
+
tp.error = null
|
|
1463
|
+
renderTuningPage()
|
|
1464
|
+
try { tp.data = await api('/api/tuning', {}); tp.at = Date.now() } catch (e) { tp.error = e }
|
|
1465
|
+
tp.loading = false
|
|
1466
|
+
renderTuningPage()
|
|
1467
|
+
}
|
|
1468
|
+
function renderTuningPage() {
|
|
1469
|
+
const box = $('tuning-page')
|
|
1470
|
+
if (box.hidden) return
|
|
1471
|
+
const kids = [h('div', { class: 'section-head' },
|
|
1472
|
+
h('h2', { id: 'h-tuning' }, t('tpage.title')),
|
|
1473
|
+
h('button', { class: 'btn btn-sm', type: 'button', disabled: tp.loading, onclick: loadTuningPage }, tp.loading ? spinner() : icon('refresh'), t('top.refresh')))]
|
|
1474
|
+
kids.push(h('p', { class: 'muted small' }, t('tune.intro', { days: BOOT.usageDays })))
|
|
1475
|
+
const d = tp.data
|
|
1476
|
+
if (tp.loading && !d) kids.push(h('p', { class: 'muted' }, spinner(), ' ', t('tune.loading')))
|
|
1477
|
+
if (tp.error) kids.push(errorNote(tp.error, loadTuningPage))
|
|
1478
|
+
if (d) {
|
|
1479
|
+
const seen = d.accounts.filter((a) => a.tuning.status === 'ok' && a.tuning.models.length)
|
|
1480
|
+
const idle = d.accounts.filter((a) => !seen.includes(a) && a.tuning.status === 'unavailable' && a.tuning.reason !== 'unrecognized').map((a) => a.ref)
|
|
1481
|
+
const broken = d.accounts.filter((a) => a.tuning.status === 'unavailable' && a.tuning.reason === 'unrecognized')
|
|
1482
|
+
if (!seen.length && !d.groups.length) kids.push(h('p', { class: 'small' }, t('tpage.none')))
|
|
1483
|
+
kids.push(tuneBody(d))
|
|
1484
|
+
for (const a of broken) kids.push(h('p', { class: 'note note-fail' }, h('b', { class: 'mono' }, a.ref), ' ', a.tuning.detail || t('tune.unreadable')))
|
|
1485
|
+
if (idle.length) kids.push(h('p', { class: 'muted small' }, t('tpage.idle', { list: idle.join(', ') })))
|
|
1486
|
+
}
|
|
1487
|
+
setKids(box, kids)
|
|
1488
|
+
}
|
|
1489
|
+
window.addEventListener('hashchange', () => { curPage = location.hash === '#tuning' ? 'tuning' : 'accounts'; renderPageChrome() })
|
|
1490
|
+
|
|
1220
1491
|
// New account. The form is built once per opening so typing keeps focus; only its derived parts re-render.
|
|
1221
1492
|
let nf = null
|
|
1222
1493
|
function familyStatus(f) {
|
|
@@ -1510,6 +1781,184 @@ const MAIN = String.raw `
|
|
|
1510
1781
|
load()
|
|
1511
1782
|
}
|
|
1512
1783
|
|
|
1784
|
+
// ---------- auto handoff ----------
|
|
1785
|
+
// Settings come with /api/state; readiness (which runs a check of the sideby on PATH) only when the dialog opens.
|
|
1786
|
+
let ah = null
|
|
1787
|
+
const HINT_QUIET_MS = 24 * 3600000
|
|
1788
|
+
function ahState() { return S.state && S.state.autoHandoff }
|
|
1789
|
+
function renderHandoffButton() {
|
|
1790
|
+
const b = $('handoff')
|
|
1791
|
+
const st = ahState()
|
|
1792
|
+
b.hidden = !st
|
|
1793
|
+
if (!st) return
|
|
1794
|
+
const on = st.settings.auto
|
|
1795
|
+
setKids(b, [h('span', { class: 'hide-sm' }, t('ah.button')), h('span', { class: 'chip chip-sm' + (on ? ' chip-ok' : '') }, on ? t('ah.on') : t('ah.off'))])
|
|
1796
|
+
b.setAttribute('aria-label', t('ah.button') + ': ' + (on ? t('ah.on') : t('ah.off')))
|
|
1797
|
+
}
|
|
1798
|
+
function handoffHint() {
|
|
1799
|
+
const st = ahState()
|
|
1800
|
+
if (!st || BOOT.readOnly) return null
|
|
1801
|
+
if (prefs.ahHintAt && Date.now() - prefs.ahHintAt < HINT_QUIET_MS) return null
|
|
1802
|
+
return handoffHintAccount(S.state.accounts, st.settings, st.starters, Date.now())
|
|
1803
|
+
}
|
|
1804
|
+
function handoffForm() {
|
|
1805
|
+
const st = ahState()
|
|
1806
|
+
const s = st.settings
|
|
1807
|
+
const orders = {}
|
|
1808
|
+
for (const f of st.starters) {
|
|
1809
|
+
const cur = s.families[f]
|
|
1810
|
+
orders[f] = { policy: cur && cur.policy === 'order' ? 'order' : 'pressure', order: cur ? cur.order.slice() : [] }
|
|
1811
|
+
}
|
|
1812
|
+
return { auto: s.auto, sameFamily: s.sameFamily, prepareAt: s.prepareAt, threshold: s.threshold, wait: s.waitIfResetWithinMinutes, countdown: s.countdownSeconds, orders }
|
|
1813
|
+
}
|
|
1814
|
+
async function openHandoff() {
|
|
1815
|
+
if (!ahState()) return
|
|
1816
|
+
ah = { form: handoffForm(), loading: true }
|
|
1817
|
+
showDialog('handoff')
|
|
1818
|
+
renderHandoff()
|
|
1819
|
+
await loadHandoffStatus()
|
|
1820
|
+
}
|
|
1821
|
+
async function loadHandoffStatus() {
|
|
1822
|
+
const mine = ah
|
|
1823
|
+
try {
|
|
1824
|
+
const st = await api('/api/handoff/status', {})
|
|
1825
|
+
if (ah === mine) { ah.status = st; ah.loading = false; ah.error = null }
|
|
1826
|
+
} catch (e) { if (ah === mine) { ah.error = e; ah.loading = false } }
|
|
1827
|
+
if (dlg.kind === 'handoff') renderHandoff()
|
|
1828
|
+
}
|
|
1829
|
+
function ahEdit(fn) {
|
|
1830
|
+
fn(ah.form)
|
|
1831
|
+
ah.preview = null
|
|
1832
|
+
ah.nothing = false
|
|
1833
|
+
ah.saved = null
|
|
1834
|
+
renderHandoff()
|
|
1835
|
+
}
|
|
1836
|
+
function ahNumber(key, label, min, max) {
|
|
1837
|
+
const id = 'ah-' + key
|
|
1838
|
+
const input = h('input', { id, type: 'number', min: String(min), max: String(max), step: '1', value: String(ah.form[key]), disabled: BOOT.readOnly })
|
|
1839
|
+
input.addEventListener('change', () => {
|
|
1840
|
+
const n = Math.round(Number(input.value))
|
|
1841
|
+
if (Number.isFinite(n)) ahEdit((f) => { f[key] = Math.min(max, Math.max(min, n)) })
|
|
1842
|
+
})
|
|
1843
|
+
return h('label', { class: 'field', for: id }, h('span', null, label), input)
|
|
1844
|
+
}
|
|
1845
|
+
function ahCheck(key, label, hintText) {
|
|
1846
|
+
const input = h('input', { type: 'checkbox', disabled: BOOT.readOnly })
|
|
1847
|
+
input.checked = Boolean(ah.form[key])
|
|
1848
|
+
input.addEventListener('change', () => ahEdit((f) => { f[key] = input.checked }))
|
|
1849
|
+
return h('div', { class: 'check-row' }, h('label', { class: 'check' }, input, h('span', null, h('b', null, label), hintText ? h('span', { class: 'muted small' }, hintText) : null)))
|
|
1850
|
+
}
|
|
1851
|
+
function accountLabel(ref) {
|
|
1852
|
+
const a = S.state.accounts.find((x) => x.ref === ref || (x.aliases || []).includes(ref))
|
|
1853
|
+
const alias = a && a.aliases && a.aliases[0]
|
|
1854
|
+
return a ? (alias && alias !== ref ? ref + ' (' + alias + ')' : a.ref) : ref
|
|
1855
|
+
}
|
|
1856
|
+
function ahFamily(fam) {
|
|
1857
|
+
const o = ah.form.orders[fam]
|
|
1858
|
+
const f = family(fam)
|
|
1859
|
+
const policy = h('select', { 'aria-label': t('ah.order', { host: f.title }), disabled: BOOT.readOnly },
|
|
1860
|
+
h('option', { value: 'pressure' }, t('ah.byQuota')), h('option', { value: 'order' }, t('ah.myOrder')))
|
|
1861
|
+
policy.value = o.policy
|
|
1862
|
+
policy.addEventListener('change', () => ahEdit((form) => { form.orders[fam].policy = policy.value }))
|
|
1863
|
+
const kids = [h('div', { class: 'ah-fam-head' }, h('b', { class: 'title-logo' }, badge(fam, 'sm'), t('ah.order', { host: f.title })), h('span', null, policy))]
|
|
1864
|
+
if (o.policy === 'order') {
|
|
1865
|
+
const move = (i, d) => ahEdit((form) => { const l = form.orders[fam].order; const x = l[i]; l[i] = l[i + d]; l[i + d] = x })
|
|
1866
|
+
kids.push(h('ol', { class: 'ah-order' }, o.order.map((ref, i) => h('li', null,
|
|
1867
|
+
h('span', { class: 'mono' }, (i + 1) + '. ' + accountLabel(ref)),
|
|
1868
|
+
h('button', { class: 'btn btn-sm', type: 'button', disabled: BOOT.readOnly || i === 0, 'aria-label': t('ah.up'), onclick: () => move(i, -1) }, '↑'),
|
|
1869
|
+
h('button', { class: 'btn btn-sm', type: 'button', disabled: BOOT.readOnly || i === o.order.length - 1, 'aria-label': t('ah.down'), onclick: () => move(i, 1) }, '↓'),
|
|
1870
|
+
h('button', { class: 'icon-btn', type: 'button', disabled: BOOT.readOnly, 'aria-label': t('ah.remove'), onclick: () => ahEdit((form) => { form.orders[fam].order.splice(i, 1) }) }, icon('x'))))))
|
|
1871
|
+
const taken = new Set(o.order)
|
|
1872
|
+
const free = S.state.accounts.filter((a) => !taken.has(a.ref) && !(a.aliases || []).some((n) => taken.has(n)))
|
|
1873
|
+
if (!o.order.length) kids.push(h('p', { class: 'note note-warn' }, t('ah.emptyOrder')))
|
|
1874
|
+
if (free.length && !BOOT.readOnly) {
|
|
1875
|
+
const pick = h('select', { 'aria-label': t('ah.add') }, h('option', { value: '' }, t('ah.add') + '…'), free.map((a) => h('option', { value: a.ref }, accountLabel(a.ref))))
|
|
1876
|
+
pick.addEventListener('change', () => { if (pick.value) ahEdit((form) => { form.orders[fam].order.push(pick.value) }) })
|
|
1877
|
+
kids.push(h('div', { class: 'ah-add' }, pick))
|
|
1878
|
+
}
|
|
1879
|
+
} else kids.push(h('p', { class: 'muted small' }, t('ah.byQuotaHint')))
|
|
1880
|
+
return h('div', { class: 'ah-fam' }, kids)
|
|
1881
|
+
}
|
|
1882
|
+
function ahReadiness() {
|
|
1883
|
+
if (ah.loading) return h('p', { class: 'muted' }, spinner(), ' ', t('ah.checking'))
|
|
1884
|
+
if (ah.error) return errorNote(ah.error, loadHandoffStatus)
|
|
1885
|
+
const st = ah.status
|
|
1886
|
+
if (!st) return null
|
|
1887
|
+
const rows = []
|
|
1888
|
+
if (st.sideby.problem) rows.push(h('p', { class: 'note note-fail' }, st.sideby.problem))
|
|
1889
|
+
for (const f of st.families) {
|
|
1890
|
+
const state = f.starts.length ? (f.ready ? h('span', { class: 'chip chip-sm chip-ok' }, t('ah.ready')) : h('span', { class: 'chip chip-sm chip-warn' }, t('ah.notYet'))) : h('span', { class: 'chip chip-sm' }, t('ah.takesOver'))
|
|
1891
|
+
rows.push(h('div', { class: 'ah-fam' }, h('div', { class: 'ah-fam-head' }, h('b', { class: 'title-logo' }, badge(f.family, 'sm'), family(f.family).title), state),
|
|
1892
|
+
f.problems.map((p) => h('p', { class: 'note note-warn' }, p)),
|
|
1893
|
+
f.notes.map((n) => h('p', { class: 'muted small' }, n))))
|
|
1894
|
+
}
|
|
1895
|
+
if (st.nextSteps.length) rows.push(h('p', { class: 'small' }, h('b', null, t('ah.steps'))), h('ol', { class: 'ah-steps' }, st.nextSteps.map((s) => h('li', null, codeCopy(s)))))
|
|
1896
|
+
return rows
|
|
1897
|
+
}
|
|
1898
|
+
function renderHandoff() {
|
|
1899
|
+
const st = ahState()
|
|
1900
|
+
if (!st || !ah) return
|
|
1901
|
+
const f = ah.form
|
|
1902
|
+
const body = [h('p', { class: 'muted small' }, t('ah.intro'))]
|
|
1903
|
+
if (BOOT.readOnly) body.push(h('p', { class: 'note' }, t('banner.readOnly')))
|
|
1904
|
+
body.push(ahCheck('auto', t('ah.auto'), null))
|
|
1905
|
+
body.push(ahCheck('sameFamily', t('ah.sameFamily'), t('ah.sameFamilyWarn')))
|
|
1906
|
+
body.push(h('div', { class: 'ah-grid' },
|
|
1907
|
+
ahNumber('prepareAt', t('ah.prepareAt'), 1, 99),
|
|
1908
|
+
ahNumber('threshold', t('ah.threshold'), 2, 100),
|
|
1909
|
+
ahNumber('wait', t('ah.wait'), 0, 300),
|
|
1910
|
+
ahNumber('countdown', t('ah.countdown'), 0, 60)))
|
|
1911
|
+
if (f.prepareAt >= f.threshold) body.push(h('p', { class: 'note note-warn' }, t('ah.badLevels')))
|
|
1912
|
+
for (const fam of st.starters) body.push(ahFamily(fam))
|
|
1913
|
+
// The result of Review or Apply sits right under the form, where the button that made it is.
|
|
1914
|
+
if (ah.applyError) body.push(errorNote(ah.applyError, null))
|
|
1915
|
+
if (ah.saved) body.push(h('p', { class: 'note' }, t('ah.saved', { file: ah.saved })))
|
|
1916
|
+
if (ah.nothing) body.push(h('p', { class: 'muted small' }, t('ah.nothing')))
|
|
1917
|
+
if (ah.preview) body.push(h('p', { class: 'small' }, tx('ah.changes', { file: h('code', null, ah.preview.file) })), diffView(ah.preview.diff))
|
|
1918
|
+
body.push(h('h3', { class: 'small' }, t('ah.readiness')))
|
|
1919
|
+
body.push(ahReadiness())
|
|
1920
|
+
const foot = [h('button', { class: 'btn', type: 'button', disabled: ah.applying, onclick: () => dlg.el.close() }, t('btn.close'))]
|
|
1921
|
+
if (!BOOT.readOnly) {
|
|
1922
|
+
const bad = f.prepareAt >= f.threshold
|
|
1923
|
+
if (ah.preview) foot.push(h('button', { class: 'btn btn-primary', type: 'button', disabled: ah.applying, 'aria-busy': ah.applying ? 'true' : null, onclick: applyHandoff }, ah.applying ? spinner() : null, ah.applying ? t('ah.applying') : t('ah.apply')))
|
|
1924
|
+
else foot.push(h('button', { class: 'btn btn-primary', type: 'button', disabled: bad || ah.reviewing, onclick: reviewHandoff }, ah.reviewing ? spinner() : null, t('ah.review')))
|
|
1925
|
+
}
|
|
1926
|
+
setKids(dlg.el, dialogFrame(t('ah.title'), null, body, foot))
|
|
1927
|
+
}
|
|
1928
|
+
async function reviewHandoff() {
|
|
1929
|
+
const change = handoffChangeOf(ahState().settings, ah.form)
|
|
1930
|
+
if (!Object.keys(change).length) { ah.nothing = true; renderHandoff(); return }
|
|
1931
|
+
ah.reviewing = true
|
|
1932
|
+
ah.applyError = null
|
|
1933
|
+
renderHandoff()
|
|
1934
|
+
try {
|
|
1935
|
+
const r = await api('/api/handoff/configure', { change })
|
|
1936
|
+
ah.preview = r.changed ? { change, file: r.file, diff: r.diff } : null
|
|
1937
|
+
ah.nothing = !r.changed
|
|
1938
|
+
} catch (e) { ah.applyError = e }
|
|
1939
|
+
ah.reviewing = false
|
|
1940
|
+
renderHandoff()
|
|
1941
|
+
}
|
|
1942
|
+
async function applyHandoff() {
|
|
1943
|
+
ah.applying = true
|
|
1944
|
+
dlg.busy = true
|
|
1945
|
+
renderHandoff()
|
|
1946
|
+
try {
|
|
1947
|
+
const r = await api('/api/handoff/configure', { change: ah.preview.change, confirm: true })
|
|
1948
|
+
ah.saved = r.file
|
|
1949
|
+
ah.preview = null
|
|
1950
|
+
toast(r.settings.auto ? t('toast.ahOn') : t('toast.ahOff'))
|
|
1951
|
+
await load({ silent: true })
|
|
1952
|
+
if (ahState()) ah.form = handoffForm()
|
|
1953
|
+
ah.loading = true
|
|
1954
|
+
ah.status = null
|
|
1955
|
+
} catch (e) { ah.applyError = e }
|
|
1956
|
+
ah.applying = false
|
|
1957
|
+
dlg.busy = false
|
|
1958
|
+
renderHandoff()
|
|
1959
|
+
if (ah.loading) loadHandoffStatus()
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1513
1962
|
// ---------- banners ----------
|
|
1514
1963
|
function renderBanners() {
|
|
1515
1964
|
const out = []
|
|
@@ -1518,6 +1967,12 @@ const MAIN = String.raw `
|
|
|
1518
1967
|
h('button', { class: 'btn btn-sm', type: 'button', onclick: () => load({ check: !S.report }) }, t('btn.retry'))))
|
|
1519
1968
|
if (S.state) {
|
|
1520
1969
|
if (BOOT.readOnly) out.push(h('div', { class: 'banner banner-quiet' }, h('div', { class: 'muted small' }, t('banner.readOnly'))))
|
|
1970
|
+
const hint = handoffHint()
|
|
1971
|
+
if (hint) out.push(h('div', { class: 'banner', role: 'status' },
|
|
1972
|
+
h('div', null, t('ah.hint', { ref: hint.ref, p: hint.pressure })),
|
|
1973
|
+
h('div', { class: 'banner-actions' },
|
|
1974
|
+
h('button', { class: 'btn btn-sm btn-primary', type: 'button', onclick: openHandoff }, t('ah.setUp')),
|
|
1975
|
+
h('button', { class: 'btn btn-sm', type: 'button', onclick: () => { prefs.ahHintAt = Date.now(); savePrefs(); renderBanners() } }, t('ah.dismiss')))))
|
|
1521
1976
|
const errs = S.state.pluginErrors
|
|
1522
1977
|
if (errs.length) out.push(h('div', { class: 'banner banner-warn', role: 'status' }, h('div', null,
|
|
1523
1978
|
h('b', null, tn('banner.plugins', errs.length) + ' '), t('banner.pluginsHint'),
|
|
@@ -1527,7 +1982,7 @@ const MAIN = String.raw `
|
|
|
1527
1982
|
}
|
|
1528
1983
|
|
|
1529
1984
|
// ---------- loading ----------
|
|
1530
|
-
function renderAll() { renderTop(); renderBanners(); renderSummary(); renderFamilyFilter(); renderEmailToggle(); renderAccounts(); renderCheckup() }
|
|
1985
|
+
function renderAll() { renderTop(); renderBanners(); renderSummary(); renderFamilyFilter(); renderEmailToggle(); renderAccounts(); renderCheckup(); renderPageChrome() }
|
|
1531
1986
|
// A load that starts while another runs is queued, never dropped, so a change made meanwhile (a new
|
|
1532
1987
|
// account, a fix) always ends up on screen; and only the newest response is ever rendered. A silent load is a
|
|
1533
1988
|
// background refresh: no spinner, and its render waits while the viewer is busy (see uiBusy).
|
|
@@ -1601,6 +2056,7 @@ const MAIN = String.raw `
|
|
|
1601
2056
|
// ---------- start ----------
|
|
1602
2057
|
$('ver').textContent = BOOT.version ? 'v' + BOOT.version : ''
|
|
1603
2058
|
$('refresh').addEventListener('click', () => load({ check: true, announce: true }))
|
|
2059
|
+
$('handoff').addEventListener('click', openHandoff)
|
|
1604
2060
|
$('lang').addEventListener('click', () => {
|
|
1605
2061
|
if (LANG_FIXED) return
|
|
1606
2062
|
lang = lang === 'zh' ? 'en' : 'zh'
|
|
@@ -1611,6 +2067,7 @@ const MAIN = String.raw `
|
|
|
1611
2067
|
renderAll()
|
|
1612
2068
|
if (dlg.kind === 'create' && nf && !nf.creating) renderCreate()
|
|
1613
2069
|
else if (dlg.kind === 'setup' && qs) renderSetup()
|
|
2070
|
+
else if (dlg.kind === 'tune' && tuneState) renderTune()
|
|
1614
2071
|
})
|
|
1615
2072
|
$('theme').addEventListener('click', () => {
|
|
1616
2073
|
const order = CHOICES.theme
|
|
@@ -116,6 +116,18 @@ code, .mono { font-family: var(--mono); font-size: 0.92em; }
|
|
|
116
116
|
.seg { display: inline-flex; align-items: center; gap: 2px; height: var(--ctl-h); padding: 2px; border: 1px solid var(--border-strong); border-radius: var(--ctl-radius); background: var(--surface); }
|
|
117
117
|
.seg-btn { display: inline-flex; align-items: center; gap: 5px; height: 100%; padding: 0 10px; border: 0; border-radius: calc(var(--ctl-radius) - 2px); background: transparent; color: var(--muted); font: inherit; font-size: var(--control-size); cursor: pointer; }
|
|
118
118
|
.seg-btn:hover { color: var(--text); }
|
|
119
|
+
.seg-btn[aria-selected=true] { background: var(--active); color: var(--text); font-weight: var(--strong-weight); }
|
|
120
|
+
.pages { margin: 0 0 14px; }
|
|
121
|
+
.row-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
|
122
|
+
.tune-confirm { display: flex; flex-direction: column; gap: 8px; border-top: 1px solid var(--border); padding-top: 10px; }
|
|
123
|
+
.tune-confirm ul { margin: 0; padding-left: 18px; }
|
|
124
|
+
.tune-undo { margin: 0; }
|
|
125
|
+
.tune-details { margin-top: 4px; }
|
|
126
|
+
.tune-details summary { cursor: pointer; color: var(--muted); font-size: var(--small); }
|
|
127
|
+
.tune-detail { border-left: 2px solid var(--border); padding: 4px 0 4px 10px; margin: 6px 0; display: flex; flex-direction: column; gap: 4px; }
|
|
128
|
+
.tune-setting { display: flex; flex-direction: column; gap: 8px; border: 1px dashed var(--border-strong); border-radius: calc(var(--radius) - 4px); padding: 10px 12px; }
|
|
129
|
+
.tune-acct { display: flex; flex-direction: column; gap: 8px; margin: 18px 0; }
|
|
130
|
+
.tune-acct-head { display: flex; align-items: center; gap: 8px; }
|
|
119
131
|
.seg-btn[aria-pressed=true] { background: var(--active); color: var(--text); font-weight: var(--strong-weight); }
|
|
120
132
|
.seg-btn:focus-visible { outline: var(--focus-outline); outline-offset: 1px; box-shadow: var(--focus-ring); }
|
|
121
133
|
.acct-head { margin-bottom: 10px; }
|
|
@@ -322,6 +334,20 @@ a.btn:hover { text-decoration: none; }
|
|
|
322
334
|
.banner-fail { background: var(--fail-soft); border-color: transparent; color: var(--fail); }
|
|
323
335
|
.banner-warn { background: var(--warn-soft); border-color: transparent; color: var(--warn); }
|
|
324
336
|
.banner ul { margin: 6px 0 0; padding-left: 18px; }
|
|
337
|
+
.banner-actions { display: flex; gap: 8px; flex: none; }
|
|
338
|
+
.ah-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; align-items: end; }
|
|
339
|
+
.tune-model { border: 1px solid var(--border); border-radius: calc(var(--radius) - 4px); padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; }
|
|
340
|
+
.tune-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; flex-wrap: wrap; }
|
|
341
|
+
.tune-vals { display: flex; gap: 24px; }
|
|
342
|
+
.tune-num { font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; }
|
|
343
|
+
.tune-best { color: var(--ok, var(--accent)); }
|
|
344
|
+
.ah-fam { border: 1px solid var(--border); border-radius: calc(var(--radius) - 4px); padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; }
|
|
345
|
+
.ah-fam-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
346
|
+
.ah-order { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
|
347
|
+
.ah-order li { display: flex; align-items: center; gap: 6px; }
|
|
348
|
+
.ah-order li .mono { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
|
349
|
+
.ah-add { display: flex; gap: 6px; }
|
|
350
|
+
.ah-steps { margin: 0; padding-left: 20px; display: flex; flex-direction: column; gap: 6px; }
|
|
325
351
|
.disabled-why { color: var(--muted); font-size: var(--small); margin-top: 10px; }
|
|
326
352
|
.actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
327
353
|
.spin { display: inline-block; width: 13px; height: 13px; border-radius: 50%; border: 2px solid currentColor; border-right-color: transparent; animation: spin .8s linear infinite; vertical-align: -2px; flex: none; }
|
|
@@ -354,11 +380,11 @@ a.btn:hover { text-decoration: none; }
|
|
|
354
380
|
.field { display: flex; flex-direction: column; gap: 6px; border: 0; padding: 0; margin: 0; min-width: 0; }
|
|
355
381
|
.field > span, .field > legend { font-weight: var(--strong-weight); font-size: var(--small); padding: 0; margin-bottom: 6px; }
|
|
356
382
|
.field > span { margin-bottom: 0; }
|
|
357
|
-
input[type=text], input[type=search], select { height: var(--field-h); border-radius: var(--ctl-radius); border: 1px solid var(--border-strong); background-color: var(--surface); color: var(--text); font: inherit; padding: 0 12px; width: 100%; transition: border-color .12s, box-shadow .12s; }
|
|
383
|
+
input[type=text], input[type=search], input[type=number], select { height: var(--field-h); border-radius: var(--ctl-radius); border: 1px solid var(--border-strong); background-color: var(--surface); color: var(--text); font: inherit; padding: 0 12px; width: 100%; transition: border-color .12s, box-shadow .12s; }
|
|
358
384
|
input[type=search] { -webkit-appearance: none; appearance: none; }
|
|
359
385
|
input[type=search]::-webkit-search-cancel-button { -webkit-appearance: none; }
|
|
360
386
|
input::placeholder { color: var(--faint); }
|
|
361
|
-
input[type=text]:hover:not(:disabled), input[type=search]:hover, select:hover:not(:disabled) { border-color: var(--btn-hover-border); }
|
|
387
|
+
input[type=text]:hover:not(:disabled), input[type=number]:hover:not(:disabled), input[type=search]:hover, select:hover:not(:disabled) { border-color: var(--btn-hover-border); }
|
|
362
388
|
input[type=text]:focus, input[type=search]:focus, select:focus { border-color: var(--focus); }
|
|
363
389
|
input[type=text]:disabled, select:disabled { background-color: var(--surface-2); color: var(--faint); cursor: not-allowed; }
|
|
364
390
|
input.invalid { border-color: var(--fail); }
|