Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
21 changes: 21 additions & 0 deletions docs-site/src/content/docs/guides/model-ordering.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,24 @@ On **Models**, choose **Default**, **A–Z by model**, **Group by provider**, or
The controls use `GET/PUT /api/subagent-models`: `chosen` and `available` retain saved roster choices, including disabled or missing models; `pickerAvailable` contains only eligible routed catalog ids. The Models page sends `pickerOrder` and `pickerOrderMode`, never `models`. Roster-only saves preserve picker settings. Invalid combined updates and failed persistence leave the previous picker/roster state intact.

Routed-only presets keep the existing featured/native priority bands. They affect the Codex catalog and Claude discovery's routed groups; Claude's native prefix and explicit Desktop profile/alias ownership remain unchanged. OpenCodex guidance ranks and configured fallback settings are preserved, but native Codex's advertised five and recommended default can change with display priority. Saving does not restart clients; a catalog refresh may remain pending, and clients holding an old catalog may need reopening.


### Custom routed order

Choose **Custom order** on Models to load a fresh routed snapshot. Drag a movable row before
another row, or use its Up/Down buttons, then **Save draft**. Featured routed rows stay at the
front in their configured rank and cannot move. Native rows are not shown; this is not a preview
of the complete native picker. Surviving saved rows keep their relative order and new candidates
follow the current candidate list. Every save sends the complete routed list, without changing
the featured roster.

An order containing bare native ids remains protected until you explicitly apply a routed preset
or Default. Selecting a different option alone does not replace it. Unknown featured state blocks
editing. Before saving, the editor checks a fresh snapshot; changes preserve your draft and block
saving until **Reload and discard draft** loads current settings. Request failures retain the
draft. Accepted saves can still have a pending catalog refresh; reload before editing again.

The editor also requires an unambiguous model identity for every routed candidate. If the model
catalog is incomplete, refresh the Models page before editing; reloading picker settings alone
cannot restore missing catalog identities. Featured choices are matched exactly without trimming;
duplicate choices use their last configured position, and canonical ids take precedence over raw ids.
191 changes: 191 additions & 0 deletions gui/src/components/ModelPickerOrderEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { useCallback, useEffect, useEffectEvent, useLayoutEffect, useRef, useState } from "react";
import { createBoundedFetch, type BoundedFetch } from "../bounded-fetch";
import { readJsonOrThrow } from "../fetch-json";
import { IconArrowDown, IconArrowUp, IconGrip } from "../icons";
import { useT, type TKey } from "../i18n/shared";
import {
customPickerRows, isPickerOrderSaved, isPickerOrderSettings, movePickerBefore,
pickerSnapshotSignature, stepPickerOrder, type PickerModelIdentity, type PickerOrderSaved,
} from "../model-picker-order";

type Receipt = PickerOrderSaved & { catalogRefresh?: unknown };
type Snapshot = { signature: string; identities: string; order: string[]; fixed: string[] };
const DRAG_TYPE = "application/x-ocx-picker-order";
let dragSequence = 0;
/** Local drag identity, not a security token. Like newClientId, supports LAN HTTP. */
function newDragToken(): string {
const sequence = ++dragSequence;
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
try { return `${sequence}:${crypto.randomUUID()}`; }
catch { /* Some browsers expose randomUUID but reject it outside secure contexts. */ }
}
return `picker-${Date.now().toString(36)}-${sequence}`;
}

export default function ModelPickerOrderEditor({ apiBase, active, identities, onAccepted, onBusyChange }: {
apiBase: string; active: boolean; identities: readonly PickerModelIdentity[];
onAccepted: (receipt: Receipt) => void; onBusyChange: (busy: boolean) => void;
}) {
const t = useT();
const [snapshot, setSnapshot] = useState<Snapshot | null>(null);
const [draft, setDraft] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
const [blocked, setBlocked] = useState<TKey | null>(null);
const [error, setError] = useState(false);
const [announcement, setAnnouncement] = useState("");
const [dragging, setDragging] = useState<string | null>(null);
const [over, setOver] = useState<string | null>(null);
const lifetime = useRef({
generation: 0,
flight: null as BoundedFetch | null,
drag: null as { id: string; token: string } | null,
});
const [activation, setActivation] = useState({ apiBase, active, onBusyChange });
const identitySignature = JSON.stringify(identities.map(({ provider, id, namespaced }) => [provider, id, namespaced]));
const latestIdentitySignature = useRef(identitySignature);
useLayoutEffect(() => { latestIdentitySignature.current = identitySignature; }, [identitySignature]);
const identityChanged = snapshot !== null && snapshot.identities !== identitySignature;
const disabled = !active || busy || !snapshot || blocked !== null || identityChanged;
const dirty = snapshot !== null && JSON.stringify(draft) !== JSON.stringify(snapshot.order);
const clearDrag = useCallback(() => { lifetime.current.drag = null; setDragging(null); setOver(null); }, []);

// Reconcile before committing children, like the existing display-name dialog.
if (activation.apiBase !== apiBase || activation.active !== active || activation.onBusyChange !== onBusyChange) {
setActivation({ apiBase, active, onBusyChange });
setSnapshot(null); setDraft([]); setBlocked(null); setError(false); setBusy(false);
}
const [dragContext, setDragContext] = useState({ disabled, snapshot, identitySignature });
if (dragContext.disabled !== disabled || dragContext.snapshot !== snapshot || dragContext.identitySignature !== identitySignature) {
setDragContext({ disabled, snapshot, identitySignature });
setDragging(null); setOver(null);
}

// Capture the stable holder, but always abort its CURRENT flight during cleanup.
useLayoutEffect(() => {
const holder = lifetime.current;
holder.generation++;
return () => {
holder.generation++;
holder.flight?.controller.abort(); holder.flight?.clear(); holder.flight = null;
holder.drag = null; onBusyChange(false);
};
}, [apiBase, active, onBusyChange]);
useLayoutEffect(() => { lifetime.current.drag = null; }, [disabled, snapshot, identitySignature]);

const run = async (save: boolean) => {
if (!active || lifetime.current.flight || (save && (disabled || !dirty))) return;
const owner = lifetime.current.generation, bounded = createBoundedFetch(15_000);
lifetime.current.flight = bounded; setBusy(true); onBusyChange(true); setError(false); clearDrag();
const owns = () => lifetime.current.generation === owner && lifetime.current.flight === bounded;
const current = () => owns() && !bounded.signal.aborted
&& latestIdentitySignature.current === identitySignature;
try {
const response = await fetch(`${apiBase}/api/subagent-models`, { signal: bounded.signal });
if (!current()) return;
const settings = await readJsonOrThrow<unknown>(response);
if (!current()) return;
if (!isPickerOrderSettings(settings)) throw new Error("Invalid picker settings");
const signature = pickerSnapshotSignature(apiBase, owner, settings);
if (save && (!snapshot || signature !== snapshot.signature || identitySignature !== snapshot.identities)) {
setBlocked("models.pickerOrder.changed"); return;
}
const rows = customPickerRows(settings, identities);
if (!rows) {
setBlocked(settings.pickerOrder.some(id => !id.includes("/"))
? "models.pickerOrder.nativeLocked" : settings.chosen === undefined
? "models.pickerOrder.unknownChosen" : "models.pickerOrder.catalogRequired");
return;
}
if (!save) {
setSnapshot({ ...rows, signature, identities: identitySignature }); setDraft(rows.order);
setBlocked(null); setAnnouncement(""); return;
}
const result = await fetch(`${apiBase}/api/subagent-models`, {
method: "PUT", headers: { "Content-Type": "application/json" }, signal: bounded.signal,
body: JSON.stringify({ pickerOrder: draft, pickerOrderMode: null }),
});
if (!current()) return;
const receipt = await readJsonOrThrow<unknown>(result);
if (!current()) return;
if (!isPickerOrderSaved(receipt) || !("ok" in receipt) || receipt.ok !== true) throw new Error("Invalid picker receipt");
setDraft(receipt.pickerOrder); setBlocked("models.pickerOrder.savedReload");
onAccepted({ pickerOrder: receipt.pickerOrder, pickerOrderMode: receipt.pickerOrderMode,
catalogRefresh: "catalogRefresh" in receipt ? receipt.catalogRefresh : undefined });
} catch {
if (owns() && latestIdentitySignature.current === identitySignature) setError(true);
// Current-identity timeouts surface an error; stale identities retain the draft silently.
} finally {
bounded.clear();
if (owns()) { lifetime.current.flight = null; setBusy(false); onBusyChange(false); }
}
};
const enter = useEffectEvent(async () => {
const holder = lifetime.current, owner = holder.generation;
// Automatic startup is cancellable before issuing transport; event actions stay immediate.
await Promise.resolve();
if (active && holder.generation === owner) void run(false);
});
useEffect(() => { if (active) void enter(); }, [apiBase, active, onBusyChange]);

const move = (id: string, next: string[]) => {
if (disabled) return;
setDraft(next);
setAnnouncement(t("models.pickerOrder.position", { model: id, position: next.indexOf(id) + 1, total: next.length }));
clearDrag();
};
const movable = (id: string) => !disabled && draft.includes(id) && !snapshot?.fixed.includes(id);
return <section className="picker-order-editor" aria-label={t("models.pickerOrder.custom")} aria-busy={busy}>
<p className="muted text-label">{t("models.pickerOrder.editorHint")}</p>
{(blocked || identityChanged) && <p role="alert">{t(blocked ?? "models.pickerOrder.changed")}</p>}
{error && <p role="alert">{t("models.pickerOrder.requestFailed")}</p>}
{snapshot && draft.length === 0 && <p>{t("models.pickerOrder.empty")}</p>}
<ol className="picker-order-list">
{draft.map((id, index) => {
const fixed = snapshot?.fixed.includes(id) === true;
return <li key={id} className={`picker-order-row${dragging === id ? " cwi-target-row--dragging" : ""}${over === id ? " cwi-target-row--drop" : ""}`}
onDragOver={event => {
if (!lifetime.current.drag || lifetime.current.drag.id === id || !movable(lifetime.current.drag.id) || !movable(id)
|| !event.dataTransfer.types.includes(DRAG_TYPE)) return;
event.preventDefault(); event.dataTransfer.dropEffect = "move"; setOver(id);
}}
onDragLeave={() => setOver(null)}
onDrop={event => {
const source = lifetime.current.drag;
if (source && source.id !== id && source.token === event.dataTransfer.getData(DRAG_TYPE) && movable(source.id) && movable(id)) {
event.preventDefault(); move(source.id, movePickerBefore(draft, source.id, id, snapshot?.fixed ?? []));
}
clearDrag();
}} onDragEnd={clearDrag}>
<button type="button" className="cwi-target-grip" disabled={disabled || fixed} draggable={!disabled && !fixed}
aria-label={t("models.pickerOrder.dragModel", { model: id })}
onDragStart={event => {
if (!movable(id)) { event.preventDefault(); return; }
const token = newDragToken(); lifetime.current.drag = { id, token }; setDragging(id);
event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData(DRAG_TYPE, token);
}}><IconGrip width={14} height={14} aria-hidden="true" /></button>
<code className="picker-order-name">{id}</code>
{fixed && <span className="muted text-caption">{t("models.pickerOrder.featured")}</span>}
<span className="picker-order-actions">
<button type="button" className="btn btn-ghost btn-sm"
disabled={disabled || fixed || index === 0 || snapshot?.fixed.includes(draft[index - 1]!)}
aria-label={t("models.pickerOrder.upModel", { model: id })}
onClick={() => move(id, stepPickerOrder(draft, id, -1, snapshot?.fixed ?? []))}>
<IconArrowUp width={14} height={14} aria-hidden="true" /></button>
<button type="button" className="btn btn-ghost btn-sm"
disabled={disabled || fixed || index === draft.length - 1 || snapshot?.fixed.includes(draft[index + 1]!)}
aria-label={t("models.pickerOrder.downModel", { model: id })}
onClick={() => move(id, stepPickerOrder(draft, id, 1, snapshot?.fixed ?? []))}>
<IconArrowDown width={14} height={14} aria-hidden="true" /></button>
</span>
</li>;
})}
</ol>
<p role="status" aria-live="polite">{announcement}</p>
<div className="row">
<button type="button" className="btn btn-primary btn-sm" disabled={disabled || !dirty} onClick={() => void run(true)}>
{t(busy ? "models.pickerOrder.applying" : "models.pickerOrder.saveDraft")}</button>
<button type="button" className="btn btn-ghost btn-sm" disabled={!active || busy} onClick={() => void run(false)}>
{t("models.pickerOrder.reloadDraft")}</button>
</div>
</section>;
}
15 changes: 15 additions & 0 deletions gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2625,4 +2625,19 @@ export const de: Record<TKey, string> = {
"usage.range.invalid": "Geben Sie gültige lokale Daten und Uhrzeiten ab 1970-01-01 UTC ein.",
"usage.range.reversed": "Das Ende muss auf oder nach dem Beginn liegen.",
"usage.range.applied": "Ausgewählter Zeitraum: {start} – {end} (beide Grenzen eingeschlossen).",
"models.pickerOrder.editorHint": "Routingsmodelle neu ordnen und den Entwurf speichern. Hervorgehobene Zeilen sind fest; native Modelle werden nicht angezeigt.",
"models.pickerOrder.nativeLocked": "Diese Reihenfolge enthält native Modelle. Vor der Bearbeitung eine Routing-Vorgabe oder Standard anwenden.",
"models.pickerOrder.unknownChosen": "Hervorgehobene Modelle sind unbekannt. Vor der Bearbeitung neu laden.",
"models.pickerOrder.changed": "Die Einstellungen haben sich geändert. Der Entwurf bleibt erhalten; erneutes Laden verwirft ihn und lädt die aktuellen Einstellungen.",
"models.pickerOrder.savedReload": "Reihenfolge gespeichert. Vor weiterer Bearbeitung aktuelle Einstellungen laden.",
"models.pickerOrder.requestFailed": "Anfrage fehlgeschlagen. Der Entwurf bleibt erhalten; erneut versuchen oder neu laden.",
"models.pickerOrder.empty": "Keine Routingmodelle verfügbar.",
"models.pickerOrder.dragModel": "{model} ziehen",
"models.pickerOrder.featured": "Hervorgehoben",
"models.pickerOrder.upModel": "{model} nach oben verschieben",
"models.pickerOrder.downModel": "{model} nach unten verschieben",
"models.pickerOrder.position": "{model}: Position {position} von {total}",
"models.pickerOrder.saveDraft": "Entwurf speichern",
"models.pickerOrder.reloadDraft": "Neu laden und Entwurf verwerfen",
"models.pickerOrder.catalogRequired": "Modellidentitäten fehlen oder sind mehrdeutig. Laden Sie die Modellseite neu, um den Katalog vor der Bearbeitung zu aktualisieren.",
};
15 changes: 15 additions & 0 deletions gui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2659,6 +2659,21 @@ export const en = {
"usage.range.invalid": "Enter valid local dates and times, on or after 1970-01-01 UTC.",
"usage.range.reversed": "The end must be at or after the start.",
"usage.range.applied": "Selected interval: {start} – {end} (both inclusive).",
"models.pickerOrder.editorHint": "Reorder routed models, then save your draft. Featured rows are fixed; native models are not shown.",
"models.pickerOrder.nativeLocked": "This saved order includes native models. Apply a routed preset or Default before editing Custom.",
"models.pickerOrder.unknownChosen": "Featured choices are unknown. Reload before editing.",
"models.pickerOrder.changed": "Picker settings changed. Your draft is kept; reload to discard it and use current settings.",
"models.pickerOrder.savedReload": "Order saved. Reload current settings before editing again.",
"models.pickerOrder.requestFailed": "Request failed. Your draft is kept; retry or reload.",
"models.pickerOrder.empty": "No routed models are available.",
"models.pickerOrder.dragModel": "Drag {model}",
"models.pickerOrder.featured": "Featured",
"models.pickerOrder.upModel": "Move {model} up",
"models.pickerOrder.downModel": "Move {model} down",
"models.pickerOrder.position": "{model}: position {position} of {total}",
"models.pickerOrder.saveDraft": "Save draft",
"models.pickerOrder.reloadDraft": "Reload and discard draft",
"models.pickerOrder.catalogRequired": "Model identities are missing or ambiguous. Reload the Models page to refresh its catalog before editing Custom.",
} as const;

export type TKey = keyof typeof en;
15 changes: 15 additions & 0 deletions gui/src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2612,4 +2612,19 @@ export const fr: Record<TKey, string> = {
"usage.range.invalid": "Saisissez des dates et heures locales valides à partir du 1970-01-01 UTC.",
"usage.range.reversed": "La fin doit être égale ou postérieure au début.",
"usage.range.applied": "Période sélectionnée : {start} – {end} (bornes incluses).",
"models.pickerOrder.editorHint": "Réordonnez les modèles routés, puis enregistrez le brouillon. Les lignes mises en avant sont fixes ; les modèles natifs ne sont pas affichés.",
"models.pickerOrder.nativeLocked": "Cet ordre contient des modèles natifs. Appliquez un préréglage de routage ou Par défaut avant de le personnaliser.",
"models.pickerOrder.unknownChosen": "Les modèles mis en avant sont inconnus. Rechargez avant de modifier.",
"models.pickerOrder.changed": "Les paramètres ont changé. Le brouillon est conservé ; rechargez pour le supprimer et utiliser les paramètres actuels.",
"models.pickerOrder.savedReload": "Ordre enregistré. Rechargez les paramètres actuels avant de modifier à nouveau.",
"models.pickerOrder.requestFailed": "Échec de la requête. Le brouillon est conservé ; réessayez ou rechargez.",
"models.pickerOrder.empty": "Aucun modèle routé disponible.",
"models.pickerOrder.dragModel": "Faire glisser {model}",
"models.pickerOrder.featured": "Mis en avant",
"models.pickerOrder.upModel": "Monter {model}",
"models.pickerOrder.downModel": "Descendre {model}",
"models.pickerOrder.position": "{model} : position {position} sur {total}",
"models.pickerOrder.saveDraft": "Enregistrer le brouillon",
"models.pickerOrder.reloadDraft": "Recharger et supprimer le brouillon",
"models.pickerOrder.catalogRequired": "Les identités des modèles sont manquantes ou ambiguës. Rechargez la page Modèles pour actualiser le catalogue avant de personnaliser l’ordre.",
};
Loading
Loading