From 6391b7cc4d26eda8a2e1fdf5aca46e946e219e14 Mon Sep 17 00:00:00 2001 From: Zig Zag Date: Wed, 26 Aug 2026 23:14:41 +0530 Subject: [PATCH 01/10] feat(gui): validate discovered model display names --- gui/src/i18n/de.ts | 19 +++++++++++++++++++ gui/src/i18n/en.ts | 19 +++++++++++++++++++ gui/src/i18n/fr.ts | 19 +++++++++++++++++++ gui/src/i18n/ja.ts | 19 +++++++++++++++++++ gui/src/i18n/ko.ts | 19 +++++++++++++++++++ gui/src/i18n/ru.ts | 19 +++++++++++++++++++ gui/src/i18n/tr.ts | 19 +++++++++++++++++++ gui/src/i18n/zh-TW.ts | 19 +++++++++++++++++++ gui/src/i18n/zh.ts | 19 +++++++++++++++++++ gui/src/pages/models-shared.ts | 16 +++++++++++++++- gui/tests/models-display-name-editor.test.tsx | 16 ++++++++++++++++ 11 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 gui/tests/models-display-name-editor.test.tsx diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 9086c9bf42..a2e305516c 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2548,4 +2548,23 @@ export const de: Record = { "integrations.cursor.colReasoning": "Reasoning-Aufwand", "integrations.cursor.colContext": "Kontext", "integrations.cursor.guide": "Anleitung zu Cursor Private Inference öffnen", + "models.displayNameAction": "Name", + "models.displayNameActionLabel": "Anzeigenamen für {model} bearbeiten", + "models.displayNameTitle": "Anzeigename", + "models.displayNameModelId": "Modell-ID", + "models.displayNameCurrent": "Aktueller Name", + "models.displayNameSourceOperator": "Ihr Name", + "models.displayNameSourceProvider": "Anbietername", + "models.displayNameSourceFallback": "Modell-ID als Ersatz", + "models.displayNameField": "Anzeigename", + "models.displayNamePlaceholder": "z. B. Grok 4.6", + "models.displayNameHelp": "Ändert nur die Anzeige. Das Routing bleibt {model}.", + "models.displayNameReset": "Name zurücksetzen", + "models.displayNameSaved": "Anzeigename gespeichert", + "models.displayNameResetDone": "Anzeigename zurückgesetzt", + "models.displayNameSaveFailed": "Anzeigename konnte nicht gespeichert werden", + "models.displayNameRequired": "Geben Sie einen Anzeigenamen ein oder verwenden Sie Name zurücksetzen.", + "models.displayNameTooLong": "Der Anzeigename darf höchstens 128 Zeichen lang sein.", + "models.displayNameNoSlash": "Der Anzeigename darf kein / enthalten.", + "models.displayNameNoControl": "Der Anzeigename darf keine Steuerzeichen enthalten.", }; diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 0d2f1d05d4..a4b87e3e9f 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2582,6 +2582,25 @@ export const en = { "usage.scope.machine": "This machine", "usage.scope.hub": "Hub-wide", "usage.hubOffline": "Hub usage is unavailable. Local usage was not substituted.", + "models.displayNameAction": "Name", + "models.displayNameActionLabel": "Edit friendly name for {model}", + "models.displayNameTitle": "Friendly name", + "models.displayNameModelId": "Model ID", + "models.displayNameCurrent": "Current name", + "models.displayNameSourceOperator": "Your name", + "models.displayNameSourceProvider": "Provider name", + "models.displayNameSourceFallback": "Model ID fallback", + "models.displayNameField": "Friendly name", + "models.displayNamePlaceholder": "e.g. Grok 4.6", + "models.displayNameHelp": "Changes presentation only. Routing remains {model}.", + "models.displayNameReset": "Reset name", + "models.displayNameSaved": "Display name saved", + "models.displayNameResetDone": "Display name reset", + "models.displayNameSaveFailed": "Failed to save display name", + "models.displayNameRequired": "Enter a friendly name, or use Reset name.", + "models.displayNameTooLong": "Friendly name must be 128 characters or fewer.", + "models.displayNameNoSlash": "Friendly name cannot contain /.", + "models.displayNameNoControl": "Friendly name cannot contain control characters.", } as const; export type TKey = keyof typeof en; diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index b6eb03f0b0..668c7fd091 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2535,4 +2535,23 @@ export const fr: Record = { "integrations.cursor.colReasoning": "Raisonnement", "integrations.cursor.colContext": "Contexte", "integrations.cursor.guide": "Ouvrir le guide de Cursor Private Inference", + "models.displayNameAction": "Nom", + "models.displayNameActionLabel": "Modifier le nom convivial de {model}", + "models.displayNameTitle": "Nom convivial", + "models.displayNameModelId": "ID du modèle", + "models.displayNameCurrent": "Nom actuel", + "models.displayNameSourceOperator": "Votre nom", + "models.displayNameSourceProvider": "Nom du fournisseur", + "models.displayNameSourceFallback": "ID du modèle par défaut", + "models.displayNameField": "Nom convivial", + "models.displayNamePlaceholder": "p. ex. Grok 4.6", + "models.displayNameHelp": "Modifie uniquement l’affichage. Le routage reste {model}.", + "models.displayNameReset": "Réinitialiser le nom", + "models.displayNameSaved": "Nom convivial enregistré", + "models.displayNameResetDone": "Nom convivial réinitialisé", + "models.displayNameSaveFailed": "Impossible d’enregistrer le nom convivial", + "models.displayNameRequired": "Saisissez un nom convivial ou utilisez Réinitialiser le nom.", + "models.displayNameTooLong": "Le nom convivial doit contenir au maximum 128 caractères.", + "models.displayNameNoSlash": "Le nom convivial ne peut pas contenir /.", + "models.displayNameNoControl": "Le nom convivial ne peut pas contenir de caractères de contrôle.", }; diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 4b16912324..10b3380a92 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2569,4 +2569,23 @@ export const ja: Record = { "integrations.cursor.colReasoning": "推論", "integrations.cursor.colContext": "コンテキスト", "integrations.cursor.guide": "Cursor Private Inference のガイドを開く", + "models.displayNameAction": "名前", + "models.displayNameActionLabel": "{model} の表示名を編集", + "models.displayNameTitle": "表示名", + "models.displayNameModelId": "モデル ID", + "models.displayNameCurrent": "現在の名前", + "models.displayNameSourceOperator": "設定した名前", + "models.displayNameSourceProvider": "プロバイダー名", + "models.displayNameSourceFallback": "モデル ID の既定値", + "models.displayNameField": "表示名", + "models.displayNamePlaceholder": "例: Grok 4.6", + "models.displayNameHelp": "表示だけを変更します。ルーティングは {model} のままです。", + "models.displayNameReset": "名前をリセット", + "models.displayNameSaved": "表示名を保存しました", + "models.displayNameResetDone": "表示名をリセットしました", + "models.displayNameSaveFailed": "表示名を保存できませんでした", + "models.displayNameRequired": "表示名を入力するか、名前をリセットしてください。", + "models.displayNameTooLong": "表示名は 128 文字以内にしてください。", + "models.displayNameNoSlash": "表示名に / は使用できません。", + "models.displayNameNoControl": "表示名に制御文字は使用できません。", }; diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index a87bf608e5..d2aefdfceb 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2570,4 +2570,23 @@ export const ko: Record = { "integrations.cursor.colReasoning": "추론", "integrations.cursor.colContext": "컨텍스트", "integrations.cursor.guide": "Cursor Private Inference 가이드 열기", + "models.displayNameAction": "이름", + "models.displayNameActionLabel": "{model}의 표시 이름 편집", + "models.displayNameTitle": "표시 이름", + "models.displayNameModelId": "모델 ID", + "models.displayNameCurrent": "현재 이름", + "models.displayNameSourceOperator": "사용자 이름", + "models.displayNameSourceProvider": "공급자 이름", + "models.displayNameSourceFallback": "모델 ID 기본값", + "models.displayNameField": "표시 이름", + "models.displayNamePlaceholder": "예: Grok 4.6", + "models.displayNameHelp": "표시 방식만 변경합니다. 라우팅은 {model}로 유지됩니다.", + "models.displayNameReset": "이름 초기화", + "models.displayNameSaved": "표시 이름이 저장되었습니다", + "models.displayNameResetDone": "표시 이름이 초기화되었습니다", + "models.displayNameSaveFailed": "표시 이름을 저장하지 못했습니다", + "models.displayNameRequired": "표시 이름을 입력하거나 이름 초기화를 사용하세요.", + "models.displayNameTooLong": "표시 이름은 128자 이하여야 합니다.", + "models.displayNameNoSlash": "표시 이름에 /를 사용할 수 없습니다.", + "models.displayNameNoControl": "표시 이름에 제어 문자를 사용할 수 없습니다.", }; diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 70eb364002..52eb0dcb87 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2571,4 +2571,23 @@ export const ru: Record = { "integrations.cursor.colReasoning": "Рассуждения", "integrations.cursor.colContext": "Контекст", "integrations.cursor.guide": "Открыть руководство по Cursor Private Inference", + "models.displayNameAction": "Имя", + "models.displayNameActionLabel": "Изменить понятное имя для {model}", + "models.displayNameTitle": "Понятное имя", + "models.displayNameModelId": "ID модели", + "models.displayNameCurrent": "Текущее имя", + "models.displayNameSourceOperator": "Ваше имя", + "models.displayNameSourceProvider": "Имя провайдера", + "models.displayNameSourceFallback": "ID модели по умолчанию", + "models.displayNameField": "Понятное имя", + "models.displayNamePlaceholder": "например, Grok 4.6", + "models.displayNameHelp": "Меняет только отображение. Маршрут остаётся {model}.", + "models.displayNameReset": "Сбросить имя", + "models.displayNameSaved": "Понятное имя сохранено", + "models.displayNameResetDone": "Понятное имя сброшено", + "models.displayNameSaveFailed": "Не удалось сохранить понятное имя", + "models.displayNameRequired": "Введите понятное имя или используйте Сбросить имя.", + "models.displayNameTooLong": "Понятное имя должно содержать не более 128 символов.", + "models.displayNameNoSlash": "Понятное имя не может содержать /.", + "models.displayNameNoControl": "Понятное имя не может содержать управляющие символы.", }; diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index ca233f452e..9e5fd8b1db 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2571,4 +2571,23 @@ export const tr: Record = { "integrations.cursor.colReasoning": "Akıl yürütme", "integrations.cursor.colContext": "Bağlam", "integrations.cursor.guide": "Cursor Private Inference kılavuzunu aç", + "models.displayNameAction": "Ad", + "models.displayNameActionLabel": "{model} için görünen adı düzenle", + "models.displayNameTitle": "Görünen ad", + "models.displayNameModelId": "Model kimliği", + "models.displayNameCurrent": "Geçerli ad", + "models.displayNameSourceOperator": "Sizin adınız", + "models.displayNameSourceProvider": "Sağlayıcı adı", + "models.displayNameSourceFallback": "Model kimliği varsayılanı", + "models.displayNameField": "Görünen ad", + "models.displayNamePlaceholder": "ör. Grok 4.6", + "models.displayNameHelp": "Yalnızca görünümü değiştirir. Yönlendirme {model} olarak kalır.", + "models.displayNameReset": "Adı sıfırla", + "models.displayNameSaved": "Görünen ad kaydedildi", + "models.displayNameResetDone": "Görünen ad sıfırlandı", + "models.displayNameSaveFailed": "Görünen ad kaydedilemedi", + "models.displayNameRequired": "Bir görünen ad girin veya Adı sıfırla seçeneğini kullanın.", + "models.displayNameTooLong": "Görünen ad en fazla 128 karakter olabilir.", + "models.displayNameNoSlash": "Görünen ad / içeremez.", + "models.displayNameNoControl": "Görünen ad denetim karakterleri içeremez.", }; diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 2b7e6ac6ba..fb30877150 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2533,4 +2533,23 @@ export const zhTW: Record = { "integrations.cursor.colReasoning": "推理", "integrations.cursor.colContext": "上下文", "integrations.cursor.guide": "開啟 Cursor Private Inference 指南", + "models.displayNameAction": "名稱", + "models.displayNameActionLabel": "編輯 {model} 的友善名稱", + "models.displayNameTitle": "友善名稱", + "models.displayNameModelId": "模型 ID", + "models.displayNameCurrent": "目前名稱", + "models.displayNameSourceOperator": "你的名稱", + "models.displayNameSourceProvider": "提供者名稱", + "models.displayNameSourceFallback": "模型 ID 預設值", + "models.displayNameField": "友善名稱", + "models.displayNamePlaceholder": "例如 Grok 4.6", + "models.displayNameHelp": "只變更顯示方式。路由仍為 {model}。", + "models.displayNameReset": "重設名稱", + "models.displayNameSaved": "友善名稱已儲存", + "models.displayNameResetDone": "友善名稱已重設", + "models.displayNameSaveFailed": "無法儲存友善名稱", + "models.displayNameRequired": "請輸入友善名稱,或使用重設名稱。", + "models.displayNameTooLong": "友善名稱不能超過 128 個字元。", + "models.displayNameNoSlash": "友善名稱不能包含 /。", + "models.displayNameNoControl": "友善名稱不能包含控制字元。", }; diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 42ac3941d4..3c1042099c 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2569,4 +2569,23 @@ export const zh: Record = { "integrations.cursor.colReasoning": "推理", "integrations.cursor.colContext": "上下文", "integrations.cursor.guide": "打开 Cursor Private Inference 指南", + "models.displayNameAction": "名称", + "models.displayNameActionLabel": "编辑 {model} 的友好名称", + "models.displayNameTitle": "友好名称", + "models.displayNameModelId": "模型 ID", + "models.displayNameCurrent": "当前名称", + "models.displayNameSourceOperator": "你的名称", + "models.displayNameSourceProvider": "提供商名称", + "models.displayNameSourceFallback": "模型 ID 默认值", + "models.displayNameField": "友好名称", + "models.displayNamePlaceholder": "例如 Grok 4.6", + "models.displayNameHelp": "仅更改显示方式。路由仍为 {model}。", + "models.displayNameReset": "重置名称", + "models.displayNameSaved": "友好名称已保存", + "models.displayNameResetDone": "友好名称已重置", + "models.displayNameSaveFailed": "无法保存友好名称", + "models.displayNameRequired": "请输入友好名称,或使用重置名称。", + "models.displayNameTooLong": "友好名称不能超过 128 个字符。", + "models.displayNameNoSlash": "友好名称不能包含 /。", + "models.displayNameNoControl": "友好名称不能包含控制字符。", }; diff --git a/gui/src/pages/models-shared.ts b/gui/src/pages/models-shared.ts index fdc487301c..fc01b9fb04 100644 --- a/gui/src/pages/models-shared.ts +++ b/gui/src/pages/models-shared.ts @@ -1,4 +1,4 @@ -import type { TFn } from "../i18n/shared"; +import type { TFn, TKey } from "../i18n/shared"; import type { ProviderDiscoverySummary } from "../models-groups"; import { modelVisible, type ProviderModelMap } from "../model-visibility"; import { formatNamespacedModelId } from "../provider-icons"; @@ -35,6 +35,8 @@ export interface ModelRow { custom?: boolean; customId?: string; displayName?: string; + displayNameOverride?: string; + displayNameSource?: "operator" | "provider" | "fallback"; inputModalities?: string[]; contextWindow?: number; contextCap?: number; @@ -43,6 +45,18 @@ export interface ModelRow { reasoningEfforts?: string[]; } +const DISPLAY_NAME_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/; + +/** Mirror the server display-name contract for immediate form feedback. */ +export function modelDisplayNameValidationKey(value: string): TKey | null { + const trimmed = value.trim(); + if (!trimmed) return "models.displayNameRequired"; + if (trimmed.length > 128) return "models.displayNameTooLong"; + if (trimmed.includes("/")) return "models.displayNameNoSlash"; + if (DISPLAY_NAME_CONTROL_CHARS.test(trimmed)) return "models.displayNameNoControl"; + return null; +} + /** * Reasoning-effort labels offered in the custom-model dialog. The full set of real * `reasoning_effort` values (none, minimal, low, medium, high, xhigh, max). Deliberately diff --git a/gui/tests/models-display-name-editor.test.tsx b/gui/tests/models-display-name-editor.test.tsx new file mode 100644 index 0000000000..d6602fc60f --- /dev/null +++ b/gui/tests/models-display-name-editor.test.tsx @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test"; +import { modelDisplayNameValidationKey } from "../src/pages/models-shared"; + +describe("discovered model display name validation", () => { + test("accepts a safe label at both ordinary and maximum length", () => { + expect(modelDisplayNameValidationKey("Grok 4.6")).toBeNull(); + expect(modelDisplayNameValidationKey("A".repeat(128))).toBeNull(); + }); + + test("rejects values that the management API cannot persist", () => { + expect(modelDisplayNameValidationKey(" ")).toBe("models.displayNameRequired"); + expect(modelDisplayNameValidationKey("Grok/4.6")).toBe("models.displayNameNoSlash"); + expect(modelDisplayNameValidationKey("Grok\n4.6")).toBe("models.displayNameNoControl"); + expect(modelDisplayNameValidationKey("A".repeat(129))).toBe("models.displayNameTooLong"); + }); +}); From dbec4c4e07614476326b70a27b159f976c303919 Mon Sep 17 00:00:00 2001 From: Zig Zag Date: Wed, 26 Aug 2026 23:18:46 +0530 Subject: [PATCH 02/10] feat(gui): add display name editor dialog --- gui/src/components/ModelDisplayNameDialog.tsx | 151 ++++++++++++++++++ gui/tests/models-display-name-editor.test.tsx | 140 +++++++++++++++- 2 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 gui/src/components/ModelDisplayNameDialog.tsx diff --git a/gui/src/components/ModelDisplayNameDialog.tsx b/gui/src/components/ModelDisplayNameDialog.tsx new file mode 100644 index 0000000000..4bd3c66d59 --- /dev/null +++ b/gui/src/components/ModelDisplayNameDialog.tsx @@ -0,0 +1,151 @@ +import { useEffect, useId, useRef, useState } from "react"; +import { useT, type TKey } from "../i18n/shared"; +import { + modelDisplayNameValidationKey, + type ModelRow, +} from "../pages/models-shared"; + +interface ModelDisplayNameDialogProps { + model: ModelRow; + saving: boolean; + requestError: string | null; + onSave: (displayName: string) => void; + onReset: () => void; + onClose: () => void; +} + +const SOURCE_LABEL_KEYS: Record, TKey> = { + operator: "models.displayNameSourceOperator", + provider: "models.displayNameSourceProvider", + fallback: "models.displayNameSourceFallback", +}; + +export default function ModelDisplayNameDialog({ + model, + saving, + requestError, + onSave, + onReset, + onClose, +}: ModelDisplayNameDialogProps) { + const t = useT(); + const dialogRef = useRef(null); + const inputRef = useRef(null); + const titleId = useId(); + const helpId = useId(); + const errorId = useId(); + const [draft, setDraft] = useState(model.displayNameOverride ?? ""); + const [validationKey, setValidationKey] = useState(null); + + useEffect(() => { + const dialog = dialogRef.current; + if (dialog && !dialog.open) dialog.showModal(); + inputRef.current?.focus(); + return () => { if (dialog?.open) dialog.close(); }; + }, []); + + const validationError = validationKey ? t(validationKey) : null; + const visibleError = validationError ?? requestError; + const sourceKey = model.displayNameSource + ? SOURCE_LABEL_KEYS[model.displayNameSource] + : "models.displayNameSourceFallback"; + + const requestClose = () => { + if (!saving) onClose(); + }; + + return ( + { + event.preventDefault(); + requestClose(); + }} + > + + + +
+ {t("models.displayNameModelId")} + {model.namespaced} +
+ +
+ {t("models.displayNameCurrent")} + {model.displayName ?? model.namespaced} + {t(sourceKey)} +
+ + + { + setDraft(event.target.value); + setValidationKey(null); + }} + /> +

+ {t("models.displayNameHelp", { model: model.namespaced })} +

+ {visibleError && ( + + )} + +
+ + + +
+ +
+ ); +} diff --git a/gui/tests/models-display-name-editor.test.tsx b/gui/tests/models-display-name-editor.test.tsx index d6602fc60f..64141832ac 100644 --- a/gui/tests/models-display-name-editor.test.tsx +++ b/gui/tests/models-display-name-editor.test.tsx @@ -1,4 +1,10 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, jest, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import ModelDisplayNameDialog from "../src/components/ModelDisplayNameDialog"; +import { LanguageProvider } from "../src/i18n/provider"; +import type { ModelRow } from "../src/pages/models-shared"; import { modelDisplayNameValidationKey } from "../src/pages/models-shared"; describe("discovered model display name validation", () => { @@ -14,3 +20,135 @@ describe("discovered model display name validation", () => { expect(modelDisplayNameValidationKey("A".repeat(129))).toBe("models.displayNameTooLong"); }); }); + +describe("discovered model display name dialog", () => { + const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; + let previousGlobals: Record<(typeof globals)[number], PropertyDescriptor | undefined>; + let testWindow: Window; + let container: HTMLElement; + let root: Root | null; + + const model: ModelRow = { + provider: "xai-demo", + id: "grok-4.6", + namespaced: "xai-demo/grok-4.6", + disabled: false, + displayName: "Grok 4.6", + displayNameOverride: "Grok 4.6", + displayNameSource: "operator", + }; + + beforeEach(() => { + previousGlobals = Object.fromEntries( + globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]), + ) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true }, + }); + container = testWindow.document.createElement("div"); + testWindow.document.body.appendChild(container as never); + root = null; + }); + + afterEach(async () => { + if (root) { + const mounted = root; + await act(async () => mounted.unmount()); + } + testWindow.close(); + for (const key of globals) { + const descriptor = previousGlobals[key]; + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } + }); + + async function mountDialog(options: { + saving?: boolean; + requestError?: string | null; + onSave?: (value: string) => void; + onReset?: () => void; + onClose?: () => void; + } = {}) { + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(container); + root.render( + + {})} + onReset={options.onReset ?? (() => {})} + onClose={options.onClose ?? (() => {})} + /> + , + ); + }); + } + + function setInputValue(input: HTMLInputElement, value: string) { + Object.getOwnPropertyDescriptor(testWindow.HTMLInputElement.prototype, "value")! + .set!.call(input, value); + input.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + } + + test("opens with immutable identity and only the operator override in the input", async () => { + await mountDialog(); + + const dialog = container.querySelector("dialog")!; + const input = container.querySelector("input")!; + expect(dialog.open).toBe(true); + expect(dialog.textContent).toContain("xai-demo/grok-4.6"); + expect(dialog.textContent).toContain("Grok 4.6"); + expect(dialog.textContent).toContain("Your name"); + expect(input.value).toBe("Grok 4.6"); + expect(testWindow.document.activeElement).toBe(input); + }); + + test("validates before save and sends the trimmed safe draft", async () => { + const onSave = jest.fn(); + await mountDialog({ onSave }); + const input = container.querySelector("input")!; + const save = [...container.querySelectorAll("button")] + .find(button => button.textContent === "Save")!; + + await act(async () => { + setInputValue(input, "Bad/Name"); + save.click(); + }); + expect(container.textContent).toContain("Friendly name cannot contain /."); + expect(onSave).not.toHaveBeenCalled(); + + await act(async () => { + setInputValue(input, " Grok Fast "); + save.click(); + }); + expect(onSave).toHaveBeenCalledTimes(1); + expect(onSave).toHaveBeenCalledWith("Grok Fast"); + }); + + test("keeps request errors visible and locks every closing action while saving", async () => { + const onClose = jest.fn(); + const onReset = jest.fn(); + await mountDialog({ saving: true, requestError: "Catalog refresh failed", onClose, onReset }); + + expect(container.textContent).toContain("Catalog refresh failed"); + const actionButtons = [...container.querySelectorAll("button")]; + expect(actionButtons.filter(button => button.tabIndex !== -1).every(button => button.disabled)).toBe(true); + + const dialog = container.querySelector("dialog")!; + await act(async () => { + dialog.dispatchEvent(new testWindow.Event("cancel", { bubbles: false, cancelable: true })); + container.querySelector(".modal-backdrop-dismiss")!.click(); + }); + expect(onClose).not.toHaveBeenCalled(); + expect(onReset).not.toHaveBeenCalled(); + }); +}); From 1b17d1dde5708285317443ff6d45e56ef9fdaa08 Mon Sep 17 00:00:00 2001 From: Zig Zag Date: Wed, 26 Aug 2026 23:24:32 +0530 Subject: [PATCH 03/10] feat(gui): edit discovered model display names --- gui/src/components/ModelDisplayNameDialog.tsx | 2 +- gui/src/pages/Models.tsx | 96 +++++++- gui/src/pages/models-shared.ts | 12 +- gui/src/styles.css | 46 ++++ gui/tests/models-display-name-editor.test.tsx | 210 ++++++++++++++++++ 5 files changed, 362 insertions(+), 4 deletions(-) diff --git a/gui/src/components/ModelDisplayNameDialog.tsx b/gui/src/components/ModelDisplayNameDialog.tsx index 4bd3c66d59..929e4a6a1f 100644 --- a/gui/src/components/ModelDisplayNameDialog.tsx +++ b/gui/src/components/ModelDisplayNameDialog.tsx @@ -108,7 +108,7 @@ export default function ModelDisplayNameDialog({ (null); + const [displayNameSaving, setDisplayNameSaving] = useState(false); + const [displayNameRequestError, setDisplayNameRequestError] = useState(null); + const displayNameSavingRef = useRef(false); + const displayNameTriggerRef = useRef(null); const reloadAliases = useCallback(async (signal?: AbortSignal) => { const response = await fetch(`${apiBase}/api/aliases`, { signal }); @@ -557,6 +563,63 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; } }, [applyCatalog, cacheKey, fetchCatalog, pickerResource.refresh]); + const finishDisplayNameEdit = useCallback(() => { + const trigger = displayNameTriggerRef.current; + setDisplayNameModel(null); + setDisplayNameRequestError(null); + window.setTimeout(() => { + if (trigger?.isConnected) trigger.focus(); + }, 0); + }, []); + + const closeDisplayNameEdit = useCallback(() => { + if (!displayNameSavingRef.current) finishDisplayNameEdit(); + }, [finishDisplayNameEdit]); + + const saveDisplayName = useCallback(async (displayName: string | null) => { + const model = displayNameModel; + if (!model || displayNameSavingRef.current) return; + displayNameSavingRef.current = true; + setDisplayNameSaving(true); + setDisplayNameRequestError(null); + try { + const response = await fetch( + `${apiBase}/api/providers/${encodeURIComponent(model.provider)}/model-display-names`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ modelId: model.id, displayName }), + }, + ); + const result = await readJsonOrThrow<{ + displayName?: string; + displayNameOverride?: string | null; + displayNameSource?: ModelRow["displayNameSource"]; + }>(response, t("models.displayNameSaveFailed")); + if (result) { + setModels(current => current.map(row => row.namespaced !== model.namespaced ? row : { + ...row, + displayName: result.displayName ?? row.displayName, + displayNameOverride: result.displayNameOverride ?? undefined, + displayNameSource: result.displayNameSource ?? row.displayNameSource, + })); + } + await load(true); + displayNameSavingRef.current = false; + setDisplayNameSaving(false); + publishFeedback(true, displayName === null + ? t("models.displayNameResetDone") + : t("models.displayNameSaved")); + finishDisplayNameEdit(); + } catch (error) { + displayNameSavingRef.current = false; + setDisplayNameSaving(false); + setDisplayNameRequestError(error instanceof Error && error.message + ? error.message + : t("models.displayNameSaveFailed")); + } + }, [apiBase, displayNameModel, finishDisplayNameEdit, load, t]); + // Shadow/v2 controls must not wait on the models catalog (live discovery can be slow). useEffect(() => { // Both belong to the catalog tab; a hidden panel polling /api/v2 every ten seconds @@ -1537,9 +1600,29 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; void applyVisibility("models", provider, [{ id: m.id, native: m.native === true }], off)} disabled={busy || m.initialSelectionPending} label={m.native ? m.id : m.namespaced} /> {m.initialSelectionPending && {t("models.initialSelectionPending")}} {aliases.models[provider]?.[m.id] && {aliases.models[provider][m.id].alias}} - {m.native ? modelLabel(m.id) : formatNamespacedModelId(m.namespaced, t)} + + {m.native ? modelLabel(m.id) : formatNamespacedModelId(m.namespaced, t)} + {!m.native && m.displayName?.trim() && m.displayName.trim() !== m.namespaced && ( + {m.displayName.trim()} + )} + {aliases.models[provider]?.[m.id]?.source === "builtin" && {t("models.aliasAuto")}} + {!m.native && !m.custom && ( + + )} {m.custom && ( {t("models.customBadge")} @@ -2485,6 +2568,17 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; )} + + {displayNameModel && ( + void saveDisplayName(value)} + onReset={() => void saveDisplayName(null)} + onClose={closeDisplayNameEdit} + /> + )} ); diff --git a/gui/src/pages/models-shared.ts b/gui/src/pages/models-shared.ts index fc01b9fb04..1f5ef7786b 100644 --- a/gui/src/pages/models-shared.ts +++ b/gui/src/pages/models-shared.ts @@ -45,7 +45,15 @@ export interface ModelRow { reasoningEfforts?: string[]; } -const DISPLAY_NAME_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/; +function containsDisplayNameControlCharacter(value: string): boolean { + return [...value].some(character => { + const codePoint = character.codePointAt(0)!; + return codePoint <= 0x1f + || (codePoint >= 0x7f && codePoint <= 0x9f) + || codePoint === 0x2028 + || codePoint === 0x2029; + }); +} /** Mirror the server display-name contract for immediate form feedback. */ export function modelDisplayNameValidationKey(value: string): TKey | null { @@ -53,7 +61,7 @@ export function modelDisplayNameValidationKey(value: string): TKey | null { if (!trimmed) return "models.displayNameRequired"; if (trimmed.length > 128) return "models.displayNameTooLong"; if (trimmed.includes("/")) return "models.displayNameNoSlash"; - if (DISPLAY_NAME_CONTROL_CHARS.test(trimmed)) return "models.displayNameNoControl"; + if (containsDisplayNameControlCharacter(trimmed)) return "models.displayNameNoControl"; return null; } diff --git a/gui/src/styles.css b/gui/src/styles.css index b0bbc0a6ff..272b01517b 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -2639,6 +2639,52 @@ button.prov-account-row.active { cursor: default; } /* ---- model row hover tooltip ---- */ .model-row-wrap { position: relative; } +.models-model-identity { + display: inline-flex; + min-width: 0; + flex-direction: column; + align-items: flex-start; + gap: 1px; +} +.models-model-friendly { + max-width: min(42vw, 420px); + overflow: hidden; + color: var(--muted); + text-overflow: ellipsis; + white-space: nowrap; +} +.models-display-name-trigger { flex-shrink: 0; } +.model-display-name-dialog { max-width: 460px; } +.model-display-name-identity, +.model-display-name-current { + display: grid; + gap: 5px; + margin-bottom: 16px; +} +.model-display-name-identity code { + overflow-wrap: anywhere; + color: var(--text); +} +.model-display-name-current { + grid-template-columns: 1fr auto; + align-items: center; +} +.model-display-name-current > .text-label { grid-column: 1 / -1; } +.model-display-name-current strong { min-width: 0; overflow-wrap: anywhere; } +.model-display-name-dialog > .input { margin-bottom: 6px; } +.model-display-name-error { + margin-top: 8px; + color: var(--red); + font-size: var(--text-label); + line-height: var(--leading-body); +} +@media (max-width: 560px) { + .models-model-friendly { max-width: 58vw; } + .model-display-name-current { grid-template-columns: 1fr; } + .model-display-name-current > .text-label { grid-column: auto; } + .model-display-name-dialog .modal-actions { align-items: stretch; flex-direction: column-reverse; } + .model-display-name-dialog .modal-actions .btn { width: 100%; } +} .model-tip { z-index: 10; background: var(--surface); diff --git a/gui/tests/models-display-name-editor.test.tsx b/gui/tests/models-display-name-editor.test.tsx index 64141832ac..23f9b61ea4 100644 --- a/gui/tests/models-display-name-editor.test.tsx +++ b/gui/tests/models-display-name-editor.test.tsx @@ -3,7 +3,9 @@ import { Window } from "happy-dom"; import { act } from "react"; import type { Root } from "react-dom/client"; import ModelDisplayNameDialog from "../src/components/ModelDisplayNameDialog"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; import { LanguageProvider } from "../src/i18n/provider"; +import Models from "../src/pages/Models"; import type { ModelRow } from "../src/pages/models-shared"; import { modelDisplayNameValidationKey } from "../src/pages/models-shared"; @@ -21,6 +23,214 @@ describe("discovered model display name validation", () => { }); }); +describe("Models dashboard discovered display name integration", () => { + const globals = [ + "document", "window", "navigator", "localStorage", "sessionStorage", + "IS_REACT_ACT_ENVIRONMENT", "fetch", "setInterval", "clearInterval", + ] as const; + let previousGlobals: Record<(typeof globals)[number], PropertyDescriptor | undefined>; + let testWindow: Window; + let container: HTMLElement; + let root: Root | null; + let mutationBodies: Array<{ modelId: string; displayName: string | null }>; + let mutationFailure: string | null; + let modelFetches: number; + let currentModels: ModelRow[]; + + const routedModel = (): ModelRow => ({ + provider: "xai-demo", + id: "grok-4.6", + namespaced: "xai-demo/grok-4.6", + disabled: false, + displayName: "Grok 4.6", + displayNameOverride: "Grok 4.6", + displayNameSource: "operator", + }); + + beforeEach(() => { + clearClientResourceStoresForTests(); + previousGlobals = Object.fromEntries( + globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]), + ) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/#models" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + sessionStorage: { configurable: true, value: testWindow.sessionStorage }, + IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true }, + setInterval: { configurable: true, value: () => 1 }, + clearInterval: { configurable: true, value: () => {} }, + }); + currentModels = [ + routedModel(), + { provider: "openai", id: "gpt-5.5", namespaced: "openai/gpt-5.5", disabled: false, native: true }, + { + provider: "xai-demo", id: "custom-one", namespaced: "xai-demo/custom-one", + disabled: false, custom: true, customId: "custom-1", displayName: "Custom One", + }, + ]; + mutationBodies = []; + mutationFailure = null; + modelFetches = 0; + testWindow.localStorage.setItem("ocx-models-collapsed:v2", JSON.stringify([])); + testWindow.sessionStorage.setItem("ocx.models.catalog.v1:http://localhost", JSON.stringify({ + models: currentModels, + providers: [ + { name: "xai-demo", liveModels: false, models: ["grok-4.6", "custom-one"] }, + { name: "openai", liveModels: false, models: ["gpt-5.5"] }, + ], + selectedModels: {}, + disabled: [], + contextCaps: {}, + contextCapValue: 350_000, + })); + + globalThis.fetch = (async (input, init) => { + const url = String(input); + if (url.endsWith("/api/models")) { + modelFetches += 1; + return Response.json(currentModels); + } + if (url.endsWith("/api/providers")) return Response.json([ + { name: "xai-demo", liveModels: false, models: ["grok-4.6", "custom-one"] }, + { name: "openai", liveModels: false, models: ["gpt-5.5"] }, + ]); + if (url.endsWith("/api/selected-models")) return Response.json({ selected: {} }); + if (url.endsWith("/api/provider-context-caps")) return Response.json({ caps: {} }); + if (url.endsWith("/api/aliases")) return Response.json({ providers: {}, models: {}, defaults: { global: false, providers: {} } }); + if (url.endsWith("/api/combos")) return Response.json({ combos: [] }); + if (url.endsWith("/api/shadow-call-settings")) return Response.json({ enabled: false, model: "" }); + if (url.endsWith("/api/v2")) return Response.json({ enabled: false, agentsMaxThreadsConflict: false, multiAgentMode: "default" }); + if (url.includes("/api/providers/xai-demo/model-display-names") && init?.method === "PUT") { + const body = JSON.parse(String(init.body)) as { modelId: string; displayName: string | null }; + mutationBodies.push(body); + if (mutationFailure) return Response.json({ error: mutationFailure }, { status: 500 }); + currentModels = currentModels.map(row => row.namespaced !== "xai-demo/grok-4.6" ? row : { + ...row, + displayName: body.displayName ?? "xai-demo/grok-4.6", + displayNameOverride: body.displayName ?? undefined, + displayNameSource: body.displayName ? "operator" : "fallback", + }); + return Response.json({ ok: true }); + } + return new Response(null, { status: 404 }); + }) as typeof fetch; + + container = testWindow.document.createElement("div"); + testWindow.document.body.appendChild(container as never); + root = null; + }); + + afterEach(async () => { + clearClientResourceStoresForTests(); + if (root) { + const mounted = root; + await act(async () => mounted.unmount()); + } + testWindow.close(); + for (const key of globals) { + const descriptor = previousGlobals[key]; + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } + }); + + async function flush() { + await act(async () => { + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); + await Promise.resolve(); + }); + } + + async function mountModels() { + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(container); + root.render(); + }); + await flush(); + } + + function nameTrigger(): HTMLButtonElement { + return container.querySelector( + '[aria-label="Edit friendly name for xai-demo/grok-4.6"]', + )!; + } + + function dialogInput(): HTMLInputElement { + return container.querySelector("dialog")! + .querySelector("input")!; + } + + function setInputValue(input: HTMLInputElement, value: string) { + Object.getOwnPropertyDescriptor(testWindow.HTMLInputElement.prototype, "value")! + .set!.call(input, value); + input.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + } + + function dialogButton(label: string): HTMLButtonElement { + return [...container.querySelectorAll("dialog button")] + .find(button => button.textContent === label)!; + } + + test("only discovered rows expose Name while showing friendly and exact identities", async () => { + await mountModels(); + + expect(nameTrigger()).not.toBeNull(); + expect(container.querySelectorAll('[aria-label^="Edit friendly name for "]')).toHaveLength(1); + expect(container.querySelector('[aria-label="Edit friendly name for openai/gpt-5.5"]')).toBeNull(); + expect(container.querySelector('[aria-label="Edit friendly name for xai-demo/custom-one"]')).toBeNull(); + expect(container.textContent).toContain("Grok 4.6"); + expect(container.textContent).toContain("xai-demo/grok-4.6"); + expect(container.textContent).toContain("Custom One"); + }); + + test("save and reset send exact payloads, reload the catalog, and restore trigger focus", async () => { + await mountModels(); + const trigger = nameTrigger(); + const fetchesBeforeSave = modelFetches; + + await act(async () => trigger.click()); + await act(async () => { + setInputValue(dialogInput(), " Grok Fast "); + dialogButton("Save").click(); + }); + await flush(); + + expect(mutationBodies).toEqual([{ modelId: "grok-4.6", displayName: "Grok Fast" }]); + expect(modelFetches).toBeGreaterThan(fetchesBeforeSave); + expect(container.querySelector("dialog")).toBeNull(); + expect(container.textContent).toContain("Grok Fast"); + expect(testWindow.document.activeElement).toBe(trigger); + + await act(async () => nameTrigger().click()); + await act(async () => dialogButton("Reset name").click()); + await flush(); + + expect(mutationBodies[1]).toEqual({ modelId: "grok-4.6", displayName: null }); + expect(container.querySelector("dialog")).toBeNull(); + expect(container.textContent).toContain("xai-demo/grok-4.6"); + }); + + test("a server failure keeps the dialog and edited draft available for retry", async () => { + mutationFailure = "Catalog refresh failed"; + await mountModels(); + await act(async () => nameTrigger().click()); + await act(async () => { + setInputValue(dialogInput(), "Retry Name"); + dialogButton("Save").click(); + }); + await flush(); + + expect(mutationBodies).toEqual([{ modelId: "grok-4.6", displayName: "Retry Name" }]); + expect(container.querySelector("dialog")).not.toBeNull(); + expect(dialogInput().value).toBe("Retry Name"); + expect(container.textContent).toContain("Catalog refresh failed"); + }); +}); + describe("discovered model display name dialog", () => { const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; let previousGlobals: Record<(typeof globals)[number], PropertyDescriptor | undefined>; From 4ec0dbab23d66e1092a0415083e0308c7fc584e5 Mon Sep 17 00:00:00 2001 From: Zig Zag Date: Wed, 26 Aug 2026 23:25:21 +0530 Subject: [PATCH 04/10] docs(gui): explain discovered model names --- .../src/content/docs/reference/configuration/providers.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index cd5c9657d6..0c560ebad4 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -231,6 +231,13 @@ label. A management client can set or reset one label with `{ "modelId": "grok-4.6", "displayName": "Grok 4.6" }`; send `displayName: null` to reset it. Provider `PATCH` does not edit this map. Use this dedicated `PUT` endpoint to change or remove labels. +The dashboard exposes the same durable setting on **Models**. Expand the provider, find a +discovered model, and choose **Name**. The dialog keeps the exact `provider/model` selector visible +while you save a friendly label. Choose **Reset name** to return to provider metadata or the normal +selector fallback. **Name** changes presentation only; the separate alias pencil changes the +short routing alias and is not a display name editor. Native OpenAI and custom model rows keep their +existing controls. + ## Codex catalog and root `config.toml` settings These settings belong in the root of `$CODEX_HOME/config.toml`, alongside From 05ca4e972777637c6b0ac1adbb8f2d7edb3d7eaa Mon Sep 17 00:00:00 2001 From: Zig Zag Date: Wed, 26 Aug 2026 23:26:20 +0530 Subject: [PATCH 05/10] test(gui): cover display name retry and focus --- gui/tests/models-display-name-editor.test.tsx | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/gui/tests/models-display-name-editor.test.tsx b/gui/tests/models-display-name-editor.test.tsx index 23f9b61ea4..7c6c3a2c2e 100644 --- a/gui/tests/models-display-name-editor.test.tsx +++ b/gui/tests/models-display-name-editor.test.tsx @@ -34,6 +34,7 @@ describe("Models dashboard discovered display name integration", () => { let root: Root | null; let mutationBodies: Array<{ modelId: string; displayName: string | null }>; let mutationFailure: string | null; + let mutationGate: Promise | null; let modelFetches: number; let currentModels: ModelRow[]; @@ -73,6 +74,7 @@ describe("Models dashboard discovered display name integration", () => { ]; mutationBodies = []; mutationFailure = null; + mutationGate = null; modelFetches = 0; testWindow.localStorage.setItem("ocx-models-collapsed:v2", JSON.stringify([])); testWindow.sessionStorage.setItem("ocx.models.catalog.v1:http://localhost", JSON.stringify({ @@ -106,6 +108,7 @@ describe("Models dashboard discovered display name integration", () => { if (url.includes("/api/providers/xai-demo/model-display-names") && init?.method === "PUT") { const body = JSON.parse(String(init.body)) as { modelId: string; displayName: string | null }; mutationBodies.push(body); + if (mutationGate) await mutationGate; if (mutationFailure) return Response.json({ error: mutationFailure }, { status: 500 }); currentModels = currentModels.map(row => row.namespaced !== "xai-demo/grok-4.6" ? row : { ...row, @@ -229,6 +232,39 @@ describe("Models dashboard discovered display name integration", () => { expect(dialogInput().value).toBe("Retry Name"); expect(container.textContent).toContain("Catalog refresh failed"); }); + + test("a pending save blocks duplicate mutations", async () => { + let releaseMutation!: () => void; + mutationGate = new Promise(resolve => { releaseMutation = resolve; }); + await mountModels(); + await act(async () => nameTrigger().click()); + const save = dialogButton("Save"); + + await act(async () => { + setInputValue(dialogInput(), "Grok Once"); + save.click(); + save.click(); + await Promise.resolve(); + }); + expect(mutationBodies).toEqual([{ modelId: "grok-4.6", displayName: "Grok Once" }]); + expect(save.disabled).toBe(true); + + releaseMutation(); + await flush(); + expect(container.querySelector("dialog")).toBeNull(); + }); + + test("Cancel closes without mutation and restores focus to Name", async () => { + await mountModels(); + const trigger = nameTrigger(); + await act(async () => trigger.click()); + await act(async () => dialogButton("Cancel").click()); + await flush(); + + expect(mutationBodies).toHaveLength(0); + expect(container.querySelector("dialog")).toBeNull(); + expect(testWindow.document.activeElement).toBe(trigger); + }); }); describe("discovered model display name dialog", () => { From 57bc6fbe48c64f11be69106ded500c9229b20d8c Mon Sep 17 00:00:00 2001 From: Zig Zag Date: Thu, 27 Aug 2026 00:38:47 +0530 Subject: [PATCH 06/10] fix(gui): preserve exact model identity and focus --- gui/src/components/ModelDisplayNameDialog.tsx | 9 +++- gui/src/pages/Models.tsx | 4 +- gui/tests/models-display-name-editor.test.tsx | 45 ++++++++++++++++--- 3 files changed, 49 insertions(+), 9 deletions(-) diff --git a/gui/src/components/ModelDisplayNameDialog.tsx b/gui/src/components/ModelDisplayNameDialog.tsx index 929e4a6a1f..86a7b48c75 100644 --- a/gui/src/components/ModelDisplayNameDialog.tsx +++ b/gui/src/components/ModelDisplayNameDialog.tsx @@ -31,6 +31,7 @@ export default function ModelDisplayNameDialog({ const t = useT(); const dialogRef = useRef(null); const inputRef = useRef(null); + const wasSavingRef = useRef(saving); const titleId = useId(); const helpId = useId(); const errorId = useId(); @@ -44,6 +45,12 @@ export default function ModelDisplayNameDialog({ return () => { if (dialog?.open) dialog.close(); }; }, []); + useEffect(() => { + const saveFailed = wasSavingRef.current && !saving && Boolean(requestError); + wasSavingRef.current = saving; + if (saveFailed) inputRef.current?.focus(); + }, [requestError, saving]); + const validationError = validationKey ? t(validationKey) : null; const visibleError = validationError ?? requestError; const sourceKey = model.displayNameSource @@ -113,7 +120,7 @@ export default function ModelDisplayNameDialog({ maxLength={129} placeholder={t("models.displayNamePlaceholder")} aria-describedby={`${helpId}${visibleError ? ` ${errorId}` : ""}`} - aria-invalid={visibleError ? true : undefined} + aria-invalid={validationError ? true : undefined} disabled={saving} onChange={event => { setDraft(event.target.value); diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 5d694b4ecc..6e8fc40d4f 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -9,7 +9,7 @@ import { IconChevron, IconBoxes, IconInfo, IconCheck, IconAlert, IconRefresh, Ic import { useT } from "../i18n/shared"; import type { TFn, TKey } from "../i18n/shared"; import { modelLabel } from "../model-display"; -import { formatNamespacedModelId, formatProviderDisplayName, providerDisplaySlug } from "../provider-icons"; +import { formatProviderDisplayName, providerDisplaySlug } from "../provider-icons"; import { readJsonIfOk, readJsonOrThrow } from "../fetch-json"; import { describeIntegrationRefusalParts } from "./integrations/refusal-copy"; import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; @@ -1601,7 +1601,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; {m.initialSelectionPending && {t("models.initialSelectionPending")}} {aliases.models[provider]?.[m.id] && {aliases.models[provider][m.id].alias}} - {m.native ? modelLabel(m.id) : formatNamespacedModelId(m.namespaced, t)} + {m.native ? modelLabel(m.id) : m.namespaced} {!m.native && m.displayName?.trim() && m.displayName.trim() !== m.namespaced && ( {m.displayName.trim()} )} diff --git a/gui/tests/models-display-name-editor.test.tsx b/gui/tests/models-display-name-editor.test.tsx index 7c6c3a2c2e..cc86716f7e 100644 --- a/gui/tests/models-display-name-editor.test.tsx +++ b/gui/tests/models-display-name-editor.test.tsx @@ -66,6 +66,14 @@ describe("Models dashboard discovered display name integration", () => { }); currentModels = [ routedModel(), + { + provider: "command-code", + id: "deepseek-deepseek-v4-flash", + namespaced: "command-code/deepseek-deepseek-v4-flash", + disabled: false, + displayName: "DeepSeek V4 Flash", + displayNameSource: "provider", + }, { provider: "openai", id: "gpt-5.5", namespaced: "openai/gpt-5.5", disabled: false, native: true }, { provider: "xai-demo", id: "custom-one", namespaced: "xai-demo/custom-one", @@ -81,6 +89,7 @@ describe("Models dashboard discovered display name integration", () => { models: currentModels, providers: [ { name: "xai-demo", liveModels: false, models: ["grok-4.6", "custom-one"] }, + { name: "command-code", liveModels: false, models: ["deepseek-deepseek-v4-flash"] }, { name: "openai", liveModels: false, models: ["gpt-5.5"] }, ], selectedModels: {}, @@ -97,6 +106,7 @@ describe("Models dashboard discovered display name integration", () => { } if (url.endsWith("/api/providers")) return Response.json([ { name: "xai-demo", liveModels: false, models: ["grok-4.6", "custom-one"] }, + { name: "command-code", liveModels: false, models: ["deepseek-deepseek-v4-flash"] }, { name: "openai", liveModels: false, models: ["gpt-5.5"] }, ]); if (url.endsWith("/api/selected-models")) return Response.json({ selected: {} }); @@ -182,11 +192,14 @@ describe("Models dashboard discovered display name integration", () => { await mountModels(); expect(nameTrigger()).not.toBeNull(); - expect(container.querySelectorAll('[aria-label^="Edit friendly name for "]')).toHaveLength(1); + expect(container.querySelectorAll('[aria-label^="Edit friendly name for "]')).toHaveLength(2); expect(container.querySelector('[aria-label="Edit friendly name for openai/gpt-5.5"]')).toBeNull(); expect(container.querySelector('[aria-label="Edit friendly name for xai-demo/custom-one"]')).toBeNull(); expect(container.textContent).toContain("Grok 4.6"); expect(container.textContent).toContain("xai-demo/grok-4.6"); + expect([...container.querySelectorAll("code")].some(code => + code.textContent === "command-code/deepseek-deepseek-v4-flash" + )).toBe(true); expect(container.textContent).toContain("Custom One"); }); @@ -231,6 +244,7 @@ describe("Models dashboard discovered display name integration", () => { expect(container.querySelector("dialog")).not.toBeNull(); expect(dialogInput().value).toBe("Retry Name"); expect(container.textContent).toContain("Catalog refresh failed"); + expect(testWindow.document.activeElement).toBe(dialogInput()); }); test("a pending save blocks duplicate mutations", async () => { @@ -314,7 +328,7 @@ describe("discovered model display name dialog", () => { } }); - async function mountDialog(options: { + async function renderDialog(options: { saving?: boolean; requestError?: string | null; onSave?: (value: string) => void; @@ -323,7 +337,7 @@ describe("discovered model display name dialog", () => { } = {}) { const { createRoot } = await import("react-dom/client"); await act(async () => { - root = createRoot(container); + root ??= createRoot(container); root.render( { } test("opens with immutable identity and only the operator override in the input", async () => { - await mountDialog(); + await renderDialog(); const dialog = container.querySelector("dialog")!; const input = container.querySelector("input")!; @@ -360,7 +374,7 @@ describe("discovered model display name dialog", () => { test("validates before save and sends the trimmed safe draft", async () => { const onSave = jest.fn(); - await mountDialog({ onSave }); + await renderDialog({ onSave }); const input = container.querySelector("input")!; const save = [...container.querySelectorAll("button")] .find(button => button.textContent === "Save")!; @@ -383,7 +397,7 @@ describe("discovered model display name dialog", () => { test("keeps request errors visible and locks every closing action while saving", async () => { const onClose = jest.fn(); const onReset = jest.fn(); - await mountDialog({ saving: true, requestError: "Catalog refresh failed", onClose, onReset }); + await renderDialog({ saving: true, requestError: "Catalog refresh failed", onClose, onReset }); expect(container.textContent).toContain("Catalog refresh failed"); const actionButtons = [...container.querySelectorAll("button")]; @@ -397,4 +411,23 @@ describe("discovered model display name dialog", () => { expect(onClose).not.toHaveBeenCalled(); expect(onReset).not.toHaveBeenCalled(); }); + + test("a request failure does not mark a valid display name as invalid", async () => { + await renderDialog({ requestError: "Catalog refresh failed" }); + + const input = container.querySelector("input")!; + expect(input.getAttribute("aria-invalid")).toBeNull(); + expect(testWindow.document.activeElement).toBe(input); + }); + + test("focus returns to the editable name after a pending save fails", async () => { + await renderDialog({ saving: true }); + testWindow.document.body.tabIndex = -1; + testWindow.document.body.focus(); + expect(testWindow.document.activeElement).toBe(testWindow.document.body); + + await renderDialog({ requestError: "Catalog refresh failed" }); + + expect(testWindow.document.activeElement).toBe(container.querySelector("input")); + }); }); From 0bf5048b50a0db6f74e291282ff48d026a247f26 Mon Sep 17 00:00:00 2001 From: Zig Zag Date: Thu, 27 Aug 2026 06:41:24 +0530 Subject: [PATCH 07/10] fix(i18n): clarify display name source labels --- gui/src/i18n/fr.ts | 22 +++++++++++----------- gui/src/i18n/ko.ts | 4 ++-- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 668c7fd091..3357d2cc75 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2536,22 +2536,22 @@ export const fr: Record = { "integrations.cursor.colContext": "Contexte", "integrations.cursor.guide": "Ouvrir le guide de Cursor Private Inference", "models.displayNameAction": "Nom", - "models.displayNameActionLabel": "Modifier le nom convivial de {model}", - "models.displayNameTitle": "Nom convivial", + "models.displayNameActionLabel": "Modifier le nom d’affichage de {model}", + "models.displayNameTitle": "Nom d’affichage", "models.displayNameModelId": "ID du modèle", "models.displayNameCurrent": "Nom actuel", - "models.displayNameSourceOperator": "Votre nom", + "models.displayNameSourceOperator": "Votre nom d’affichage", "models.displayNameSourceProvider": "Nom du fournisseur", "models.displayNameSourceFallback": "ID du modèle par défaut", - "models.displayNameField": "Nom convivial", + "models.displayNameField": "Nom d’affichage", "models.displayNamePlaceholder": "p. ex. Grok 4.6", "models.displayNameHelp": "Modifie uniquement l’affichage. Le routage reste {model}.", "models.displayNameReset": "Réinitialiser le nom", - "models.displayNameSaved": "Nom convivial enregistré", - "models.displayNameResetDone": "Nom convivial réinitialisé", - "models.displayNameSaveFailed": "Impossible d’enregistrer le nom convivial", - "models.displayNameRequired": "Saisissez un nom convivial ou utilisez Réinitialiser le nom.", - "models.displayNameTooLong": "Le nom convivial doit contenir au maximum 128 caractères.", - "models.displayNameNoSlash": "Le nom convivial ne peut pas contenir /.", - "models.displayNameNoControl": "Le nom convivial ne peut pas contenir de caractères de contrôle.", + "models.displayNameSaved": "Nom d’affichage enregistré", + "models.displayNameResetDone": "Nom d’affichage réinitialisé", + "models.displayNameSaveFailed": "Impossible d’enregistrer le nom d’affichage", + "models.displayNameRequired": "Saisissez un nom d’affichage ou utilisez Réinitialiser le nom.", + "models.displayNameTooLong": "Le nom d’affichage doit contenir au maximum 128 caractères.", + "models.displayNameNoSlash": "Le nom d’affichage ne peut pas contenir /.", + "models.displayNameNoControl": "Le nom d’affichage ne peut pas contenir de caractères de contrôle.", }; diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index d2aefdfceb..a8a01870a8 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2575,8 +2575,8 @@ export const ko: Record = { "models.displayNameTitle": "표시 이름", "models.displayNameModelId": "모델 ID", "models.displayNameCurrent": "현재 이름", - "models.displayNameSourceOperator": "사용자 이름", - "models.displayNameSourceProvider": "공급자 이름", + "models.displayNameSourceOperator": "운영자 지정 이름", + "models.displayNameSourceProvider": "프로바이더 제공 이름", "models.displayNameSourceFallback": "모델 ID 기본값", "models.displayNameField": "표시 이름", "models.displayNamePlaceholder": "예: Grok 4.6", From b0cccfd4b15a9a73a2916f855a9190fde6eda9c0 Mon Sep 17 00:00:00 2001 From: Zig Zag Date: Wed, 2 Sep 2026 06:03:51 +0530 Subject: [PATCH 08/10] fix(i18n): polish display name translations --- gui/src/i18n/tr.ts | 2 +- gui/src/i18n/zh-TW.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 9e5fd8b1db..e02e10ef49 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2580,7 +2580,7 @@ export const tr: Record = { "models.displayNameSourceProvider": "Sağlayıcı adı", "models.displayNameSourceFallback": "Model kimliği varsayılanı", "models.displayNameField": "Görünen ad", - "models.displayNamePlaceholder": "ör. Grok 4.6", + "models.displayNamePlaceholder": "örn. Grok 4.6", "models.displayNameHelp": "Yalnızca görünümü değiştirir. Yönlendirme {model} olarak kalır.", "models.displayNameReset": "Adı sıfırla", "models.displayNameSaved": "Görünen ad kaydedildi", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index fb30877150..4a48dcffe6 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2539,7 +2539,7 @@ export const zhTW: Record = { "models.displayNameModelId": "模型 ID", "models.displayNameCurrent": "目前名稱", "models.displayNameSourceOperator": "你的名稱", - "models.displayNameSourceProvider": "提供者名稱", + "models.displayNameSourceProvider": "供應商名稱", "models.displayNameSourceFallback": "模型 ID 預設值", "models.displayNameField": "友善名稱", "models.displayNamePlaceholder": "例如 Grok 4.6", From b7e5f98d969a774f86fcd913a368742d68b87706 Mon Sep 17 00:00:00 2001 From: Zig Zag Date: Sat, 5 Sep 2026 19:08:57 +0530 Subject: [PATCH 09/10] fix(gui): align display name action order --- gui/src/styles.css | 2 +- gui/tests/models-display-name-editor.test.tsx | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/gui/src/styles.css b/gui/src/styles.css index 272b01517b..630882006f 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -2682,7 +2682,7 @@ button.prov-account-row.active { cursor: default; } .models-model-friendly { max-width: 58vw; } .model-display-name-current { grid-template-columns: 1fr; } .model-display-name-current > .text-label { grid-column: auto; } - .model-display-name-dialog .modal-actions { align-items: stretch; flex-direction: column-reverse; } + .model-display-name-dialog .modal-actions { align-items: stretch; flex-direction: column; } .model-display-name-dialog .modal-actions .btn { width: 100%; } } .model-tip { diff --git a/gui/tests/models-display-name-editor.test.tsx b/gui/tests/models-display-name-editor.test.tsx index cc86716f7e..9f1d819037 100644 --- a/gui/tests/models-display-name-editor.test.tsx +++ b/gui/tests/models-display-name-editor.test.tsx @@ -23,6 +23,19 @@ describe("discovered model display name validation", () => { }); }); +describe("discovered model display name responsive styles", () => { + test("keeps the narrow action order aligned with keyboard navigation", async () => { + const styles = await Bun.file(new URL("../src/styles.css", import.meta.url)).text(); + + expect(styles).toContain( + ".model-display-name-dialog .modal-actions { align-items: stretch; flex-direction: column; }", + ); + expect(styles).not.toContain( + ".model-display-name-dialog .modal-actions { align-items: stretch; flex-direction: column-reverse; }", + ); + }); +}); + describe("Models dashboard discovered display name integration", () => { const globals = [ "document", "window", "navigator", "localStorage", "sessionStorage", From 93ed44053b68a9707f8271981d5f7e4bc25e9b70 Mon Sep 17 00:00:00 2001 From: Zig Zag Date: Mon, 7 Sep 2026 01:22:58 +0530 Subject: [PATCH 10/10] fix(gui): keep display name editor on reload failure --- gui/src/pages/Models.tsx | 4 +++- gui/tests/models-display-name-editor.test.tsx | 22 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 6e8fc40d4f..e3fe0d66ca 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -604,7 +604,9 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; displayNameSource: result.displayNameSource ?? row.displayNameSource, })); } - await load(true); + if (!await load(true)) { + throw new Error(t("models.loadFail")); + } displayNameSavingRef.current = false; setDisplayNameSaving(false); publishFeedback(true, displayName === null diff --git a/gui/tests/models-display-name-editor.test.tsx b/gui/tests/models-display-name-editor.test.tsx index 9f1d819037..6cdb169999 100644 --- a/gui/tests/models-display-name-editor.test.tsx +++ b/gui/tests/models-display-name-editor.test.tsx @@ -49,6 +49,7 @@ describe("Models dashboard discovered display name integration", () => { let mutationFailure: string | null; let mutationGate: Promise | null; let modelFetches: number; + let modelFetchFailure: string | null; let currentModels: ModelRow[]; const routedModel = (): ModelRow => ({ @@ -97,6 +98,7 @@ describe("Models dashboard discovered display name integration", () => { mutationFailure = null; mutationGate = null; modelFetches = 0; + modelFetchFailure = null; testWindow.localStorage.setItem("ocx-models-collapsed:v2", JSON.stringify([])); testWindow.sessionStorage.setItem("ocx.models.catalog.v1:http://localhost", JSON.stringify({ models: currentModels, @@ -115,6 +117,9 @@ describe("Models dashboard discovered display name integration", () => { const url = String(input); if (url.endsWith("/api/models")) { modelFetches += 1; + if (modelFetchFailure) { + return Response.json({ error: modelFetchFailure }, { status: 500 }); + } return Response.json(currentModels); } if (url.endsWith("/api/providers")) return Response.json([ @@ -260,6 +265,23 @@ describe("Models dashboard discovered display name integration", () => { expect(testWindow.document.activeElement).toBe(dialogInput()); }); + test("a failed catalog reload after save keeps the dialog available for retry", async () => { + await mountModels(); + modelFetchFailure = "Catalog reload failed"; + await act(async () => nameTrigger().click()); + await act(async () => { + setInputValue(dialogInput(), "Retry Reload"); + dialogButton("Save").click(); + }); + await flush(); + + expect(mutationBodies).toEqual([{ modelId: "grok-4.6", displayName: "Retry Reload" }]); + expect(container.querySelector("dialog")).not.toBeNull(); + expect(dialogInput().value).toBe("Retry Reload"); + expect(container.textContent).toContain("Failed to load models"); + expect(testWindow.document.activeElement).toBe(dialogInput()); + }); + test("a pending save blocks duplicate mutations", async () => { let releaseMutation!: () => void; mutationGate = new Promise(resolve => { releaseMutation = resolve; });