From 92c95fafa0eb5605ae8414274af493a51e55ee06 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:40:59 +0900 Subject: [PATCH 1/4] feat(gui): carry discovered model name editor with recoverable saves Carry the net diff from PR #2716 at 93ed44053b68a9707f8271981d5f7e4bc25e9b70. Reconcile confirmed saved/reset receipts, including saved:true errors, with the editor draft, current label and reset availability. Preserve reset intent on retry and retry only the list read after a successful mutation and failed read. Reuse createBoundedFetch for a single 60-second write-and-refresh budget. Timeouts retain the draft, release the modal lock and leave persistence unknown; retry reads current state before another mutation. Keep global fetch unchanged. Add focused regression coverage, nine-locale recovery copy and workflow docs. Local tests, typecheck, build and browser smoke NOT RUN by owner mandate. Static diff inspection only; parent owns remote CI and browser verification. Co-authored-by: Zig Zag --- .../docs/reference/configuration/providers.md | 14 + gui/src/components/ModelDisplayNameDialog.tsx | 172 +++++ gui/src/i18n/de.ts | 23 + gui/src/i18n/en.ts | 23 + gui/src/i18n/fr.ts | 23 + gui/src/i18n/ja.ts | 23 + gui/src/i18n/ko.ts | 23 + gui/src/i18n/ru.ts | 23 + gui/src/i18n/tr.ts | 23 + gui/src/i18n/zh-TW.ts | 23 + gui/src/i18n/zh.ts | 23 + gui/src/pages/Models.tsx | 161 ++++- gui/src/pages/models-shared.ts | 24 +- gui/src/styles.css | 46 ++ gui/tests/models-display-name-editor.test.tsx | 657 ++++++++++++++++++ 15 files changed, 1276 insertions(+), 5 deletions(-) create mode 100644 gui/src/components/ModelDisplayNameDialog.tsx create mode 100644 gui/tests/models-display-name-editor.test.tsx diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 9bd558be44..78d97cd79c 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -249,6 +249,20 @@ 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. + +If the change is saved but refreshing fails, the dialog reflects the saved override and keeps +**Retry** available. Retry repeats catalog convergence when the server reported it failed, or +reloads the list when only the list request failed. Reset recovery keeps the reset operation; +it does not restore the old name. Requests have a 60-second deadline covering the write and its +follow-up list refresh. A timeout does not undo a write: use **Retry** to check the current name +before making another change. + ## Codex catalog and root `config.toml` settings These settings belong in the root of `$CODEX_HOME/config.toml`, alongside diff --git a/gui/src/components/ModelDisplayNameDialog.tsx b/gui/src/components/ModelDisplayNameDialog.tsx new file mode 100644 index 0000000000..c24b6612d7 --- /dev/null +++ b/gui/src/components/ModelDisplayNameDialog.tsx @@ -0,0 +1,172 @@ +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; + currentNamePending?: boolean; + onRetry?: () => void; + onEdit?: () => void; + 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, + currentNamePending = false, + onRetry, + onEdit, + onSave, + onReset, + onClose, +}: ModelDisplayNameDialogProps) { + const t = useT(); + const dialogRef = useRef(null); + const inputRef = useRef(null); + const wasSavingRef = useRef(saving); + 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(); }; + }, []); + + useEffect(() => { + const saveFailed = wasSavingRef.current && !saving && Boolean(requestError); + wasSavingRef.current = saving; + if (saveFailed) inputRef.current?.focus(); + }, [requestError, saving]); + + // Parent replaces this snapshot only after a confirmed mutation, not catalog polling. + useEffect(() => { + setDraft(model.displayNameOverride ?? ""); + setValidationKey(null); + }, [model]); + + 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")} + {currentNamePending ? t("models.displayNameCurrentUnavailable") : model.displayName ?? model.namespaced} + {!currentNamePending && {t(sourceKey)}} +
+ + + { + onEdit?.(); + setDraft(event.target.value); + setValidationKey(null); + }} + /> +

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

+ {visibleError && ( + + )} + +
+ + + +
+ +
+ ); +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 9086c9bf42..817034bed6 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2548,4 +2548,27 @@ export const de: Record = { "integrations.cursor.colReasoning": "Reasoning-Aufwand", "integrations.cursor.colContext": "Kontext", "integrations.cursor.guide": "Anleitung zu Cursor Private Inference öffnen", + "models.displayNameSavedRefreshFailed": "Die Änderung wurde gespeichert, aber die Modellliste konnte nicht aktualisiert werden. Versuchen Sie es erneut.", + "models.displayNameOutcomeUnknown": "Die Anfrage wurde nicht abgeschlossen. Die Änderung wurde möglicherweise gespeichert. Prüfen Sie den aktuellen Namen durch erneutes Versuchen, bevor Sie ihn weiter ändern.", + "models.displayNameCurrentUnavailable": "Aktueller Name erst nach Aktualisierung verfügbar", + "models.displayNameReloaded": "Modellliste aktualisiert", + "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..a5ba86e2dd 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2582,6 +2582,29 @@ 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.displayNameSavedRefreshFailed": "The change was saved, but the model list could not be refreshed. Retry to refresh it.", + "models.displayNameOutcomeUnknown": "The request did not finish. The change may have been saved. Retry to check the current name before making another change.", + "models.displayNameCurrentUnavailable": "Current name unavailable until refresh", + "models.displayNameReloaded": "Model list refreshed", + "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..fecc7ca243 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2535,4 +2535,27 @@ export const fr: Record = { "integrations.cursor.colReasoning": "Raisonnement", "integrations.cursor.colContext": "Contexte", "integrations.cursor.guide": "Ouvrir le guide de Cursor Private Inference", + "models.displayNameSavedRefreshFailed": "La modification a été enregistrée, mais la liste des modèles n’a pas pu être actualisée. Réessayez.", + "models.displayNameOutcomeUnknown": "La requête n’a pas abouti. La modification a peut-être été enregistrée. Réessayez pour vérifier le nom actuel avant toute autre modification.", + "models.displayNameCurrentUnavailable": "Nom actuel indisponible avant actualisation", + "models.displayNameReloaded": "Liste des modèles actualisée", + "models.displayNameAction": "Nom", + "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 d’affichage", + "models.displayNameSourceProvider": "Nom du fournisseur", + "models.displayNameSourceFallback": "ID du modèle par défaut", + "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 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/ja.ts b/gui/src/i18n/ja.ts index 4b16912324..cf2a3df5a8 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2569,4 +2569,27 @@ export const ja: Record = { "integrations.cursor.colReasoning": "推論", "integrations.cursor.colContext": "コンテキスト", "integrations.cursor.guide": "Cursor Private Inference のガイドを開く", + "models.displayNameSavedRefreshFailed": "変更は保存されましたが、モデル一覧を更新できませんでした。再試行してください。", + "models.displayNameOutcomeUnknown": "リクエストが完了しませんでした。変更が保存されている可能性があります。再度変更する前に再試行して現在の名前を確認してください。", + "models.displayNameCurrentUnavailable": "更新するまで現在の名前を確認できません", + "models.displayNameReloaded": "モデル一覧を更新しました", + "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..3c1d0b1497 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2570,4 +2570,27 @@ export const ko: Record = { "integrations.cursor.colReasoning": "추론", "integrations.cursor.colContext": "컨텍스트", "integrations.cursor.guide": "Cursor Private Inference 가이드 열기", + "models.displayNameSavedRefreshFailed": "변경 사항은 저장되었지만 모델 목록을 새로 고치지 못했습니다. 다시 시도해 주세요.", + "models.displayNameOutcomeUnknown": "요청이 완료되지 않았습니다. 변경 사항이 저장되었을 수 있습니다. 다시 변경하기 전에 재시도하여 현재 이름을 확인하세요.", + "models.displayNameCurrentUnavailable": "새로 고침 전까지 현재 이름을 확인할 수 없음", + "models.displayNameReloaded": "모델 목록을 새로 고쳤습니다", + "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..b7f02bdf50 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2571,4 +2571,27 @@ export const ru: Record = { "integrations.cursor.colReasoning": "Рассуждения", "integrations.cursor.colContext": "Контекст", "integrations.cursor.guide": "Открыть руководство по Cursor Private Inference", + "models.displayNameSavedRefreshFailed": "Изменение сохранено, но список моделей не удалось обновить. Повторите попытку.", + "models.displayNameOutcomeUnknown": "Запрос не завершён. Изменение могло сохраниться. Повторите попытку, чтобы проверить текущее имя перед следующим изменением.", + "models.displayNameCurrentUnavailable": "Текущее имя недоступно до обновления", + "models.displayNameReloaded": "Список моделей обновлён", + "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..b18d1cb360 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2571,4 +2571,27 @@ 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.displayNameSavedRefreshFailed": "Değişiklik kaydedildi ancak model listesi yenilenemedi. Yenilemek için tekrar deneyin.", + "models.displayNameOutcomeUnknown": "İstek tamamlanmadı. Değişiklik kaydedilmiş olabilir. Başka bir değişiklik yapmadan önce geçerli adı kontrol etmek için tekrar deneyin.", + "models.displayNameCurrentUnavailable": "Geçerli ad yenilemeye kadar kullanılamıyor", + "models.displayNameReloaded": "Model listesi yenilendi", + "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": "ö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", + "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..463c2c88e5 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2533,4 +2533,27 @@ export const zhTW: Record = { "integrations.cursor.colReasoning": "推理", "integrations.cursor.colContext": "上下文", "integrations.cursor.guide": "開啟 Cursor Private Inference 指南", + "models.displayNameSavedRefreshFailed": "變更已儲存,但無法重新整理模型清單。請重試。", + "models.displayNameOutcomeUnknown": "請求未完成。變更可能已儲存。再次變更之前,請重試以檢查目前名稱。", + "models.displayNameCurrentUnavailable": "重新整理之前無法取得目前名稱", + "models.displayNameReloaded": "模型清單已重新整理", + "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..1c6694fc8b 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2569,4 +2569,27 @@ export const zh: Record = { "integrations.cursor.colReasoning": "推理", "integrations.cursor.colContext": "上下文", "integrations.cursor.guide": "打开 Cursor Private Inference 指南", + "models.displayNameSavedRefreshFailed": "更改已保存,但无法刷新模型列表。请重试以刷新。", + "models.displayNameOutcomeUnknown": "请求未完成。更改可能已保存。再次更改之前,请重试以检查当前名称。", + "models.displayNameCurrentUnavailable": "刷新之前无法获取当前名称", + "models.displayNameReloaded": "模型列表已刷新", + "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.tsx b/gui/src/pages/Models.tsx index 22ea51bc20..b7b3a0e285 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -1,4 +1,5 @@ import { CodexStaleBanner } from "../components/codex-stale-banner"; +import ModelDisplayNameDialog from "../components/ModelDisplayNameDialog"; import { fetchCodexAppServerState } from "../codex-app-server-state"; import type { AppServerStateOutcome } from "../codex-app-server-state"; import { useCodexRestart } from "../use-codex-restart"; @@ -8,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"; @@ -328,6 +329,22 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; const [showThreadsCustom, setShowThreadsCustom] = useState(false); const [v2HelpOpen, setV2HelpOpen] = useState(false); const [customModalOpen, setCustomModalOpen] = useState(false); + const [displayNameModel, setDisplayNameModel] = useState(null); + const [displayNameSaving, setDisplayNameSaving] = useState(false); + const [displayNameRequestError, setDisplayNameRequestError] = useState(null); + const [displayNameRecovery, setDisplayNameRecovery] = useState<{ + value: string | null | undefined; + confirmed: boolean; + } | null>(null); + const [displayNameCurrentPending, setDisplayNameCurrentPending] = useState(false); + const displayNameRequestRef = useRef(null); + const displayNameSavingRef = useRef(false); + useEffect(() => () => { + displayNameRequestRef.current?.controller.abort(); + displayNameRequestRef.current?.clear(); + displayNameRequestRef.current = null; + }, []); + const displayNameTriggerRef = useRef(null); const reloadAliases = useCallback(async (signal?: AbortSignal) => { const response = await fetch(`${apiBase}/api/aliases`, { signal }); @@ -535,12 +552,12 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; ); const catalogState = catalogResource.state; - const load = useCallback(async (force = false): Promise => { + const load = useCallback(async (force = false, signal?: AbortSignal): Promise => { if (loadPendingRef.current && !force) return false; loadPendingRef.current = true; const generation = ++loadGenerationRef.current; try { - const next = await fetchCatalog(new AbortController().signal); + const next = await fetchCatalog(signal ?? new AbortController().signal); if (!shouldApplyLoadGeneration(generation, loadGenerationRef.current)) return false; applyCatalog(next); // Follow-up mutation refreshes retain their existing awaitable contract while publishing @@ -557,6 +574,106 @@ 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); + setDisplayNameRecovery(null); + setDisplayNameCurrentPending(false); + window.setTimeout(() => { + if (trigger?.isConnected) trigger.focus(); + }, 0); + }, []); + + const closeDisplayNameEdit = useCallback(() => { + if (!displayNameSavingRef.current) finishDisplayNameEdit(); + }, [finishDisplayNameEdit]); + + // undefined retries only the read after a confirmed write or an unknown outcome. + const saveDisplayName = useCallback(async (displayName: string | null | undefined) => { + const model = displayNameModel; + if (!model || displayNameSavingRef.current) return; + const bounded = createBoundedFetch(60_000); + displayNameRequestRef.current = bounded; + displayNameSavingRef.current = true; + setDisplayNameSaving(true); + setDisplayNameRequestError(null); + let confirmed = displayName === undefined && displayNameRecovery?.confirmed === true; + let refreshOnly = displayName === undefined; + try { + if (displayName !== undefined) { + 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 }), + signal: bounded.signal, + }, + ); + // The route can persist the value and return 503 when catalog convergence fails. + // Keep that receipt instead of throwing away saved:true with the error body. + type DisplayNameReceipt = { + saved?: boolean; + error?: string; + displayName?: string; + displayNameOverride?: string | null; + displayNameSource?: ModelRow["displayNameSource"]; + }; + const result: DisplayNameReceipt | undefined = response.ok + ? await readJsonOrThrow(response, t("models.displayNameSaveFailed")) + : await response.json(); + bounded.signal.throwIfAborted(); + if (!result) throw new Error(t("models.displayNameSaveFailed")); + confirmed = response.ok || result.saved === true; + if (confirmed) { + const override = result.displayNameOverride === null ? undefined + : result.displayNameOverride ?? displayName ?? undefined; + const fields: Pick = { + displayName: result.displayName ?? override, + displayNameOverride: override, + displayNameSource: result.displayNameSource ?? (override ? "operator" : undefined), + }; + setModels(current => current.map(row => row.namespaced === model.namespaced ? { ...row, ...fields } : row)); + setDisplayNameModel({ ...model, ...fields }); + // A saved:true reset receipt omits the provider's effective fallback label. + setDisplayNameCurrentPending(fields.displayName === undefined); + } + if (!response.ok) { + throw new Error(result.error || t("models.displayNameSaveFailed")); + } + refreshOnly = true; + } + if (!await load(true, bounded.signal)) throw new Error(t("models.loadFail")); + bounded.signal.throwIfAborted(); + publishFeedback(true, confirmed + ? t(displayName === null || (displayName === undefined && displayNameRecovery?.value === null) + ? "models.displayNameResetDone" : "models.displayNameSaved") + : t("models.displayNameReloaded")); + finishDisplayNameEdit(); + } catch (error) { + if (displayNameRequestRef.current !== bounded) return; + if (bounded.signal.aborted && !confirmed) setDisplayNameCurrentPending(true); + setDisplayNameRecovery(confirmed || bounded.signal.aborted || refreshOnly + ? { value: refreshOnly || bounded.signal.aborted ? undefined : displayName, confirmed } + : null); + setDisplayNameRequestError(confirmed + ? t("models.displayNameSavedRefreshFailed") + : bounded.signal.aborted || refreshOnly + ? t("models.displayNameOutcomeUnknown") + : error instanceof Error && error.message + ? error.message + : t("models.displayNameSaveFailed")); + } finally { + bounded.clear(); + if (displayNameRequestRef.current === bounded) { + displayNameRequestRef.current = null; + displayNameSavingRef.current = false; + setDisplayNameSaving(false); + } + } + }, [apiBase, displayNameModel, displayNameRecovery, 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 +1654,31 @@ 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) : m.namespaced} + {!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 +2624,20 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; )} + + {displayNameModel && ( + void saveDisplayName(displayNameRecovery.value) : undefined} + onEdit={() => setDisplayNameRecovery(null)} + onSave={value => 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 fdc487301c..1f5ef7786b 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,26 @@ export interface ModelRow { reasoningEfforts?: string[]; } +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 { + const trimmed = value.trim(); + if (!trimmed) return "models.displayNameRequired"; + if (trimmed.length > 128) return "models.displayNameTooLong"; + if (trimmed.includes("/")) return "models.displayNameNoSlash"; + if (containsDisplayNameControlCharacter(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/src/styles.css b/gui/src/styles.css index b0bbc0a6ff..630882006f 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; } + .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 new file mode 100644 index 0000000000..be64333944 --- /dev/null +++ b/gui/tests/models-display-name-editor.test.tsx @@ -0,0 +1,657 @@ +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 { clearClientResourceStoresForTests } from "../src/client-resource"; +import { LanguageProvider } from "../src/i18n/provider"; +import { installApiAuthFetch, resetApiAuthFetchForTests } from "../src/api"; +import Models from "../src/pages/Models"; +import type { ModelRow } from "../src/pages/models-shared"; +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(); + expect(modelDisplayNameValidationKey("모델 이름")).toBeNull(); + expect(modelDisplayNameValidationKey("🚀".repeat(64))).toBeNull(); + expect(modelDisplayNameValidationKey("🚀".repeat(65))).toBe("models.displayNameTooLong"); + }); + + test("rejects values that the management API cannot persist", () => { + expect(modelDisplayNameValidationKey(" ")).toBe("models.displayNameRequired"); + expect(modelDisplayNameValidationKey("Grok/4.6")).toBe("models.displayNameNoSlash"); + for (const control of ["\n", "\u0000", "\u007f", "\u0085", "\u2028", "\u2029"]) { + expect(modelDisplayNameValidationKey(`Grok${control}4.6`)).toBe("models.displayNameNoControl"); + } + expect(modelDisplayNameValidationKey("A".repeat(129))).toBe("models.displayNameTooLong"); + }); +}); + +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", + "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 savedFailure: boolean; + let mutationGate: Promise | null; + let modelFetches: number; + let modelFetchFailure: string | null; + 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: "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", + disabled: false, custom: true, customId: "custom-1", displayName: "Custom One", + }, + ]; + mutationBodies = []; + mutationFailure = null; + savedFailure = false; + resetApiAuthFetchForTests(); + 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, + 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: {}, + disabled: [], + contextCaps: {}, + contextCapValue: 350_000, + })); + + globalThis.fetch = (async (input, init) => { + 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([ + { 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: {} }); + 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 (mutationGate) await mutationGate; + if (mutationFailure && !savedFailure) 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", + }); + if (savedFailure) return Response.json({ + error: "model display name saved but catalog refresh failed", + saved: true, + displayNameOverride: body.displayName, + }, { status: 503 }); + const row = currentModels.find(model => model.namespaced === "xai-demo/grok-4.6")!; + return Response.json({ + ok: true, + displayName: row.displayName, + displayNameOverride: row.displayNameOverride ?? null, + displayNameSource: row.displayNameSource, + }); + } + 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 () => { + resetApiAuthFetchForTests(); + 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(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"); + }); + + 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"); + expect(testWindow.document.activeElement).toBe(dialogInput()); + mutationFailure = null; + await act(async () => dialogButton("Save").click()); + await flush(); + expect(mutationBodies).toHaveLength(2); + expect(currentModels[0]!.displayNameOverride).toBe("Retry Name"); + expect(container.querySelector("dialog")).toBeNull(); + }); + + 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("The change was saved, but the model list could not be refreshed."); + expect(testWindow.document.activeElement).toBe(dialogInput()); + }); + + function currentNameText(): string { + return container.querySelector(".model-display-name-current")!.textContent ?? ""; + } + + test("first save followed by failed reload updates the snapshot and enables Reset", async () => { + await mountModels(); + await act(async () => nameTrigger().click()); + await act(async () => dialogButton("Reset name").click()); + await flush(); + mutationBodies = []; + await act(async () => nameTrigger().click()); + expect(dialogButton("Reset name").disabled).toBe(true); + modelFetchFailure = "reload failed"; + await act(async () => { + setInputValue(dialogInput(), " First Name "); + dialogButton("Save").click(); + }); + await flush(); + expect(dialogInput().value).toBe("First Name"); + expect(currentNameText()).toContain("First Name"); + expect(currentNameText()).toContain("Your name"); + expect(dialogButton("Reset name").disabled).toBe(false); + + modelFetchFailure = null; + await act(async () => dialogButton("Retry").click()); + await flush(); + expect(mutationBodies).toEqual([{ modelId: "grok-4.6", displayName: "First Name" }]); + expect(container.querySelector("dialog")).toBeNull(); + }); + + test("reset followed by failed reload clears the draft and Enter retries only the read", async () => { + await mountModels(); + await act(async () => nameTrigger().click()); + modelFetchFailure = "reload failed"; + await act(async () => dialogButton("Reset name").click()); + await flush(); + expect(dialogInput().value).toBe(""); + expect(currentNameText()).toContain("xai-demo/grok-4.6"); + expect(currentNameText()).not.toContain("Your name"); + expect(dialogButton("Reset name").disabled).toBe(true); + + modelFetchFailure = null; + await act(async () => container.querySelector("dialog form")!.dispatchEvent( + new testWindow.Event("submit", { bubbles: true, cancelable: true }), + )); + await flush(); + expect(mutationBodies).toEqual([{ modelId: "grok-4.6", displayName: null }]); + expect(container.querySelector("dialog")).toBeNull(); + expect(currentModels[0]!.displayNameOverride).toBeUndefined(); + }); + + for (const value of ["Saved Name", null]) { + test(`saved:true failure reconciles ${value === null ? "reset" : "save"} and retries the same operation`, async () => { + await mountModels(); + await act(async () => nameTrigger().click()); + savedFailure = true; + await act(async () => { + if (value === null) dialogButton("Reset name").click(); + else { + setInputValue(dialogInput(), value); + dialogButton("Save").click(); + } + }); + await flush(); + expect(dialogInput().value).toBe(value ?? ""); + expect(dialogButton("Reset name").disabled).toBe(value === null); + expect(currentNameText()).toContain(value ?? "Current name unavailable until refresh"); + expect(currentNameText()).not.toContain(value === null ? "Your name" : "Model ID fallback"); + expect(container.textContent).toContain("The change was saved"); + savedFailure = false; + await act(async () => dialogButton("Retry").click()); + await flush(); + expect(mutationBodies).toEqual([ + { modelId: "grok-4.6", displayName: value }, + { modelId: "grok-4.6", displayName: value }, + ]); + expect(container.querySelector("dialog")).toBeNull(); + }); + } + + test("editing after a saved receipt explicitly starts a new save", async () => { + await mountModels(); + await act(async () => nameTrigger().click()); + savedFailure = true; + await act(async () => dialogButton("Reset name").click()); + await flush(); + savedFailure = false; + await act(async () => setInputValue(dialogInput(), "New intention")); + await act(async () => dialogButton("Save").click()); + await flush(); + expect(mutationBodies.map(body => body.displayName)).toEqual([null, "New intention"]); + }); + + // Exercise the real global auth wrapper over an abort-aware transport. Only + // the deadline clock is controlled; the operation must supply its own signal. + for (const stage of ["mutation", "reload"] as const) { + test(`stalled ${stage} through installed API fetch releases the editor and retries a read`, async () => { + await mountModels(); + const descriptor = Object.getOwnPropertyDescriptor(AbortSignal, "timeout"); + const deadline = new AbortController(); + const budgets: number[] = []; + const seenSignals: Array = []; + let stall = true; + const transport = globalThis.fetch; + Object.defineProperty(AbortSignal, "timeout", { + configurable: true, + value: (ms: number) => { budgets.push(ms); return deadline.signal; }, + }); + const boundedTransport = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input).includes("model-display-names") || String(input).endsWith("/api/models")) { + seenSignals.push(init?.signal); + } + if (stall && (stage === "mutation" + ? init?.method === "PUT" && String(input).includes("model-display-names") + : String(input).endsWith("/api/models"))) { + // Persist the write before losing its response: abort is not rollback. + if (stage === "mutation") await transport(input, init); + return new Promise((_resolve, reject) => { + const signal = init?.signal; + if (signal?.aborted) reject(signal.reason); + else signal?.addEventListener("abort", () => reject(signal.reason), { once: true }); + }); + } + return transport(input, init); + }) as typeof fetch; + Object.defineProperty(window, "fetch", { configurable: true, value: boundedTransport }); + installApiAuthFetch(); + globalThis.fetch = window.fetch; + try { + const trigger = nameTrigger(); + await act(async () => trigger.click()); + await act(async () => { + setInputValue(dialogInput(), "Possibly saved"); + dialogButton("Save").click(); + }); + await flush(); + expect(budgets).toEqual([60_000]); + expect(seenSignals.every(signal => signal != null)).toBe(true); + if (stage === "reload") expect(seenSignals[1]).toBe(seenSignals[0]); + await act(async () => deadline.abort(new DOMException("Timed out", "TimeoutError"))); + await flush(); + expect(dialogInput().disabled).toBe(false); + expect(dialogButton("Cancel").disabled).toBe(false); + expect(dialogInput().value).toBe("Possibly saved"); + expect(container.textContent).toContain(stage === "mutation" + ? "The change may have been saved" : "The change was saved"); + expect(testWindow.document.activeElement).toBe(dialogInput()); + stall = false; + if (descriptor) Object.defineProperty(AbortSignal, "timeout", descriptor); + await act(async () => dialogButton("Retry").click()); + await flush(); + expect(mutationBodies).toHaveLength(1); + expect(currentModels[0]!.displayNameOverride).toBe("Possibly saved"); + expect(container.querySelector("dialog")).toBeNull(); + expect(testWindow.document.activeElement).toBe(trigger); + } finally { + if (descriptor) Object.defineProperty(AbortSignal, "timeout", descriptor); + else Reflect.deleteProperty(AbortSignal, "timeout"); + } + }); + } + + 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", () => { + 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 renderDialog(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 renderDialog(); + + 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 renderDialog({ 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 renderDialog({ 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(); + }); + + 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 9d775faccce38d9e16e5d544bdba0b451d170333 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:44:48 +0900 Subject: [PATCH 2/4] fix(gui): preserve display name receipts across recovery failures Keep an earlier confirmed save/reset receipt when retrying the same value and convergence fails with an ordinary HTTP error. Do not replace the saved snapshot from an unconfirmed response; retain the pending reset intent for another retry. Treat transport and response-body failures without a usable receipt as unknown outcomes, hide the stale current name, and make Retry/Enter read-only. Keep known unpersisted HTTP failures on the existing editable-draft path. Add regressions for reset -> saved:true -> HTTP failure -> success and persisted save/reset followed by rejected transport or response-body reads without abort. Local tests/typecheck/build NOT RUN by owner mandate; static diff check only. Co-authored-by: Zig Zag --- gui/src/pages/Models.tsx | 28 +++++--- gui/tests/models-display-name-editor.test.tsx | 72 +++++++++++++++++++ 2 files changed, 92 insertions(+), 8 deletions(-) diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index b7b3a0e285..c342866d7e 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -598,7 +598,11 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; displayNameSavingRef.current = true; setDisplayNameSaving(true); setDisplayNameRequestError(null); - let confirmed = displayName === undefined && displayNameRecovery?.confirmed === true; + // A failed convergence retry cannot invalidate an earlier persistence receipt + // for the same value. Editing the draft clears recovery and starts a new intent. + let confirmed = displayNameRecovery?.confirmed === true + && (displayName === undefined || displayName === displayNameRecovery.value); + let receivedReceipt = displayName === undefined; let refreshOnly = displayName === undefined; try { if (displayName !== undefined) { @@ -624,9 +628,14 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; ? await readJsonOrThrow(response, t("models.displayNameSaveFailed")) : await response.json(); bounded.signal.throwIfAborted(); - if (!result) throw new Error(t("models.displayNameSaveFailed")); - confirmed = response.ok || result.saved === true; - if (confirmed) { + if (!result || typeof result !== "object" || Array.isArray(result) + || (!response.ok && result.saved !== true && typeof result.error !== "string")) { + throw new Error(t("models.displayNameSaveFailed")); + } + receivedReceipt = true; + const receiptConfirmed = response.ok || result.saved === true; + confirmed = confirmed || receiptConfirmed; + if (receiptConfirmed) { const override = result.displayNameOverride === null ? undefined : result.displayNameOverride ?? displayName ?? undefined; const fields: Pick = { @@ -653,13 +662,16 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; finishDisplayNameEdit(); } catch (error) { if (displayNameRequestRef.current !== bounded) return; - if (bounded.signal.aborted && !confirmed) setDisplayNameCurrentPending(true); - setDisplayNameRecovery(confirmed || bounded.signal.aborted || refreshOnly - ? { value: refreshOnly || bounded.signal.aborted ? undefined : displayName, confirmed } + // A dropped connection or unreadable body can hide a committed write just + // like a timeout. Reconcile by reading; never replay an unchanged old draft. + const unknownOutcome = !receivedReceipt || bounded.signal.aborted; + if (unknownOutcome && !confirmed) setDisplayNameCurrentPending(true); + setDisplayNameRecovery(confirmed || unknownOutcome || refreshOnly + ? { value: refreshOnly || unknownOutcome ? undefined : displayName, confirmed } : null); setDisplayNameRequestError(confirmed ? t("models.displayNameSavedRefreshFailed") - : bounded.signal.aborted || refreshOnly + : unknownOutcome || refreshOnly ? t("models.displayNameOutcomeUnknown") : error instanceof Error && error.message ? error.message diff --git a/gui/tests/models-display-name-editor.test.tsx b/gui/tests/models-display-name-editor.test.tsx index be64333944..b0656391ed 100644 --- a/gui/tests/models-display-name-editor.test.tsx +++ b/gui/tests/models-display-name-editor.test.tsx @@ -389,6 +389,78 @@ describe("Models dashboard discovered display name integration", () => { }); } + test("confirmed reset survives an ordinary convergence retry error before success", async () => { + await mountModels(); + await act(async () => nameTrigger().click()); + savedFailure = true; + await act(async () => dialogButton("Reset name").click()); + await flush(); + savedFailure = false; + mutationFailure = "Temporary server failure"; + await act(async () => dialogButton("Retry").click()); + await flush(); + expect(dialogInput().value).toBe(""); + expect(dialogButton("Reset name").disabled).toBe(true); + expect(dialogButton("Retry").disabled).toBe(false); + expect(container.textContent).toContain("The change was saved"); + expect(currentNameText()).not.toContain("Your name"); + expect(currentModels[0]!.displayNameOverride).toBeUndefined(); + + mutationFailure = null; + await act(async () => container.querySelector("dialog form")!.dispatchEvent( + new testWindow.Event("submit", { bubbles: true, cancelable: true }), + )); + await flush(); + expect(mutationBodies.map(body => body.displayName)).toEqual([null, null, null]); + expect(container.querySelector("dialog")).toBeNull(); + expect(currentModels[0]!.displayNameOverride).toBeUndefined(); + }); + + for (const failure of ["transport", "body"] as const) { + for (const value of ["Saved despite disconnect", null]) { + test(`persisted ${value === null ? "reset" : "save"} with ${failure} failure retries only a read`, async () => { + await mountModels(); + const transport = globalThis.fetch; + let failedSignal: AbortSignal | null | undefined; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const response = await transport(input, init); + if (init?.method === "PUT" && String(input).includes("model-display-names")) { + failedSignal = init.signal; + if (failure === "transport") throw new TypeError("Connection closed"); + Object.defineProperty(response, "text", { + value: async () => { throw new TypeError("Response body interrupted"); }, + }); + } + return response; + }) as typeof fetch; + await act(async () => nameTrigger().click()); + await act(async () => { + if (value === null) dialogButton("Reset name").click(); + else { + setInputValue(dialogInput(), value); + dialogButton("Save").click(); + } + }); + await flush(); + expect(failedSignal?.aborted).toBe(false); + expect(currentModels[0]!.displayNameOverride).toBe(value ?? undefined); + expect(dialogInput().value).toBe(value ?? "Grok 4.6"); + expect(currentNameText()).toContain("Current name unavailable until refresh"); + expect(currentNameText()).not.toContain("Your name"); + expect(container.textContent).toContain("The change may have been saved"); + expect(dialogButton("Retry").disabled).toBe(false); + expect(dialogButton("Cancel").disabled).toBe(false); + await act(async () => container.querySelector("dialog form")!.dispatchEvent( + new testWindow.Event("submit", { bubbles: true, cancelable: true }), + )); + await flush(); + expect(mutationBodies).toEqual([{ modelId: "grok-4.6", displayName: value }]); + expect(currentModels[0]!.displayNameOverride).toBe(value ?? undefined); + expect(container.querySelector("dialog")).toBeNull(); + }); + } + } + test("editing after a saved receipt explicitly starts a new save", async () => { await mountModels(); await act(async () => nameTrigger().click()); From e352ede9f6ad2e2a5fa58d982315968c5f4520e6 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:48:54 +0900 Subject: [PATCH 3/4] fix(gui): balance name editor helper text on narrow screens Browser review found an isolated Korean ending at 390px. Balance the short helper sentence without fixed line breaks or changing the existing layout. Co-authored-by: Zig Zag --- gui/src/styles.css | 1 + 1 file changed, 1 insertion(+) diff --git a/gui/src/styles.css b/gui/src/styles.css index 630882006f..a5838be7e4 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -2672,6 +2672,7 @@ button.prov-account-row.active { cursor: default; } .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-dialog > .small { text-wrap: balance; } .model-display-name-error { margin-top: 8px; color: var(--red); From 4c1d9afaa6152b6a84c5f5a929aefbae52adc595 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 08:05:11 +0900 Subject: [PATCH 4/4] fix(gui): reconcile display name draft during snapshot render Replace effect-driven draft synchronization with a guarded render-state adjustment when the parent supplies a new confirmed model snapshot. Keep the dialog mounted, retaining focus refs, pending state and request errors. Ordinary typing and catalog polling do not replace the editor snapshot. Local tests, lint, typecheck and build NOT RUN by owner mandate. Static diff inspection only; final CI and independent review remain parent-owned. Co-authored-by: Zig Zag --- gui/src/components/ModelDisplayNameDialog.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/gui/src/components/ModelDisplayNameDialog.tsx b/gui/src/components/ModelDisplayNameDialog.tsx index c24b6612d7..2a57ff8279 100644 --- a/gui/src/components/ModelDisplayNameDialog.tsx +++ b/gui/src/components/ModelDisplayNameDialog.tsx @@ -41,6 +41,7 @@ export default function ModelDisplayNameDialog({ const titleId = useId(); const helpId = useId(); const errorId = useId(); + const [draftSnapshot, setDraftSnapshot] = useState(model); const [draft, setDraft] = useState(model.displayNameOverride ?? ""); const [validationKey, setValidationKey] = useState(null); @@ -57,11 +58,13 @@ export default function ModelDisplayNameDialog({ if (saveFailed) inputRef.current?.focus(); }, [requestError, saving]); - // Parent replaces this snapshot only after a confirmed mutation, not catalog polling. - useEffect(() => { + // Parent replaces this snapshot only after a confirmed mutation, not typing or polling. + // Adjust before committing children, preserving the mounted dialog and its focus refs. + if (draftSnapshot !== model) { + setDraftSnapshot(model); setDraft(model.displayNameOverride ?? ""); setValidationKey(null); - }, [model]); + } const validationError = validationKey ? t(validationKey) : null; const visibleError = validationError ?? requestError;