sideby 0.3.0 → 0.4.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 +27 -10
- package/dist/build.json +1 -1
- package/dist/src/cli/commands.d.ts +8 -1
- package/dist/src/cli/commands.js +330 -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 +113 -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 +51 -0
- package/dist/src/core/tuning-apply.js +237 -0
- package/dist/src/core/tuning.d.ts +98 -0
- package/dist/src/core/tuning.js +176 -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 +187 -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 +96 -0
- package/dist/src/panel/i18n.js +192 -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 +454 -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 +210 -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 +108 -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,247 @@ 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
|
+
h('p', { class: 'muted small' }, t('tune.newSessions')),
|
|
1319
|
+
h('div', { class: 'row-actions' },
|
|
1320
|
+
h('button', { class: 'btn btn-primary btn-sm', type: 'button', onclick: useConfirm }, t('tune.confirm')),
|
|
1321
|
+
h('button', { class: 'btn btn-sm', type: 'button', onclick: () => { useUi = null; tuneRedraw() } }, t('btn.cancel'))))
|
|
1322
|
+
}
|
|
1323
|
+
// The card for one settings file.
|
|
1324
|
+
function tuneGroupCard(g, entries) {
|
|
1325
|
+
const pick = g.pick
|
|
1326
|
+
const sizes = new Set([50000, 80000, 100000, 120000, 160000, 200000, 300000, 500000, 800000])
|
|
1327
|
+
let top = 200000
|
|
1328
|
+
for (const e of entries) if (e.tuning.status === 'ok') for (const m of e.tuning.models) top = Math.max(top, m.contextWindow)
|
|
1329
|
+
for (const v of [...sizes]) if (v > top) sizes.delete(v)
|
|
1330
|
+
if (pick) sizes.add(pick.threshold)
|
|
1331
|
+
if (g.current) sizes.add(g.current)
|
|
1332
|
+
const key = (v) => (v === null ? 'default' : String(v))
|
|
1333
|
+
const chosen = tuneSel[g.id] !== undefined ? tuneSel[g.id] : key(g.current)
|
|
1334
|
+
const sel = h('select', { 'aria-label': t('tune.setting') },
|
|
1335
|
+
h('option', { value: 'default' }, t('tune.hostDefaultLong')),
|
|
1336
|
+
[...sizes].sort((x, y) => x - y).map((v) => h('option', { value: String(v) }, tokens(v) + (pick && v === pick.threshold ? ' (' + t('tune.suggestedTag') + ')' : ''))))
|
|
1337
|
+
sel.value = chosen
|
|
1338
|
+
sel.addEventListener('change', () => { tuneSel[g.id] = sel.value; tuneRedraw() })
|
|
1339
|
+
const value = chosen === 'default' ? null : Number(chosen)
|
|
1340
|
+
const mine = useUi && useUi.gid === g.id
|
|
1341
|
+
const busy = mine && (useUi.step === 'asking' || useUi.step === 'busy')
|
|
1342
|
+
const asking = mine && (useUi.step === 'confirm' || busy)
|
|
1343
|
+
const rows = [h('p', { class: 'small' }, g.members.length > 1 ? t('tune.groupShared', { list: g.members.join(', ') }) : h('b', { class: 'mono' }, g.members[0]))]
|
|
1344
|
+
rows.push(h('div', { class: 'tune-vals' },
|
|
1345
|
+
h('div', null, h('span', { class: 'muted small' }, t('tune.now')), h('div', { class: 'tune-num' }, tuneSize(g.current))),
|
|
1346
|
+
pick ? h('div', null, h('span', { class: 'muted small' }, t('tune.best')), h('div', { class: 'tune-num tune-best' }, tokens(pick.threshold))) : null))
|
|
1347
|
+
if (pick && pick.savedPercent !== null) rows.push(h('p', { class: 'small' }, t('tune.saved', { n: Math.round(pick.savedPercent) })))
|
|
1348
|
+
if (pick) rows.push(h('p', { class: 'muted small' }, t('tune.basis', { model: pick.model, ref: pick.ref })))
|
|
1349
|
+
else rows.push(h('p', { class: 'note' }, t('tune.groupNone')))
|
|
1350
|
+
if (!BOOT.readOnly) {
|
|
1351
|
+
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) }))))
|
|
1352
|
+
rows.push(h('div', { class: 'tune-setting' },
|
|
1353
|
+
h('b', { class: 'small' }, t('tune.setting')),
|
|
1354
|
+
h('div', { class: 'row-actions' }, sel,
|
|
1355
|
+
h('button', { class: 'btn btn-sm', type: 'button', disabled: Boolean(busy) || value === g.current, onclick: () => useAsk(g, value) }, t('tune.useThis')))))
|
|
1356
|
+
rows.push(useBox(g, false))
|
|
1357
|
+
if (g.undo && !(mine && (useUi.step === 'done' || useUi.step === 'undone')))
|
|
1358
|
+
rows.push(h('p', { class: 'small tune-undo' }, t('tune.applied', { prev: tuneSize(g.undo.previous) }), ' ',
|
|
1359
|
+
h('button', { class: 'btn btn-sm', type: 'button', onclick: () => useUndo(g) }, t('tune.undo'))))
|
|
1360
|
+
rows.push(useBox(g, true))
|
|
1361
|
+
}
|
|
1362
|
+
return h('div', { class: 'tune-model tune-group' }, rows)
|
|
1363
|
+
}
|
|
1364
|
+
// What was seen for one model: information only, behind a click.
|
|
1365
|
+
function tuneModel(m, minCalls) {
|
|
1366
|
+
const rows = [h('div', { class: 'tune-head' }, h('b', { class: 'mono' }, m.model), h('span', { class: 'muted small' }, t('tune.calls', { n: m.calls })))]
|
|
1367
|
+
rows.push(h('div', { class: 'tune-vals' },
|
|
1368
|
+
h('div', null, h('span', { class: 'muted small' }, t('tune.now')), h('div', { class: 'small' }, tuneSize(m.configured))),
|
|
1369
|
+
m.suggestion ? h('div', null, h('span', { class: 'muted small' }, t('tune.best')), h('div', { class: 'small' }, tokens(m.suggestion.threshold))) : null))
|
|
1370
|
+
if (!m.suggestion) rows.push(h('p', { class: 'muted small' }, t('tune.reason.' + m.reason, { min: minCalls })))
|
|
1371
|
+
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')))
|
|
1372
|
+
return h('div', { class: 'tune-detail' }, rows)
|
|
1373
|
+
}
|
|
1374
|
+
function tuneDetails(entries, minCalls, label) {
|
|
1375
|
+
const kids = []
|
|
1376
|
+
for (const e of entries) {
|
|
1377
|
+
if (e.tuning.status !== 'ok' || !e.tuning.models.length) continue
|
|
1378
|
+
kids.push(h('div', { class: 'tune-acct-head title-logo' }, badge(e.family, 'sm'), h('b', { class: 'mono' }, e.ref)))
|
|
1379
|
+
for (const m of e.tuning.models) kids.push(tuneModel(m, minCalls))
|
|
1380
|
+
}
|
|
1381
|
+
return kids.length ? h('details', { class: 'tune-details' }, h('summary', null, label), kids) : null
|
|
1382
|
+
}
|
|
1383
|
+
// Everything for a response: a card per settings file, then what Codex accounts show (it only observes).
|
|
1384
|
+
function tuneBody(data) {
|
|
1385
|
+
const out = []
|
|
1386
|
+
const byRef = {}
|
|
1387
|
+
for (const e of data.accounts) byRef[e.ref] = e
|
|
1388
|
+
for (const g of data.groups) {
|
|
1389
|
+
const entries = g.members.map((r) => byRef[r]).filter(Boolean)
|
|
1390
|
+
out.push(h('div', { class: 'tune-acct' }, tuneGroupCard(g, entries), tuneDetails(entries, data.minCalls, t('tune.details'))))
|
|
1391
|
+
}
|
|
1392
|
+
const rest = data.accounts.filter((e) => !data.groups.some((g) => g.members.includes(e.ref)))
|
|
1393
|
+
const seen = rest.filter((e) => e.tuning.status === 'ok' && e.tuning.models.length)
|
|
1394
|
+
if (seen.length) out.push(h('div', { class: 'tune-acct' }, tuneDetails(seen, data.minCalls, t('tune.detailsObserved'))))
|
|
1395
|
+
return out
|
|
1396
|
+
}
|
|
1397
|
+
async function openTune(a) {
|
|
1398
|
+
tuneState.ref = a.ref; tuneState.family = a.family; tuneState.data = null; tuneState.error = null
|
|
1399
|
+
// A finished change is not shown again when the dialog opens next time.
|
|
1400
|
+
if (useUi && (useUi.step === 'done' || useUi.step === 'undone')) useUi = null
|
|
1401
|
+
showDialog('tune')
|
|
1402
|
+
await loadTuneDialog()
|
|
1403
|
+
}
|
|
1404
|
+
async function loadTuneDialog() {
|
|
1405
|
+
const ref = tuneState.ref
|
|
1406
|
+
tuneState.loading = true
|
|
1407
|
+
renderTune()
|
|
1408
|
+
try {
|
|
1409
|
+
const r = await api('/api/tuning', { account: ref })
|
|
1410
|
+
if (tuneState.ref === ref) { tuneState.data = r; tuneState.error = null }
|
|
1411
|
+
} catch (e) { if (tuneState.ref === ref) tuneState.error = e }
|
|
1412
|
+
tuneState.loading = false
|
|
1413
|
+
if (dlg.kind === 'tune' && tuneState.ref === ref) renderTune()
|
|
1414
|
+
}
|
|
1415
|
+
function renderTune() {
|
|
1416
|
+
if (!tuneState.ref) return
|
|
1417
|
+
const body = [h('p', { class: 'muted small' }, t('tune.intro', { days: BOOT.usageDays }))]
|
|
1418
|
+
const d = tuneState.data
|
|
1419
|
+
const one = d && d.accounts[0]
|
|
1420
|
+
if (tuneState.loading && !d) body.push(h('p', { class: 'muted' }, spinner(), ' ', t('tune.loading')))
|
|
1421
|
+
else if (tuneState.error) body.push(errorNote(tuneState.error, loadTuneDialog))
|
|
1422
|
+
else if (d && d.groups.length) body.push(tuneBody(d))
|
|
1423
|
+
else if (one && one.tuning.status === 'ok') body.push(tuneBody(d))
|
|
1424
|
+
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')))
|
|
1425
|
+
const foot = [h('button', { class: 'btn', type: 'button', onclick: () => dlg.el.close() }, t('btn.close'))]
|
|
1426
|
+
setKids(dlg.el, dialogFrame(t('tune.title') + ' · ' + tuneState.ref, tuneState.family, body, foot))
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
// The page: every account that can tune, on a page of its own. The address ending in #tuning opens it.
|
|
1430
|
+
function tuningAvailable() { return Boolean(S.state && S.state.families.some((f) => f.tuning)) }
|
|
1431
|
+
function setPage(next) {
|
|
1432
|
+
curPage = next
|
|
1433
|
+
try { history.replaceState(null, '', next === 'tuning' ? '#tuning' : location.pathname + location.search) } catch {}
|
|
1434
|
+
renderPageChrome()
|
|
1435
|
+
}
|
|
1436
|
+
// The page tabs, and which sections show; runs with every render, so a reload or a language switch keeps them.
|
|
1437
|
+
function renderPageChrome() {
|
|
1438
|
+
const nav = $('pages')
|
|
1439
|
+
const can = tuningAvailable()
|
|
1440
|
+
if (!can && curPage === 'tuning' && S.state) curPage = 'accounts'
|
|
1441
|
+
nav.hidden = !can
|
|
1442
|
+
const tabs = [['accounts', t('page.accounts')], ['tuning', t('page.tuning')]]
|
|
1443
|
+
setKids(nav, h('div', { class: 'seg', role: 'presentation' }, tabs.map(([id, label]) =>
|
|
1444
|
+
h('button', { class: 'seg-btn', type: 'button', role: 'tab', 'aria-selected': curPage === id ? 'true' : 'false', onclick: () => setPage(id) }, label))))
|
|
1445
|
+
const on = can && curPage === 'tuning'
|
|
1446
|
+
for (const id of ['summary', 'accounts-page', 'checkup']) $(id).hidden = on
|
|
1447
|
+
$('tuning-page').hidden = !on
|
|
1448
|
+
if (on) {
|
|
1449
|
+
if (!tp.data && !tp.loading && !tp.error) loadTuningPage()
|
|
1450
|
+
else renderTuningPage()
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
async function loadTuningPage() {
|
|
1454
|
+
tp.loading = true
|
|
1455
|
+
tp.error = null
|
|
1456
|
+
renderTuningPage()
|
|
1457
|
+
try { tp.data = await api('/api/tuning', {}); tp.at = Date.now() } catch (e) { tp.error = e }
|
|
1458
|
+
tp.loading = false
|
|
1459
|
+
renderTuningPage()
|
|
1460
|
+
}
|
|
1461
|
+
function renderTuningPage() {
|
|
1462
|
+
const box = $('tuning-page')
|
|
1463
|
+
if (box.hidden) return
|
|
1464
|
+
const kids = [h('div', { class: 'section-head' },
|
|
1465
|
+
h('h2', { id: 'h-tuning' }, t('tpage.title')),
|
|
1466
|
+
h('button', { class: 'btn btn-sm', type: 'button', disabled: tp.loading, onclick: loadTuningPage }, tp.loading ? spinner() : icon('refresh'), t('top.refresh')))]
|
|
1467
|
+
kids.push(h('p', { class: 'muted small' }, t('tune.intro', { days: BOOT.usageDays })))
|
|
1468
|
+
const d = tp.data
|
|
1469
|
+
if (tp.loading && !d) kids.push(h('p', { class: 'muted' }, spinner(), ' ', t('tune.loading')))
|
|
1470
|
+
if (tp.error) kids.push(errorNote(tp.error, loadTuningPage))
|
|
1471
|
+
if (d) {
|
|
1472
|
+
const seen = d.accounts.filter((a) => a.tuning.status === 'ok' && a.tuning.models.length)
|
|
1473
|
+
const idle = d.accounts.filter((a) => !seen.includes(a) && a.tuning.status === 'unavailable' && a.tuning.reason !== 'unrecognized').map((a) => a.ref)
|
|
1474
|
+
const broken = d.accounts.filter((a) => a.tuning.status === 'unavailable' && a.tuning.reason === 'unrecognized')
|
|
1475
|
+
if (!seen.length && !d.groups.length) kids.push(h('p', { class: 'small' }, t('tpage.none')))
|
|
1476
|
+
kids.push(tuneBody(d))
|
|
1477
|
+
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')))
|
|
1478
|
+
if (idle.length) kids.push(h('p', { class: 'muted small' }, t('tpage.idle', { list: idle.join(', ') })))
|
|
1479
|
+
}
|
|
1480
|
+
setKids(box, kids)
|
|
1481
|
+
}
|
|
1482
|
+
window.addEventListener('hashchange', () => { curPage = location.hash === '#tuning' ? 'tuning' : 'accounts'; renderPageChrome() })
|
|
1483
|
+
|
|
1220
1484
|
// New account. The form is built once per opening so typing keeps focus; only its derived parts re-render.
|
|
1221
1485
|
let nf = null
|
|
1222
1486
|
function familyStatus(f) {
|
|
@@ -1510,6 +1774,184 @@ const MAIN = String.raw `
|
|
|
1510
1774
|
load()
|
|
1511
1775
|
}
|
|
1512
1776
|
|
|
1777
|
+
// ---------- auto handoff ----------
|
|
1778
|
+
// Settings come with /api/state; readiness (which runs a check of the sideby on PATH) only when the dialog opens.
|
|
1779
|
+
let ah = null
|
|
1780
|
+
const HINT_QUIET_MS = 24 * 3600000
|
|
1781
|
+
function ahState() { return S.state && S.state.autoHandoff }
|
|
1782
|
+
function renderHandoffButton() {
|
|
1783
|
+
const b = $('handoff')
|
|
1784
|
+
const st = ahState()
|
|
1785
|
+
b.hidden = !st
|
|
1786
|
+
if (!st) return
|
|
1787
|
+
const on = st.settings.auto
|
|
1788
|
+
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'))])
|
|
1789
|
+
b.setAttribute('aria-label', t('ah.button') + ': ' + (on ? t('ah.on') : t('ah.off')))
|
|
1790
|
+
}
|
|
1791
|
+
function handoffHint() {
|
|
1792
|
+
const st = ahState()
|
|
1793
|
+
if (!st || BOOT.readOnly) return null
|
|
1794
|
+
if (prefs.ahHintAt && Date.now() - prefs.ahHintAt < HINT_QUIET_MS) return null
|
|
1795
|
+
return handoffHintAccount(S.state.accounts, st.settings, st.starters, Date.now())
|
|
1796
|
+
}
|
|
1797
|
+
function handoffForm() {
|
|
1798
|
+
const st = ahState()
|
|
1799
|
+
const s = st.settings
|
|
1800
|
+
const orders = {}
|
|
1801
|
+
for (const f of st.starters) {
|
|
1802
|
+
const cur = s.families[f]
|
|
1803
|
+
orders[f] = { policy: cur && cur.policy === 'order' ? 'order' : 'pressure', order: cur ? cur.order.slice() : [] }
|
|
1804
|
+
}
|
|
1805
|
+
return { auto: s.auto, sameFamily: s.sameFamily, prepareAt: s.prepareAt, threshold: s.threshold, wait: s.waitIfResetWithinMinutes, countdown: s.countdownSeconds, orders }
|
|
1806
|
+
}
|
|
1807
|
+
async function openHandoff() {
|
|
1808
|
+
if (!ahState()) return
|
|
1809
|
+
ah = { form: handoffForm(), loading: true }
|
|
1810
|
+
showDialog('handoff')
|
|
1811
|
+
renderHandoff()
|
|
1812
|
+
await loadHandoffStatus()
|
|
1813
|
+
}
|
|
1814
|
+
async function loadHandoffStatus() {
|
|
1815
|
+
const mine = ah
|
|
1816
|
+
try {
|
|
1817
|
+
const st = await api('/api/handoff/status', {})
|
|
1818
|
+
if (ah === mine) { ah.status = st; ah.loading = false; ah.error = null }
|
|
1819
|
+
} catch (e) { if (ah === mine) { ah.error = e; ah.loading = false } }
|
|
1820
|
+
if (dlg.kind === 'handoff') renderHandoff()
|
|
1821
|
+
}
|
|
1822
|
+
function ahEdit(fn) {
|
|
1823
|
+
fn(ah.form)
|
|
1824
|
+
ah.preview = null
|
|
1825
|
+
ah.nothing = false
|
|
1826
|
+
ah.saved = null
|
|
1827
|
+
renderHandoff()
|
|
1828
|
+
}
|
|
1829
|
+
function ahNumber(key, label, min, max) {
|
|
1830
|
+
const id = 'ah-' + key
|
|
1831
|
+
const input = h('input', { id, type: 'number', min: String(min), max: String(max), step: '1', value: String(ah.form[key]), disabled: BOOT.readOnly })
|
|
1832
|
+
input.addEventListener('change', () => {
|
|
1833
|
+
const n = Math.round(Number(input.value))
|
|
1834
|
+
if (Number.isFinite(n)) ahEdit((f) => { f[key] = Math.min(max, Math.max(min, n)) })
|
|
1835
|
+
})
|
|
1836
|
+
return h('label', { class: 'field', for: id }, h('span', null, label), input)
|
|
1837
|
+
}
|
|
1838
|
+
function ahCheck(key, label, hintText) {
|
|
1839
|
+
const input = h('input', { type: 'checkbox', disabled: BOOT.readOnly })
|
|
1840
|
+
input.checked = Boolean(ah.form[key])
|
|
1841
|
+
input.addEventListener('change', () => ahEdit((f) => { f[key] = input.checked }))
|
|
1842
|
+
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)))
|
|
1843
|
+
}
|
|
1844
|
+
function accountLabel(ref) {
|
|
1845
|
+
const a = S.state.accounts.find((x) => x.ref === ref || (x.aliases || []).includes(ref))
|
|
1846
|
+
const alias = a && a.aliases && a.aliases[0]
|
|
1847
|
+
return a ? (alias && alias !== ref ? ref + ' (' + alias + ')' : a.ref) : ref
|
|
1848
|
+
}
|
|
1849
|
+
function ahFamily(fam) {
|
|
1850
|
+
const o = ah.form.orders[fam]
|
|
1851
|
+
const f = family(fam)
|
|
1852
|
+
const policy = h('select', { 'aria-label': t('ah.order', { host: f.title }), disabled: BOOT.readOnly },
|
|
1853
|
+
h('option', { value: 'pressure' }, t('ah.byQuota')), h('option', { value: 'order' }, t('ah.myOrder')))
|
|
1854
|
+
policy.value = o.policy
|
|
1855
|
+
policy.addEventListener('change', () => ahEdit((form) => { form.orders[fam].policy = policy.value }))
|
|
1856
|
+
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))]
|
|
1857
|
+
if (o.policy === 'order') {
|
|
1858
|
+
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 })
|
|
1859
|
+
kids.push(h('ol', { class: 'ah-order' }, o.order.map((ref, i) => h('li', null,
|
|
1860
|
+
h('span', { class: 'mono' }, (i + 1) + '. ' + accountLabel(ref)),
|
|
1861
|
+
h('button', { class: 'btn btn-sm', type: 'button', disabled: BOOT.readOnly || i === 0, 'aria-label': t('ah.up'), onclick: () => move(i, -1) }, '↑'),
|
|
1862
|
+
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) }, '↓'),
|
|
1863
|
+
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'))))))
|
|
1864
|
+
const taken = new Set(o.order)
|
|
1865
|
+
const free = S.state.accounts.filter((a) => !taken.has(a.ref) && !(a.aliases || []).some((n) => taken.has(n)))
|
|
1866
|
+
if (!o.order.length) kids.push(h('p', { class: 'note note-warn' }, t('ah.emptyOrder')))
|
|
1867
|
+
if (free.length && !BOOT.readOnly) {
|
|
1868
|
+
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))))
|
|
1869
|
+
pick.addEventListener('change', () => { if (pick.value) ahEdit((form) => { form.orders[fam].order.push(pick.value) }) })
|
|
1870
|
+
kids.push(h('div', { class: 'ah-add' }, pick))
|
|
1871
|
+
}
|
|
1872
|
+
} else kids.push(h('p', { class: 'muted small' }, t('ah.byQuotaHint')))
|
|
1873
|
+
return h('div', { class: 'ah-fam' }, kids)
|
|
1874
|
+
}
|
|
1875
|
+
function ahReadiness() {
|
|
1876
|
+
if (ah.loading) return h('p', { class: 'muted' }, spinner(), ' ', t('ah.checking'))
|
|
1877
|
+
if (ah.error) return errorNote(ah.error, loadHandoffStatus)
|
|
1878
|
+
const st = ah.status
|
|
1879
|
+
if (!st) return null
|
|
1880
|
+
const rows = []
|
|
1881
|
+
if (st.sideby.problem) rows.push(h('p', { class: 'note note-fail' }, st.sideby.problem))
|
|
1882
|
+
for (const f of st.families) {
|
|
1883
|
+
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'))
|
|
1884
|
+
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),
|
|
1885
|
+
f.problems.map((p) => h('p', { class: 'note note-warn' }, p)),
|
|
1886
|
+
f.notes.map((n) => h('p', { class: 'muted small' }, n))))
|
|
1887
|
+
}
|
|
1888
|
+
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)))))
|
|
1889
|
+
return rows
|
|
1890
|
+
}
|
|
1891
|
+
function renderHandoff() {
|
|
1892
|
+
const st = ahState()
|
|
1893
|
+
if (!st || !ah) return
|
|
1894
|
+
const f = ah.form
|
|
1895
|
+
const body = [h('p', { class: 'muted small' }, t('ah.intro'))]
|
|
1896
|
+
if (BOOT.readOnly) body.push(h('p', { class: 'note' }, t('banner.readOnly')))
|
|
1897
|
+
body.push(ahCheck('auto', t('ah.auto'), null))
|
|
1898
|
+
body.push(ahCheck('sameFamily', t('ah.sameFamily'), t('ah.sameFamilyWarn')))
|
|
1899
|
+
body.push(h('div', { class: 'ah-grid' },
|
|
1900
|
+
ahNumber('prepareAt', t('ah.prepareAt'), 1, 99),
|
|
1901
|
+
ahNumber('threshold', t('ah.threshold'), 2, 100),
|
|
1902
|
+
ahNumber('wait', t('ah.wait'), 0, 300),
|
|
1903
|
+
ahNumber('countdown', t('ah.countdown'), 0, 60)))
|
|
1904
|
+
if (f.prepareAt >= f.threshold) body.push(h('p', { class: 'note note-warn' }, t('ah.badLevels')))
|
|
1905
|
+
for (const fam of st.starters) body.push(ahFamily(fam))
|
|
1906
|
+
// The result of Review or Apply sits right under the form, where the button that made it is.
|
|
1907
|
+
if (ah.applyError) body.push(errorNote(ah.applyError, null))
|
|
1908
|
+
if (ah.saved) body.push(h('p', { class: 'note' }, t('ah.saved', { file: ah.saved })))
|
|
1909
|
+
if (ah.nothing) body.push(h('p', { class: 'muted small' }, t('ah.nothing')))
|
|
1910
|
+
if (ah.preview) body.push(h('p', { class: 'small' }, tx('ah.changes', { file: h('code', null, ah.preview.file) })), diffView(ah.preview.diff))
|
|
1911
|
+
body.push(h('h3', { class: 'small' }, t('ah.readiness')))
|
|
1912
|
+
body.push(ahReadiness())
|
|
1913
|
+
const foot = [h('button', { class: 'btn', type: 'button', disabled: ah.applying, onclick: () => dlg.el.close() }, t('btn.close'))]
|
|
1914
|
+
if (!BOOT.readOnly) {
|
|
1915
|
+
const bad = f.prepareAt >= f.threshold
|
|
1916
|
+
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')))
|
|
1917
|
+
else foot.push(h('button', { class: 'btn btn-primary', type: 'button', disabled: bad || ah.reviewing, onclick: reviewHandoff }, ah.reviewing ? spinner() : null, t('ah.review')))
|
|
1918
|
+
}
|
|
1919
|
+
setKids(dlg.el, dialogFrame(t('ah.title'), null, body, foot))
|
|
1920
|
+
}
|
|
1921
|
+
async function reviewHandoff() {
|
|
1922
|
+
const change = handoffChangeOf(ahState().settings, ah.form)
|
|
1923
|
+
if (!Object.keys(change).length) { ah.nothing = true; renderHandoff(); return }
|
|
1924
|
+
ah.reviewing = true
|
|
1925
|
+
ah.applyError = null
|
|
1926
|
+
renderHandoff()
|
|
1927
|
+
try {
|
|
1928
|
+
const r = await api('/api/handoff/configure', { change })
|
|
1929
|
+
ah.preview = r.changed ? { change, file: r.file, diff: r.diff } : null
|
|
1930
|
+
ah.nothing = !r.changed
|
|
1931
|
+
} catch (e) { ah.applyError = e }
|
|
1932
|
+
ah.reviewing = false
|
|
1933
|
+
renderHandoff()
|
|
1934
|
+
}
|
|
1935
|
+
async function applyHandoff() {
|
|
1936
|
+
ah.applying = true
|
|
1937
|
+
dlg.busy = true
|
|
1938
|
+
renderHandoff()
|
|
1939
|
+
try {
|
|
1940
|
+
const r = await api('/api/handoff/configure', { change: ah.preview.change, confirm: true })
|
|
1941
|
+
ah.saved = r.file
|
|
1942
|
+
ah.preview = null
|
|
1943
|
+
toast(r.settings.auto ? t('toast.ahOn') : t('toast.ahOff'))
|
|
1944
|
+
await load({ silent: true })
|
|
1945
|
+
if (ahState()) ah.form = handoffForm()
|
|
1946
|
+
ah.loading = true
|
|
1947
|
+
ah.status = null
|
|
1948
|
+
} catch (e) { ah.applyError = e }
|
|
1949
|
+
ah.applying = false
|
|
1950
|
+
dlg.busy = false
|
|
1951
|
+
renderHandoff()
|
|
1952
|
+
if (ah.loading) loadHandoffStatus()
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1513
1955
|
// ---------- banners ----------
|
|
1514
1956
|
function renderBanners() {
|
|
1515
1957
|
const out = []
|
|
@@ -1518,6 +1960,12 @@ const MAIN = String.raw `
|
|
|
1518
1960
|
h('button', { class: 'btn btn-sm', type: 'button', onclick: () => load({ check: !S.report }) }, t('btn.retry'))))
|
|
1519
1961
|
if (S.state) {
|
|
1520
1962
|
if (BOOT.readOnly) out.push(h('div', { class: 'banner banner-quiet' }, h('div', { class: 'muted small' }, t('banner.readOnly'))))
|
|
1963
|
+
const hint = handoffHint()
|
|
1964
|
+
if (hint) out.push(h('div', { class: 'banner', role: 'status' },
|
|
1965
|
+
h('div', null, t('ah.hint', { ref: hint.ref, p: hint.pressure })),
|
|
1966
|
+
h('div', { class: 'banner-actions' },
|
|
1967
|
+
h('button', { class: 'btn btn-sm btn-primary', type: 'button', onclick: openHandoff }, t('ah.setUp')),
|
|
1968
|
+
h('button', { class: 'btn btn-sm', type: 'button', onclick: () => { prefs.ahHintAt = Date.now(); savePrefs(); renderBanners() } }, t('ah.dismiss')))))
|
|
1521
1969
|
const errs = S.state.pluginErrors
|
|
1522
1970
|
if (errs.length) out.push(h('div', { class: 'banner banner-warn', role: 'status' }, h('div', null,
|
|
1523
1971
|
h('b', null, tn('banner.plugins', errs.length) + ' '), t('banner.pluginsHint'),
|
|
@@ -1527,7 +1975,7 @@ const MAIN = String.raw `
|
|
|
1527
1975
|
}
|
|
1528
1976
|
|
|
1529
1977
|
// ---------- loading ----------
|
|
1530
|
-
function renderAll() { renderTop(); renderBanners(); renderSummary(); renderFamilyFilter(); renderEmailToggle(); renderAccounts(); renderCheckup() }
|
|
1978
|
+
function renderAll() { renderTop(); renderBanners(); renderSummary(); renderFamilyFilter(); renderEmailToggle(); renderAccounts(); renderCheckup(); renderPageChrome() }
|
|
1531
1979
|
// A load that starts while another runs is queued, never dropped, so a change made meanwhile (a new
|
|
1532
1980
|
// account, a fix) always ends up on screen; and only the newest response is ever rendered. A silent load is a
|
|
1533
1981
|
// background refresh: no spinner, and its render waits while the viewer is busy (see uiBusy).
|
|
@@ -1601,6 +2049,7 @@ const MAIN = String.raw `
|
|
|
1601
2049
|
// ---------- start ----------
|
|
1602
2050
|
$('ver').textContent = BOOT.version ? 'v' + BOOT.version : ''
|
|
1603
2051
|
$('refresh').addEventListener('click', () => load({ check: true, announce: true }))
|
|
2052
|
+
$('handoff').addEventListener('click', openHandoff)
|
|
1604
2053
|
$('lang').addEventListener('click', () => {
|
|
1605
2054
|
if (LANG_FIXED) return
|
|
1606
2055
|
lang = lang === 'zh' ? 'en' : 'zh'
|
|
@@ -1611,6 +2060,7 @@ const MAIN = String.raw `
|
|
|
1611
2060
|
renderAll()
|
|
1612
2061
|
if (dlg.kind === 'create' && nf && !nf.creating) renderCreate()
|
|
1613
2062
|
else if (dlg.kind === 'setup' && qs) renderSetup()
|
|
2063
|
+
else if (dlg.kind === 'tune' && tuneState) renderTune()
|
|
1614
2064
|
})
|
|
1615
2065
|
$('theme').addEventListener('click', () => {
|
|
1616
2066
|
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); }
|
package/dist/src/panel/page.js
CHANGED
|
@@ -67,14 +67,17 @@ export function renderPage(opts) {
|
|
|
67
67
|
<span id="updated" class="muted small">Loading…</span>
|
|
68
68
|
<button id="lang" class="icon-btn lang-btn" type="button"${lang === 'auto' ? '' : ' hidden'}>${lang === 'zh' ? 'EN' : '中文'}</button>
|
|
69
69
|
<button id="theme" class="icon-btn" type="button" hidden></button>
|
|
70
|
+
<button id="handoff" class="btn" type="button" hidden></button>
|
|
70
71
|
<button id="refresh" class="btn" type="button" aria-label="Refresh"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 11a8 8 0 1 0-2.3 5.7"/><path d="M20 4v7h-7"/></svg><span class="hide-sm" data-i18n="top.refresh">Refresh</span></button>
|
|
71
72
|
</div>
|
|
72
73
|
</div>
|
|
73
74
|
</header>
|
|
74
75
|
<main class="wrap">
|
|
75
76
|
<div id="banners"></div>
|
|
77
|
+
<nav id="pages" class="pages" role="tablist" hidden></nav>
|
|
76
78
|
<section id="summary" class="summary" aria-busy="true">${SKELETON_STAT.repeat(4)}</section>
|
|
77
|
-
<section class="block" aria-labelledby="h-
|
|
79
|
+
<section id="tuning-page" class="block" aria-labelledby="h-tuning" hidden></section>
|
|
80
|
+
<section id="accounts-page" class="block" aria-labelledby="h-accounts">
|
|
78
81
|
<div class="section-head acct-head"><h2 id="h-accounts"><span data-i18n="acct.title">Accounts</span><span id="acct-count" class="muted small"></span></h2></div>
|
|
79
82
|
<div id="toolbar" class="toolbar" hidden></div>
|
|
80
83
|
<div id="accounts" aria-busy="true"><div class="group group-list">${SKELETON_ROW.repeat(4)}</div></div>
|