diff --git a/devlog/_plan/260904_dashboard_minimal/assets/031_models_after.png b/devlog/_plan/260904_dashboard_minimal/assets/031_models_after.png new file mode 100644 index 0000000000..d721fb7ef2 Binary files /dev/null and b/devlog/_plan/260904_dashboard_minimal/assets/031_models_after.png differ diff --git a/devlog/_plan/260904_dashboard_minimal/assets/031_models_disclosures_open.png b/devlog/_plan/260904_dashboard_minimal/assets/031_models_disclosures_open.png new file mode 100644 index 0000000000..82bf0e2d7f Binary files /dev/null and b/devlog/_plan/260904_dashboard_minimal/assets/031_models_disclosures_open.png differ diff --git a/gui/src/components/subagents-workspace/SubagentDelegationSection.tsx b/gui/src/components/subagents-workspace/SubagentDelegationSection.tsx index e631570734..7628a8a417 100644 --- a/gui/src/components/subagents-workspace/SubagentDelegationSection.tsx +++ b/gui/src/components/subagents-workspace/SubagentDelegationSection.tsx @@ -7,7 +7,9 @@ * gets called first. */ import { useState } from "react"; -import { Select } from "../../ui"; +import { Select, Tooltip } from "../../ui"; +import { IconInfo } from "../../icons"; +import type { TKey } from "../../i18n/shared"; import { useT } from "../../i18n/shared"; import { formatNamespacedModelId } from "../../provider-icons"; import type { DelegationPatch, DelegationModelOption } from "../../pages/use-subagent-delegation"; @@ -63,6 +65,46 @@ export default function SubagentDelegationSection({ )} + {/* + The multi-agent surface switch (v1 / base / v2). It lived on Models and on the + dashboard; both were editors for the same /api/v2 value. It is a delegation + setting, so it sits above the model that gets delegated to. The long help text + stays reachable from the focusable info button. + */} +
+
+
+ {t("models.v2Label")} + + +
+
+ + {t("models.v2DocsLink")} + +
+
+
+
+ {(["v1", "default", "v2"] as const).map(mode => ( + + ))} +
+
+
{t("sub.delegation.model")}
diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 35504d78df..d7202581c2 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -485,6 +485,10 @@ export const de: Record = { "models.subtitle.combos": "Geordnete Modellgruppen, die unter einer id antworten. Ziele mit Failover verketten oder die Last mit einer Balancing-Strategie verteilen.", "models.subtitle.compatibility": "Schreibgeschützte Kompatibilitätsmatrix aus der Lab-Projektion.", "models.subtitle.routing": "Policy-Profile, Dry-Run-Auswertung und quellenbasierte Routing-Analysen.", + "models.advanced": "Erweitert", + "models.groupMore": "Weitere Anbieteraktionen anzeigen", + "models.subtitleAria": "So funktioniert der Katalog", + "models.orderHintAria": "So wird die Reihenfolge im Picker bestimmt", "models.subtitle": "Steuere, welche Modelle Codex sieht — natives GPT-Passthrough und geroutete Anbieter, nach Anbieter gruppiert (Kopfzeile zum Einklappen anklicken). Ausgeblendete Modelle fehlen in Katalog und Auswahl, bleiben aber per genauer ID aufrufbar. Änderungen gelten bei der nächsten Codex-Runde — opencodex invalidiert Codex 5-Minuten-Modell-Cache, kein Neustart nötig.", "models.nativeGroupLabel": "OpenAI nativ", "models.nativeHint": "Passthrough-Modelle verwenden die unter Anbieter gewählte Pool- oder Direkt-Option. Ausblenden entfernt sie aus der Codex-Auswahl (Katalogeintrag bleibt, Reaktivierung stellt exakt wieder her). Ein hier hinzugefügtes Modell wird als gerouteter `openai/`-Selektor registriert, nicht als neue reine Passthrough-ID.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 7b196d7143..73c376938d 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -510,6 +510,10 @@ export const en = { "models.subtitle.combos": "Ordered groups of models that answer as one id. Chain targets with failover or spread the load with a balancing strategy.", "models.subtitle.compatibility": "Read-only compatibility verdict matrix from lab projection evidence.", "models.subtitle.routing": "Policy profiles, dry-run evaluation, and source-backed routing analytics.", + "models.advanced": "Advanced", + "models.groupMore": "Show more provider actions", + "models.subtitleAria": "How the catalog works", + "models.orderHintAria": "How picker order is decided", "models.subtitle": "Toggle which models Codex sees — native GPT passthrough and routed providers, grouped by provider (click a header to collapse). Hidden models stay off the catalog + model picker but remain directly callable by exact id. Changes apply on the next Codex turn — opencodex invalidates Codex's 5-min model cache so no restart is needed.", "models.nativeGroupLabel": "OpenAI native", "models.nativeHint": "Passthrough models use the Pool or Direct account option selected on Providers. Toggling one off hides it from the Codex picker (the catalog entry is kept, so re-enabling restores it exactly). Adding a model here registers a routed `openai/` selector, not a new bare passthrough id.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 0a49d9bee4..e60046e060 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -495,6 +495,10 @@ export const fr: Record = { "models.subtitle.combos": "Groupes ordonnés de modèles qui répondent sous un même identifiant. Enchaînez les cibles avec le repli ou répartissez la charge avec une stratégie d’équilibrage.", "models.subtitle.compatibility": "Matrice en lecture seule des verdicts de compatibilité issus des preuves de projection du laboratoire.", "models.subtitle.routing": "Profils de stratégie, évaluation à blanc et analyses de routage fondées sur les sources.", + "models.advanced": "Avancé", + "models.groupMore": "Afficher plus d’actions du fournisseur", + "models.subtitleAria": "Fonctionnement du catalogue", + "models.orderHintAria": "Comment l’ordre du sélecteur est décidé", "models.subtitle": "Choisissez les modèles visibles par Codex — accès direct aux GPT natifs et fournisseurs routés, regroupés par fournisseur (cliquez sur un en-tête pour le réduire). Les modèles masqués sont retirés du catalogue et du sélecteur, mais restent directement accessibles par leur identifiant exact. Les modifications s’appliquent au prochain tour Codex — opencodex invalide le cache de modèles de Codex de 5 min, sans nécessiter de redémarrage.", "models.nativeGroupLabel": "OpenAI natif", "models.nativeHint": "Les modèles en accès direct utilisent l’option de compte Groupe ou Direct sélectionnée dans Fournisseurs. La désactivation d’un modèle le masque dans le sélecteur Codex (son entrée de catalogue est conservée afin que sa réactivation la restaure à l’identique). Ajouter un modèle ici enregistre un sélecteur routé `openai/`, et non un nouvel identifiant passthrough brut.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 6f46bb9360..f27979e6e0 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -493,6 +493,10 @@ export const ja: Record = { "models.subtitle.combos": "複数のモデルを 1 つの id にまとめ、順に応答させます。failover でターゲットを連鎖させるか、分散戦略で負荷を分散します。", "models.subtitle.compatibility": "ラボ投影証拠の読み取り専用互換性判定マトリクス。", "models.subtitle.routing": "ポリシープロファイル、dry-run 評価、そして根拠の残るルーティング分析です。", + "models.advanced": "詳細設定", + "models.groupMore": "プロバイダーのその他の操作を表示", + "models.subtitleAria": "カタログの仕組み", + "models.orderHintAria": "ピッカー順序の決まり方", "models.subtitle": "Codex に表示するモデルを切り替えます — ネイティブ GPT パススルーとルーティングプロバイダー、プロバイダー別(ヘッダーをクリックで折りたたみ)。非表示モデルはカタログとピッカーから外れますが、正確な id での直接呼び出しは可能です。変更は次回の Codex ターンで適用 — opencodex は Codex の 5 分間モデルキャッシュを無効化するので再起動は不要です。", "models.nativeGroupLabel": "OpenAI ネイティブ", "models.nativeHint": "パススルーモデルはプロバイダーで選択したプールまたはダイレクトアカウントオプションを使用します。一つオフにすると Codex ピッカーから隠します(カタログエントリは保持されるので、再有効化で正確に復元されます)。 ここでモデルを追加すると、bare passthrough id ではなくルーティングされた `openai/` セレクタとして登録されます。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 3b0fb7debc..02596d7b46 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -496,6 +496,10 @@ export const ko: Record = { "models.subtitle.combos": "여러 모델을 하나의 id로 묶어 순서대로 응답하게 합니다. failover로 대상을 연결하거나 분산 전략으로 부하를 나눕니다.", "models.subtitle.compatibility": "랩 프로젝션 증거의 읽기 전용 호환성 판정 행렬.", "models.subtitle.routing": "정책 프로필, dry-run 평가, 그리고 근거가 남는 라우팅 분석입니다.", + "models.advanced": "고급", + "models.groupMore": "프로바이더 추가 작업 표시", + "models.subtitleAria": "카탈로그 동작 방식", + "models.orderHintAria": "피커 순서가 정해지는 방식", "models.subtitle": "Codex가 보는 모델을 켜고 끕니다 — 네이티브 GPT passthrough와 라우팅된 모델을 프로바이더별로 묶어 보여줍니다(헤더를 클릭하면 접힘). 숨긴 모델은 카탈로그와 선택기에서 빠지지만 정확한 id로 직접 호출할 수 있습니다. 변경 사항은 다음 Codex 턴에 적용됩니다 — opencodex가 Codex의 5분 모델 캐시를 무효화하므로 재시작이 필요 없습니다.", "models.nativeGroupLabel": "OpenAI 네이티브", "models.nativeHint": "프로바이더에서 선택한 풀 또는 직접 계정 옵션으로 서빙되는 passthrough 모델입니다. 끄면 Codex 선택기에서 숨겨지고, 카탈로그 항목은 유지되므로 다시 켜면 그대로 복원됩니다. 여기서 모델을 추가하면 bare passthrough id가 아니라 라우팅된 `openai/` selector로 등록됩니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 4c27f149b0..9d8437fee4 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -498,6 +498,10 @@ export const ru: Record = { "models.subtitle.combos": "Упорядоченные группы моделей, отвечающие под одним id. Связывайте цели через failover или распределяйте нагрузку стратегией балансировки.", "models.subtitle.compatibility": "Матрица совместимости только для чтения из проекции лаборатории.", "models.subtitle.routing": "Профили политик, оценка в режиме dry-run и аналитика маршрутизации с подтверждением источников.", + "models.advanced": "Дополнительно", + "models.groupMore": "Показать дополнительные действия провайдера", + "models.subtitleAria": "Как работает каталог", + "models.orderHintAria": "Как определяется порядок в выборе", "models.subtitle": "Управляйте тем, какие модели видит Codex — нативные GPT (сквозной проброс) и модели маршрутизируемых провайдеров, сгруппированные по провайдеру (нажмите на заголовок, чтобы свернуть группу). Скрытые модели исчезают из каталога и селектора, но остаются вызываемыми по точному id. Изменения применяются на следующем ходе Codex — opencodex сбрасывает 5-минутный кэш моделей Codex, поэтому перезапуск не требуется.", "models.nativeGroupLabel": "Нативные OpenAI", "models.nativeHint": "Модели сквозного проброса используют режим аккаунта (пул или прямое подключение), выбранный на странице «Провайдеры». Отключение модели скрывает её из селектора Codex (запись в каталоге сохраняется, поэтому при повторном включении она восстанавливается в точности). Добавление модели здесь регистрирует маршрутизируемый селектор `openai/`, а не новый «голый» passthrough-идентификатор.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 9bf760c0d3..0b26fa1a82 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -501,6 +501,10 @@ export const tr: Record = { "models.subtitle.combos": "Tek bir kimlik olarak yanıt veren sıralı model grupları. Hedefleri failover ile zincirleyin veya yükü dengeleme stratejisiyle dağıtın.", "models.subtitle.compatibility": "Lab projeksiyon kanıtından salt okunur uyumluluk matrisi.", "models.subtitle.routing": "Politika profilleri ve simülasyon değerlendirmesi.", + "models.advanced": "Gelişmiş", + "models.groupMore": "Daha fazla sağlayıcı işlemi göster", + "models.subtitleAria": "Katalog nasıl çalışır", + "models.orderHintAria": "Seçici sırası nasıl belirlenir", "models.subtitle": "Codex'in göreceği modelleri açıp kapatın.", "models.nativeGroupLabel": "Yerel OpenAI", "models.nativeHint": "Doğrudan geçiş modelleri Sağlayıcılar bölümünde seçilen Havuz veya Doğrudan seçeneğini kullanır. Buradan model eklemek, yeni bir düz passthrough kimliği değil, yönlendirilmiş bir `openai/` seçici kaydeder.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 39c652acc6..29813ad625 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -376,6 +376,10 @@ export const zhTW: Record = { "prov.manageCodexAccounts": "管理 Codex 帳號", "prov.openaiApiMissing": "需要 API 金鑰", "prov.openaiApiSetup": "設定 API 金鑰", + "models.advanced": "進階", + "models.groupMore": "顯示更多供應商操作", + "models.subtitleAria": "目錄的運作方式", + "models.orderHintAria": "選擇器順序的決定方式", "models.subtitle": "開關 Codex 可見的模型 — 原生 GPT passthrough 與已路由模型按供應商分組(點選標題可摺疊)。已停用的模型會從目錄和模型選擇器中隱藏。更改在下一個 Codex 回合生效 — opencodex 會使 Codex 的 5 分鐘模型快取失效,因此無需重新啟動。", "models.nativeGroupLabel": "OpenAI 原生", "models.nativeHint": "Passthrough 模型使用在供應商頁面選擇的帳號池或直連選項。關閉後會從 Codex 選擇器中隱藏(目錄條目保留,重新開啟即可完整恢復)。 在此新增模型會註冊為路由的 `openai/` 選擇器,而非新的裸 passthrough id。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 90ec15878a..754e721d18 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -493,6 +493,10 @@ export const zh: Record = { "models.subtitle.combos": "把多个模型合成一个 id 依次应答。用 failover 串联目标,或用均衡策略分摊负载。", "models.subtitle.compatibility": "来自实验室投影证据的只读兼容性判定矩阵。", "models.subtitle.routing": "策略配置、dry-run 评估,以及有据可查的路由分析。", + "models.advanced": "高级", + "models.groupMore": "显示更多提供商操作", + "models.subtitleAria": "目录的工作方式", + "models.orderHintAria": "选择器顺序的决定方式", "models.subtitle": "开关 Codex 可见的模型 — 原生 GPT passthrough 与已路由模型按提供方分组(点击标题可折叠)。隐藏的模型不会出现在目录和模型选择器中,但仍可按精确 id 直接调用。更改在下一个 Codex 回合生效 — opencodex 会使 Codex 的 5 分钟模型缓存失效,因此无需重启。", "models.nativeGroupLabel": "OpenAI 原生", "models.nativeHint": "Passthrough 模型使用在提供方页面选择的账户池或直连选项。关闭后会从 Codex 选择器中隐藏(目录条目保留,重新开启即可完整恢复)。 在此添加模型将注册为路由的 `openai/` 选择器,而不是新的裸 passthrough id。", diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index d849780e49..4c00641422 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -4,7 +4,7 @@ import type { AppServerStateOutcome } from "../codex-app-server-state"; import { useCodexRestart } from "../use-codex-restart"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Switch, Notice, EmptyState, Select, Tooltip } from "../ui"; -import { IconChevron, IconBoxes, IconInfo, IconCheck, IconAlert, IconRefresh, IconPencil } from "../icons"; +import { IconChevron, IconBoxes, IconInfo, IconCheck, IconAlert, IconPencil } from "../icons"; import { useT } from "../i18n/shared"; import type { TFn, TKey } from "../i18n/shared"; import { modelLabel } from "../model-display"; @@ -255,13 +255,12 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; const [aliases, setAliases] = useState({ providers: {}, models: {}, defaults: { global: false, providers: {} } }); const [showAliases, setShowAliases] = useState(false); const [presetBusy, setPresetBusy] = useState(null); - const [v2Loading, setV2Loading] = useState(true); + const [, setV2Loading] = useState(true); const [v2Busy, setV2Busy] = useState(false); const [v2Note, setV2Note] = useState(""); const v2BusyRef = useRef(false); const [threadsCustom, setThreadsCustom] = useState(""); const [showThreadsCustom, setShowThreadsCustom] = useState(false); - const [v2HelpOpen, setV2HelpOpen] = useState(false); const [customModalOpen, setCustomModalOpen] = useState(false); const reloadAliases = useCallback(async (signal?: AbortSignal) => { @@ -683,6 +682,11 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; * panels report theirs up once mounted, and a tab that has never been opened has * nothing truthful to say. */ + // Combos/routing show their onboarding sentence only while the workspace is empty; the + // compatibility tab keeps its one line (it is a caveat, not onboarding). + const tabIsEmpty = tab === "combos" ? comboCount === 0 + : tab === "routing" ? routingCount === 0 + : true; const tabMeta = useMemo(() => ({ catalog: catalogCountReady ? t("models.active", { active: effectiveVisibleCount, total: models.length }) @@ -926,10 +930,6 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; } }; - const setMultiAgentMode = async (mode: "v1" | "default" | "v2") => { - if (!v2 || v2.multiAgentMode === mode) return; - await putV2Setting({ multiAgentMode: mode }); - }; /** @@ -1246,6 +1246,14 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string;
+ + + {/* Rarely used per-provider actions (aliases, custom model, presets, context + cap) fold into a labelled disclosure so the header row reads as + identity + on/off instead of a six-control wall. */} +
+ +
void setDefaultAliases(!(aliases.defaults.providers[provider] ?? aliases.defaults.global), provider)} @@ -1339,8 +1347,6 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; ); })()} - -
{/* The label names the FUNCTION. It used to be `models.capValue` - "기본 128k" - which is a value masquerading as a name: even a @@ -1412,6 +1418,8 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; aria-haspopup="dialog" >{t("models.contextSettings")}
+
+
{!isCollapsed && ( @@ -1600,38 +1608,6 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string;
- {(v2Loading || v2) && ( -
- {t("models.v2Label")} -
- {(["v1", "default", "v2"] as const).map(mode => ( - - ))} -
- -
- )} {v2 && v2.multiAgentMode === "v2" && (
@@ -1749,15 +1725,15 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; ); })()} -
-
); const collapseControls = (
+ + @@ -1779,27 +1755,6 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; const modalsBlock = ( <> - {v2HelpOpen && ( -
setV2HelpOpen(false)} onKeyDown={e => { if (e.key === "Escape") setV2HelpOpen(false); }}> -
e.stopPropagation()}> -
-

{t("models.v2Label")}

- -
-
- {t("models.v2Help")} -
- -
- -
-
-
- )} {contextModalProvider && (
- {controlsBlock} + {/* + Catalog-wide policy (new-model default, aliases, shadow-call, context cap) is + set once and rarely revisited; it used to be a wall of switches above the list. + One closed disclosure keeps every control reachable and the list first. + */} +
+ {t("models.advanced")} + {controlsBlock} +
{collapseControls} {showAliases && (
@@ -2203,14 +2166,15 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; <>

{t("nav.models")}

-
- -
+ {/* + The catalog's four-line explanation (cache semantics, hidden ids still resolve) is + reference, not a decision; it lives on a focusable info button instead of above + the controls. Other tabs keep a one-line subtitle only in their empty state. + */} + +
{t(SUBTITLE_TKEY[tab])}

+ {tab !== "catalog" && tabIsEmpty &&

{t(SUBTITLE_TKEY[tab])}

} {/* Panels mount lazily and then stay mounted, hidden — a half-typed combo draft diff --git a/gui/src/pages/use-subagent-delegation.ts b/gui/src/pages/use-subagent-delegation.ts index 4df63db21c..9e5149d3ce 100644 --- a/gui/src/pages/use-subagent-delegation.ts +++ b/gui/src/pages/use-subagent-delegation.ts @@ -29,7 +29,9 @@ export type UltraModeState = { }; export type UltraModePatch = { - multiAgentModeHintText: string | null; + multiAgentModeHintText?: string | null; + /** The v1/base/v2 switch; moved here from Models (030) so delegation settings have one home. */ + multiAgentMode?: "v1" | "default" | "v2"; }; type DelegationResponse = { diff --git a/gui/src/styles.css b/gui/src/styles.css index fe50480908..51b5df44d0 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -1195,6 +1195,20 @@ select.input { appearance: none; } flex: 0 0 auto; line-height: normal; } +/* Catalog-wide policy lives behind one disclosure; per-provider rarely-used actions behind a + ⋯ disclosure in the header row. Both are native details: keyboard and aria-expanded come + free, and the revealed controls stay inline (DOM tab order), so neither claims a menu role. */ +.models-advanced { margin: 0 0 12px; } +.models-advanced > summary { cursor: pointer; list-style: none; display: inline-flex; align-items: center; gap: 6px; padding: 4px 0; } +.models-advanced > summary::-webkit-details-marker { display: none; } +.models-advanced > summary::before { content: ""; width: 0; height: 0; border-left: 5px solid var(--muted); border-top: 4px solid transparent; border-bottom: 4px solid transparent; transition: transform var(--motion-fast); } +.models-advanced[open] > summary::before { transform: rotate(90deg); } +.models-advanced[open] > summary { margin-bottom: 8px; } +.models-group-more { display: inline-flex; align-items: center; } +.models-group-more > summary { list-style: none; cursor: pointer; min-width: 28px; justify-content: center; } +.models-group-more > summary::-webkit-details-marker { display: none; } +.models-group-more-body { flex-wrap: wrap; gap: 8px; } +.models-group-more[open] { flex-wrap: wrap; } .switch.switch-labeled::before { content: ""; width: 34px; diff --git a/gui/tests/codex-stale-banner.test.ts b/gui/tests/codex-stale-banner.test.ts index ba2c3f6506..8501e16ae5 100644 --- a/gui/tests/codex-stale-banner.test.ts +++ b/gui/tests/codex-stale-banner.test.ts @@ -8,7 +8,6 @@ import { describe, expect, test } from "bun:test"; import { fetchCodexAppServerState } from "../src/codex-app-server-state"; -const BANNER_SRC = await Bun.file(new URL("../src/components/codex-stale-banner.tsx", import.meta.url)).text(); const MODELS_SRC = await Bun.file(new URL("../src/pages/Models.tsx", import.meta.url)).text(); const APP_TSX_SRC = await Bun.file(new URL("../src/App.tsx", import.meta.url)).text(); @@ -120,11 +119,17 @@ describe("Models page wiring", () => { expect(src.indexOf(" { + test("no restart action inside the tablist, and none in the page head either", () => { // Every child of ModelsTabStrip is role="tab"; a mutation button there breaks - // the ARIA contract. - const head = src.slice(src.indexOf('className="page-head"'), src.indexOf(" { + test("the controls block renders inside a closed details, before the collapse controls", () => { + const at = models.indexOf('
'); + expect(at).toBeGreaterThan(-1); + expect(models.slice(at, at + 400)).toContain("{controlsBlock}"); + expect(models.indexOf("{collapseControls}")).toBeGreaterThan(at); + // Closed by default: no `open` attribute on the element. + expect(models).not.toMatch(/
]*\bopen\b/); + }); + + test("the v1/base/v2 radiogroup left Models and lives in the Subagents delegation section", () => { + expect(models).not.toContain("models-v2-mode-row"); + expect(models).not.toContain("const setMultiAgentMode"); + expect(models).not.toContain("v2HelpOpen"); + expect(delegation).toContain('role="radiogroup" aria-label={t("models.v2Label")}'); + expect(delegation).toContain("onUltraModeSave({ multiAgentMode: mode })"); + // The long help text is reachable from a focusable Tooltip, not a modal. + expect(delegation).toContain('content={t("models.v2Help")}'); + }); + + test("the subtitle and order hint became focusable tooltips with accessible names", () => { + expect(models).not.toContain("models-order-hint"); + expect(models).toContain('content={t("models.subtitle")}'); + expect(models).toContain('{t("models.subtitleAria")}'); + expect(models).toContain('content={t("models.orderHint")}'); + expect(models).toContain('{t("models.orderHintAria")}'); + // Non-catalog subtitles only in the empty state. + expect(models).toContain('{tab !== "catalog" && tabIsEmpty &&

'); + }); + + test("the page head no longer carries a third restart button", () => { + const head = models.slice(models.indexOf('className="page-head"'), models.indexOf(" { + test("edit and all-on/all-off stay inline; aliases, custom model, presets and cap fold into the details", () => { + const at = models.indexOf('

'); + const more = models.indexOf('
', at); + expect(more).toBeGreaterThan(at); + const inline = models.slice(at, more); + expect(inline).toContain("models-alias-edit"); + expect(inline).toContain('{t("models.allOn")}'); + expect(inline).toContain('{t("models.allOff")}'); + const folded = models.slice(more, models.indexOf("
", more)); + expect(folded).toContain('label={t("models.useDefaultAliases")}'); + expect(folded).toContain('{t("models.customAdd")}'); + expect(folded).toContain('aria-label={t("models.presetLabel")}'); + expect(folded).toContain("models-cap-cluster"); + // Labelled disclosure, never a menu role. + expect(folded).toContain('aria-label={t("models.groupMore")}'); + expect(folded).not.toContain('role="menu"'); + }); + + test("both disclosures have summary styling that hides the native marker", () => { + expect(css).toContain(".models-advanced > summary::-webkit-details-marker { display: none; }"); + expect(css).toContain(".models-group-more > summary::-webkit-details-marker { display: none; }"); + }); +}); diff --git a/gui/tests/models-keep-native-v1-placement.test.ts b/gui/tests/models-keep-native-v1-placement.test.ts index 4e3969df58..ec1cde3c91 100644 --- a/gui/tests/models-keep-native-v1-placement.test.ts +++ b/gui/tests/models-keep-native-v1-placement.test.ts @@ -68,13 +68,16 @@ test("v2 setting writes adopt the response instead of a no-op refetch", () => { expect(helper).not.toContain("void loadV2()"); }); -test("both v2 surface setters route through the shared writer", () => { +test("the keep-native setter routes through the shared writer; the mode switch left for Subagents", () => { const setters = modelsSource.slice( - modelsSource.indexOf("const setMultiAgentMode"), + modelsSource.indexOf("const setKeepNativeChatGptOnV1"), modelsSource.indexOf("const putV2Threads"), ); - expect(setters).toContain("putV2Setting({ multiAgentMode: mode })"); expect(setters).toContain("putV2Setting({ keepNativeChatGptOnV1: next })"); expect(setters).not.toContain("void loadV2()"); + // The v1/base/v2 radiogroup is a delegation setting and lives on Subagents now + // (030): Models no longer owns a multiAgentMode writer. + expect(modelsSource).not.toContain("const setMultiAgentMode"); + expect(modelsSource).not.toContain("putV2Setting({ multiAgentMode: mode })"); }); diff --git a/gui/tests/subagents-ultra-mode.test.tsx b/gui/tests/subagents-ultra-mode.test.tsx index 73e34907c0..2c12cbd379 100644 --- a/gui/tests/subagents-ultra-mode.test.tsx +++ b/gui/tests/subagents-ultra-mode.test.tsx @@ -173,3 +173,50 @@ test("a save refresh from an old API server cannot overwrite a newer server", as expect(ultraSwitch().disabled).toBe(true); expect(ultraSwitch().getAttribute("aria-pressed")).toBe("false"); }); + +test("clicking a surface mode on Subagents PUTs /api/v2 with multiAgentMode and re-reads it", async () => { + // Server starts in v1; after the PUT the re-read returns the new mode, and the radiogroup + // must follow the server, not the click. + v2Responses = [{ ok: true, body: { enabled: false, multiAgentMode: "v1", multiAgentModeHintText: null } }]; + let serverMode: "v1" | "default" | "v2" = "v1"; + const previousFetch = globalThis.fetch; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (url: string, init?: RequestInit) => { + requests.push({ url: String(url), init }); + const path = new URL(String(url), "http://localhost/").pathname; + if (path === "/api/v2") { + if (init?.method === "PUT") { + const body = JSON.parse(String(init.body)) as { multiAgentMode?: typeof serverMode }; + if (body.multiAgentMode) serverMode = body.multiAgentMode; + return response({ ok: true }); + } + return response({ enabled: false, multiAgentMode: serverMode, multiAgentModeHintText: null }); + } + return (previousFetch as typeof fetch)(url, init); + }, + }); + + root = createRoot(container); + await act(async () => { + root!.render(); + }); + await act(async () => { await new Promise(r => testWindow.setTimeout(r, 30)); }); + + const group = container.querySelector('[role="radiogroup"][aria-label="Sub-agent mode"]') + ?? [...container.querySelectorAll('[role="radiogroup"]')].find(g => g.querySelector('[role="radio"]')); + expect(group).toBeDefined(); + const radios = [...group!.querySelectorAll('[role="radio"]')]; + expect(radios.map(r => r.getAttribute("aria-checked"))).toEqual(["true", "false", "false"]); + + await act(async () => { radios[2]!.click(); }); + await act(async () => { await new Promise(r => testWindow.setTimeout(r, 30)); }); + + const put = requests.find(r => r.init?.method === "PUT" && r.url.endsWith("/api/v2")); + expect(put).toBeDefined(); + expect(JSON.parse(String(put!.init!.body))).toEqual({ multiAgentMode: "v2" }); + // The follow-up GET landed and the radiogroup reflects the server. + const gets = requests.filter(r => (r.init?.method ?? "GET") === "GET" && r.url.endsWith("/api/v2")); + expect(gets.length).toBeGreaterThanOrEqual(2); + expect(radios.map(r => r.getAttribute("aria-checked"))).toEqual(["false", "false", "true"]); +});