Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
ce6d696
feat(config): add independent accounts per curated provider
SivanCola Sep 3, 2026
0ea1ddc
fix(frontend): remove provider account settings EOF whitespace
SivanCola Sep 3, 2026
f9faeef
test(boot): guard full provider request prefix across accounts
SivanCola Sep 3, 2026
a4aff19
fix(config): persist account route state and default selection
SivanCola Sep 3, 2026
8ce64ef
fix(cli): persist provider account setup operations
SivanCola Sep 3, 2026
361bb55
fix(config): synchronize account route access state
SivanCola Sep 3, 2026
0f27da3
test(cli): include account state in setup summary
SivanCola Sep 3, 2026
9a860ba
test(config): cover disabled route persistence
SivanCola Sep 3, 2026
070148a
fix(cli): hide disabled account routes in provider picker
SivanCola Sep 3, 2026
a7d3bd3
fix(cli): record account default model mutations
SivanCola Sep 3, 2026
f251ed8
fix(config): disable retained routes when retiring account
SivanCola Sep 3, 2026
d996e67
fix(cli): replay explicit account route state
SivanCola Sep 3, 2026
5f1290a
fix(desktop): preserve account routes and generic account UI
SivanCola Sep 3, 2026
c436a41
fix(cli): preserve account order during replay
SivanCola Sep 3, 2026
0214899
fix(config): recognize legacy family defaults when switching accounts
SivanCola Sep 3, 2026
037c835
test(desktop): assert retained account routes on removal
SivanCola Sep 3, 2026
1d82f91
test(cli): make curated account menu ordering explicit
SivanCola Sep 3, 2026
7fc4353
fix(frontend): normalize provider route metadata
SivanCola Sep 3, 2026
7e4119a
fix(config): allow explicit restoration of optional routes
SivanCola Sep 3, 2026
5bc9084
fix(cli): avoid logging setup prompt defaults
SivanCola Sep 3, 2026
6f87618
fix(accounts): keep family fallback when provider metadata is sparse
SivanCola Sep 3, 2026
f6d4944
fix(cli): expose disabled account routes for restore
SivanCola Sep 3, 2026
6beb101
fix(config): roll back route state on reconcile failure
SivanCola Sep 3, 2026
9d3f516
feat(frontend): expose account route controls
SivanCola Sep 3, 2026
4a85e83
fix(cli): reject empty account replay operations
SivanCola Sep 3, 2026
7fe1928
test(desktop): cover retained optional account routes
SivanCola Sep 3, 2026
5158390
fix(cli): make provider picker searchable by model
SivanCola Sep 3, 2026
50f2486
fix(cli): persist account route access transitions
SivanCola Sep 3, 2026
b41a7d7
chore(frontend): ratchet initial bundle budget
SivanCola Sep 3, 2026
e77ba0c
chore(frontend): bound initial bundle at measured size
SivanCola Sep 3, 2026
f43265c
chore(frontend): bound account locale payload
SivanCola Sep 3, 2026
a7c8061
chore(frontend): bound account startup payload
SivanCola Sep 3, 2026
f6e317c
fix(desktop): preserve removal validation and runtime safety
SivanCola Sep 3, 2026
2695619
refactor(cli): isolate provider account replay helpers
SivanCola Sep 3, 2026
d3682cb
fix(config): retarget default when disabling stale account
SivanCola Sep 3, 2026
e1995f8
fix(desktop): preserve legacy account family model aliases
SivanCola Sep 3, 2026
c361e1a
fix(desktop): respect explicit empty provider access
SivanCola Sep 3, 2026
931d864
fix(desktop): keep compatibility views outside settings monolith
SivanCola Sep 3, 2026
b2c1bc5
fix(desktop): clean compatibility helper imports
SivanCola Sep 3, 2026
eb485b5
fix(desktop): retain shared account credentials safely
SivanCola Sep 3, 2026
09c42da
fix(config): clear remaining lint findings
SivanCola Sep 3, 2026
5509b67
fix(cli): keep API key prompt out of CodeQL taint flow
SivanCola Sep 3, 2026
62cc611
feat(config): add unified curated provider selection model
SivanCola Sep 3, 2026
4b898b7
feat(selection): unify curated family account model switching
SivanCola Sep 3, 2026
83f5609
feat(selection): route account switches through canonical refs
SivanCola Sep 3, 2026
ebd423a
refactor(selection): keep compatibility refs out of public catalog
SivanCola Sep 3, 2026
ddf77d5
fix(selection): make family defaults and route errors deterministic
SivanCola Sep 3, 2026
0a86cd9
refactor(selection): finalize canonical account switch path
SivanCola Sep 3, 2026
2613bfd
fix(config): complete atomic account change rollback
SivanCola Sep 3, 2026
bcad15e
refactor(selection): centralize account mutation semantics
SivanCola Sep 3, 2026
f942d7c
docs(selection): document canonical family account model refs
SivanCola Sep 3, 2026
ca8deeb
refactor(config): route retirement through atomic account patches
SivanCola Sep 3, 2026
44403c4
feat(config): add unified curated provider selection model
SivanCola Sep 3, 2026
b531b9e
feat(selection): unify curated family account model switching
SivanCola Sep 3, 2026
ea5170f
feat(selection): route account switches through canonical refs
SivanCola Sep 3, 2026
28691e6
refactor(selection): keep compatibility refs out of public catalog
SivanCola Sep 3, 2026
abe9ee5
fix(selection): make family defaults and route errors deterministic
SivanCola Sep 3, 2026
2acc125
refactor(config): centralize account lifecycle mutations
SivanCola Sep 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions desktop/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -9208,10 +9208,14 @@ func removeServerOrder(order []string, name string) []string {
// ModelInfo is one (provider, model) the bottom switcher can pick. Ref ("provider/
// model") is what SetModel takes; Provider/Model are for display.
type ModelInfo struct {
Ref string `json:"ref"`
Provider string `json:"provider"`
Model string `json:"model"`
Current bool `json:"current"`
Ref string `json:"ref"`
Provider string `json:"provider"`
Model string `json:"model"`
Current bool `json:"current"`
ProviderGroup string `json:"providerGroup,omitempty"`
AccountID string `json:"accountId,omitempty"`
AccountLabel string `json:"accountLabel,omitempty"`
AccountDefault bool `json:"accountDefault,omitempty"`
}

type EffortInfo struct {
Expand Down Expand Up @@ -9624,7 +9628,7 @@ func (a *App) SetModelForTab(tabID, name string) (retErr error) {
if err != nil {
return err
}
entry, ok := cfg.ResolveModel(name)
entry, ok, name := resolveDesktopModelSelection(cfg, name)
pluginRef := false
if !ok {
// Plugin-namespaced refs belong to extension sidecars: validate them
Expand Down
28 changes: 10 additions & 18 deletions desktop/frontend/scripts/check-bundle-budget.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -180,14 +180,10 @@ console.log("\nbundle budgets");
// move the combined path to 462.2 KiB. Local spectator reclaim adds the
// desktop-vs-remote command branch. Sticky Context's session-scoped file chips
// bring the merged stable path to 462.587 KiB. Windows' embedded build metadata
// lands just above the rounded 462.6 KiB boundary; retain one cross-platform
// decimal step without widening any chunk or raw gate.
// Reading the applied item-list transform (instead of the remembered offset)
// keeps the reader/anchor visual guards from compounding under reduced-motion
// WebView2; the merged path measures 462.827 KiB. Retain one decimal step.
// Generation-bound native-thumb transactions and the rebased custom-scrollbar
// drag add 0.3 KiB gzip; the merged path measures 463.102 KiB.
const initialJSBudgetKiB = 463.2;
// and generic provider-account controls land at 463.414 KiB gzip. Keep this
// narrow one-decimal ratchet scoped to the initial shell; all other chunk
// budgets remain unchanged.
const initialJSBudgetKiB = 463.5;
assertBudget("initial JavaScript gzip", initialJSGzip, initialJSBudgetKiB * 1024);
assertBudget("largest initial JavaScript chunk gzip", largestInitialJS, 280 * 1024);
// Render-blocking CSS is intentionally absent: styles.css loads deferred via
Expand Down Expand Up @@ -251,7 +247,9 @@ for (const path of localeChunks) {
// reclaim), while Sticky Context adds file-state and limit diagnostics. The
// merged stable chunks measure 60.395 KiB zh and 61.232 KiB zh-TW; retain
// only the next one-decimal ceiling for each dialect.
const budget = name.startsWith("zh-TW-") ? 61.3 * 1024 : 60.4 * 1024;
// Account retirement/restore copy adds a bounded locale payload increase.
// Keep per-locale ceilings narrow and leave all other bundle budgets intact.
const budget = name.startsWith("zh-TW-") ? 61.4 * 1024 : 60.6 * 1024;
assertBudget(`${name} gzip`, gzipBytes(path), budget);
}

Expand Down Expand Up @@ -329,14 +327,8 @@ const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS]
// path 2465.105 KiB raw; the merged test channel measures 2464.979 KiB.
// Session takeover banners and #9703/#9711's provisional-selection handoff
// combine with Sticky Context's pinned-file state at 2469.125 KiB raw on the
// merged stable path. Retain only the next one-decimal ceiling.
// The passive reader-anchor lease for delayed WebView2 range commits measures
// 2469.347 KiB raw (+0.222 KiB, +0.009%). Retain only the next one-decimal
// ceiling; gzip and largest-chunk budgets remain unchanged.
// Reading the applied item-list transform for the reader/anchor visual guards
// adds 0.5 KiB raw on top; the merged path measures 2469.815 KiB.
// The scrollbar generation fence and drag rebase add 1.1 KiB raw; the merged
// path measures 2470.932 KiB.
const rawInitialBudgetKiB = 2_471.0;
// merged stable path. Account route controls and canonical selection payload
// add 4.25 KiB raw to the initial graph; retain a narrow one-decimal ceiling.
const rawInitialBudgetKiB = 2_473.5;
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, rawInitialBudgetKiB * 1024);
assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024);
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ function renderCard(
<LocaleProvider>
<ProviderAccessCard
group={providerGroup}
apply={async () => undefined}
busy={false}
fetching={false}
editing={null}
Expand Down
97 changes: 65 additions & 32 deletions desktop/frontend/src/components/ModelSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,43 +80,63 @@ export function ModelSwitcher({
const keyword = query.trim().toLowerCase();
const filtered = useMemo(
() => keyword
? models.filter((m) => m.model.toLowerCase().includes(keyword) || m.provider.toLowerCase().includes(keyword))
? models.filter((m) => m.model.toLowerCase().includes(keyword) || m.provider.toLowerCase().includes(keyword) || (m.accountLabel ?? "").toLowerCase().includes(keyword))
: models,
[models, keyword],
);

// Group by provider, with the current model's group first
const groups = useMemo(() => {
const map = new Map<string, ModelInfo[]>();
let currentProvider = "";
const map = new Map<string, { label: string; accounts: Map<string, { label: string; items: ModelInfo[] }> }>();
let currentGroup = "";
for (const m of filtered) {
if (m.current) currentProvider = m.provider;
const list = map.get(m.provider);
if (list) list.push(m);
else map.set(m.provider, [m]);
const groupID = m.providerGroup || m.provider;
const groupLabel = providerLabel(groupID, t);
const accountID = m.accountId || m.provider;
const accountLabel = m.accountLabel || m.provider;
if (m.current) currentGroup = groupID;
let group = map.get(groupID);
if (!group) {
group = { label: groupLabel, accounts: new Map() };
map.set(groupID, group);
}
let account = group.accounts.get(accountID);
if (!account) {
account = { label: accountLabel, items: [] };
group.accounts.set(accountID, account);
}
account.items.push(m);
}
return [...map.entries()]
.sort(([a], [b]) => {
if (a === currentProvider) return -1;
if (b === currentProvider) return 1;
return providerLabel(a, t).localeCompare(providerLabel(b, t));
if (a === currentGroup) return -1;
if (b === currentGroup) return 1;
return (map.get(a)?.label ?? a).localeCompare(map.get(b)?.label ?? b);
})
.map(([provider, items]) => ({
provider,
label: providerLabel(provider, t),
items,
.map(([id, group]) => ({
id,
label: group.label,
accounts: [...group.accounts.entries()].map(([accountId, account]) => ({
id: accountId,
label: account.label,
items: account.items,
})),
}));
}, [filtered, t]);

const currentProvider = useMemo(() => {
const cur = models.find((m) => m.current) ?? models.find((m) => m.model === label || m.ref === label);
return cur ? providerLabel(cur.provider, t) : null;
if (!cur) return null;
const group = providerLabel(cur.providerGroup || cur.provider, t);
return cur.accountLabel ? `${group} · ${cur.accountLabel}` : group;
}, [label, models, t]);
const triggerLabel = currentProvider ? `${label} · ${currentProvider}` : label;

const pick = (model: ModelInfo) => {
setOpen(false);
const pendingKey = tabId ?? "";
const selectionRef = model.providerGroup && model.accountId
? `${model.providerGroup}/${model.accountId}/${model.model}`
: model.ref;
const pendingPickCount = pendingPickCountByTabRef.current.get(pendingKey) ?? 0;
// A catalog refresh can still report the outgoing model as current while
// an earlier switch is rebuilding. In that window, selecting it again is
Expand Down Expand Up @@ -150,7 +170,7 @@ export function ModelSwitcher({
void loadModelsForTab(tabId);
};
try {
void Promise.resolve(onPick(model.ref)).then(
void Promise.resolve(onPick(selectionRef)).then(
(switched) => settlePick(switched),
() => settlePick(false),
);
Expand Down Expand Up @@ -202,22 +222,29 @@ export function ModelSwitcher({
{models.length === 0 && <div className="modelsw__empty">{t("status.noModels")}</div>}
{models.length > 0 && filtered.length === 0 && query && <div className="modelsw__empty">{t("modelSwitcher.noMatches")}</div>}
{groups.map((g) => (
<div key={g.provider} role="group" aria-label={g.label} className="modelsw__group">
<div key={g.id} role="group" aria-label={g.label} className="modelsw__group">
<div className="modelsw__group-label" role="presentation"><Brain size={11} />{g.label}</div>
{g.items.map((m) => (
<button
key={m.ref}
type="button"
role="option"
aria-selected={m.current}
className={`modelsw__item ${m.current ? "modelsw__item--current" : ""}`}
onClick={() => pick(m)}
>
<span className="modelsw__copy">
<span className="modelsw__model">{m.model}</span>
</span>
{m.current && <Check size={13} className="modelsw__check" />}
</button>
{g.accounts.map((account) => (
<div key={account.id} role="group" aria-label={account.label}>
{account.label && account.label !== g.label && (
<div className="modelsw__group-label" role="presentation">{account.label}</div>
)}
{account.items.map((m) => (
<button
key={m.ref}
type="button"
role="option"
aria-selected={m.current}
className={`modelsw__item ${m.current ? "modelsw__item--current" : ""}`}
onClick={() => pick(m)}
>
<span className="modelsw__copy">
<span className="modelsw__model">{m.model}</span>
</span>
{m.current && <Check size={13} className="modelsw__check" />}
</button>
))}
</div>
))}
</div>
))}
Expand All @@ -232,6 +259,10 @@ export function normalizeModelInfo(model: ModelInfo): ModelInfo {
...model,
provider: String(model.provider ?? ""),
model: String(model.model ?? ""),
providerGroup: String(model.providerGroup ?? ""),
accountId: String(model.accountId ?? ""),
accountLabel: String(model.accountLabel ?? ""),
accountDefault: Boolean(model.accountDefault),
};
}

Expand All @@ -241,6 +272,8 @@ function providerLabel(provider: string, t: ReturnType<typeof useT>): string {
case "deepseek-flash":
case "deepseek-pro":
return t("settings.providerLabel.deepseek");
case "opencode-go":
return t("settings.providerLabel.opencodeGo");
default:
return provider;
}
Expand Down
Loading
Loading