diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 1d6cfbc91..99c433bd3 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -277,7 +277,7 @@ export default function App() { {page === "dashboard" && } {page === "startup" && } {page === "providers" && } - {page === "models" && } + {page === "models" && } {page === "combos" && } {page === "subagents" && } {page === "logs" && } diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 37850fdff..1918bda86 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -310,6 +310,9 @@ export const de = { "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).", "models.active": "{active}/{total} aktiv", + "models.workspace.providers": "Anbieter", + "models.workspace.allProviders": "Alle Anbieter", + "models.workspace.mainAria": "Modelldetails", "models.combosEmpty": "Noch keine Kombos konfiguriert", "models.combosSetup": "Einrichten", "models.combosAdd": "Kombo hinzufügen", @@ -322,6 +325,7 @@ export const de = { "models.contextCapped": "350k-Limit", "models.contextCapLabel": "Kontext-Limit", "models.v2Label": "Sub-Agent", + "models.shadowCallOriginal": "⚠ 5.4-mini →", "models.v2DocsLink": "Was ist v1 / v2?", "models.v2Mode_v1": "v1", "models.v2Mode_default": "base", @@ -332,7 +336,7 @@ export const de = { "models.v2Help": "Steuert die Multi-Agent-Oberfläche für alle Modelle.\n\nv1: Klassischer Single-Thread-Agent. Jedes Modell nutzt die v1-Collab-Oberfläche.\nbase: Upstream-Standard — sol/terra nutzen v2, luna v1, andere folgen dem Codex-Feature-Flag.\nv2: Multi-Thread-Agent mit spawn_agent. Jedes Modell nutzt die v2-Collab-Oberfläche.\n\nÄnderungen gelten für neue Sitzungen.", "dash.multiAgent": "Sub-Agent", "models.v2Conflict": "[agents] max_threads ist gesetzt — codex verweigert den Start; entferne es aus config.toml", - "models.v2Applied": "multi_agent_v2 aktualisiert — gilt für neue Sitzungen (Codex-App neu starten, um die Auswahl zu aktualisieren)", + "models.v2Applied": "Sub-Agent-Modus aktualisiert — gilt für neue Sitzungen (Codex-App neu starten, um die Auswahl zu aktualisieren)", "models.v2ThreadsLabel": "Max. Threads", "models.v2ThreadsDefault": "Standard (4)", "models.v2ThreadsApplied": "Thread-Limit aktualisiert — gilt für neue Sitzungen", @@ -341,6 +345,10 @@ export const de = { "models.capValue": "Limit {value}", "models.contextCappedValue": "{value}-Limit", "models.setAll": "Alle setzen", + "models.setAllHint": "Wendet das {value}-Kontext-Limit auf alle gerouteten Anbieter an. Native Anbieter bleiben unberührt.", + "models.collapseAll": "Alle einklappen", + "models.expandAll": "Alle ausklappen", + "models.orderHint": "Reihenfolge in der Modellauswahl: Subagents-Auswahl (in der festgelegten Reihenfolge) → übrige geroutete Modelle alphabetisch nach Anbieter, dann Modell-ID → native Modelle. Sichtbarkeitsschalter filtern nur; sie ändern diese Reihenfolge nicht.", "models.custom": "Benutzerdefiniert…", "models.customApply": "Anwenden", "models.customPlaceholder": "Tokens (z. B. 420000)", @@ -393,7 +401,6 @@ export const de = { "models.allowlistLabel": "Nur ausgewählte", "models.allowlistHint": "Nur geprüfte Modelle gehen in den Katalog (leer = alle). Nützlich für Anbieter mit tausenden Modellen.", "models.selectedCount": "{n} ausgewählt", - "models.orderHint": "Reihenfolge in der Modellauswahl: Subagents-Auswahl (in der festgelegten Reihenfolge) → übrige geroutete Modelle alphabetisch nach Anbieter, dann Modell-ID → native Modelle. Sichtbarkeitsschalter filtern nur; sie ändern diese Reihenfolge nicht.", "sub.subtitle": "Codex {cmd} bewirbt nur die ersten 5 Modelle (nach Priorität) als Overrides. Wähle hier bis zu 5 — natives gpt oder geroutet — und opencodex setzt ihre Katalog-Priorität, sodass genau diese führen. Jedes andere Modell bleibt über seinen exakten Namen aufrufbar; dies steuert nur die Anzeige.", "sub.featured": "Empfohlen", "sub.orderHint": "Die hier gewählte und angezeigte Reihenfolge bestimmt die Plätze 1–5 oben in der Codex-Modellauswahl und die Standard-Modellkandidaten für {cmd}.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 32a6ecfd3..e433d6a84 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -326,6 +326,9 @@ export const en = { "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).", "models.active": "{active}/{total} active", + "models.workspace.providers": "Providers", + "models.workspace.allProviders": "All providers", + "models.workspace.mainAria": "Model details", "models.combosEmpty": "No combos configured yet", "models.combosSetup": "Set up", "models.combosAdd": "Add combo", @@ -338,6 +341,7 @@ export const en = { "models.contextCapped": "350k cap", "models.contextCapLabel": "Context cap", "models.v2Label": "Sub-agent", + "models.shadowCallOriginal": "⚠ 5.4-mini →", "models.v2DocsLink": "What is v1 / v2?", "models.v2Mode_v1": "v1", "models.v2Mode_default": "base", @@ -348,7 +352,7 @@ export const en = { "models.v2Help": "Controls the multi-agent surface for all models.\n\nv1: Classic single-thread agent. Every model uses the v1 collab surface.\nbase: Upstream defaults — sol/terra use v2, luna uses v1, others follow the codex feature flag.\nv2: Multi-thread agent with spawn_agent. Every model uses the v2 collab surface.\n\nChanges apply to new sessions.", "dash.multiAgent": "Sub-agent", "models.v2Conflict": "[agents] max_threads is set — codex will refuse to start; remove it from config.toml", - "models.v2Applied": "multi_agent_v2 updated — applies to new sessions (restart the Codex app to refresh the picker)", + "models.v2Applied": "Sub-agent mode updated — applies to new sessions (restart the Codex app to refresh the picker)", "models.v2ThreadsLabel": "Max threads", "models.v2ThreadsDefault": "default (4)", "models.v2ThreadsApplied": "Thread limit updated — applies to new sessions", @@ -357,6 +361,10 @@ export const en = { "models.capValue": "Cap {value}", "models.contextCappedValue": "{value} cap", "models.setAll": "Set all", + "models.setAllHint": "Apply the {value} context cap to every routed provider. Native providers are unaffected.", + "models.collapseAll": "Collapse all", + "models.expandAll": "Expand all", + "models.orderHint": "Picker order: Subagents picks (in the selected order) → remaining routed models alphabetically by provider, then model ID → native models. Visibility switches only filter models; they do not change this order.", "models.custom": "Custom…", "models.customApply": "Apply", "models.customPlaceholder": "Tokens (e.g. 420000)", @@ -409,7 +417,6 @@ export const en = { "models.allowlistLabel": "Only selected", "models.allowlistHint": "Only checked models ship to the catalog (empty = all). Useful for providers exposing thousands of models.", "models.selectedCount": "{n} selected", - "models.orderHint": "Picker order: Subagents picks (in the selected order) → remaining routed models alphabetically by provider, then model ID → native models. Visibility switches only filter models; they do not change this order.", // subagents "sub.subtitle": "Codex's {cmd} advertises only the first 5 models (by priority) as overrides. Pick up to 5 here — native gpt or routed — and opencodex sets their catalog priority so exactly these lead. Any other model is still callable by its exact name; this only controls what's shown.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 75722b222..374149484 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -318,6 +318,9 @@ export const ja: Record = { "models.nativeGroupLabel": "OpenAI ネイティブ", "models.nativeHint": "パススルーモデルはプロバイダーで選択したプールまたはダイレクトアカウントオプションを使用します。一つオフにすると Codex ピッカーから隠します(カタログエントリは保持されるので、再有効化で正確に復元されます)。", "models.active": "{active}/{total} アクティブ", + "models.workspace.providers": "プロバイダー", + "models.workspace.allProviders": "すべてのプロバイダー", + "models.workspace.mainAria": "モデルの詳細", "models.combosEmpty": "まだコンボが設定されていません", "models.combosSetup": "セットアップ", "models.combosAdd": "コンボを追加", @@ -330,6 +333,7 @@ export const ja: Record = { "models.contextCapped": "350k 上限", "models.contextCapLabel": "コンテキスト上限", "models.v2Label": "サブエージェント", + "models.shadowCallOriginal": "⚠ 5.4-mini →", "models.v2DocsLink": "v1 / v2 とは?", "models.v2Mode_v1": "v1", "models.v2Mode_default": "ベース", @@ -340,7 +344,7 @@ export const ja: Record = { "models.v2Help": "すべてのモデルのマルチエージェントサーフェスを制御します。\n\nv1: クラシックな単一スレッドエージェント。すべてのモデルが v1 コラボサーフェスを使います。\nベース: 上流のデフォルト — sol/terra は v2、luna は v1、それ以外は codex のフィーチャーフラグに従います。\nv2: spawn_agent を備えたマルチスレッドエージェント。すべてのモデルが v2 コラボサーフェスを使います。\n\n変更は新規セッションに適用されます。", "dash.multiAgent": "サブエージェント", "models.v2Conflict": "[agents] max_threads が設定されています — codex は起動を拒否します; config.toml から削除してください", - "models.v2Applied": "multi_agent_v2 を更新しました — 新規セッションに適用(ピッカーを更新するには Codex アプリを再起動)", + "models.v2Applied": "サブエージェントモードを更新しました — 新規セッションに適用(ピッカーを更新するには Codex アプリを再起動)", "models.v2ThreadsLabel": "最大スレッド数", "models.v2ThreadsDefault": "デフォルト (4)", "models.v2ThreadsApplied": "スレッド上限を更新しました — 新規セッションに適用", @@ -349,6 +353,10 @@ export const ja: Record = { "models.capValue": "上限 {value}", "models.contextCappedValue": "{value} 上限", "models.setAll": "すべて設定", + "models.setAllHint": "{value} のコンテキスト上限をすべてのルーティング済みプロバイダーに適用します。ネイティブプロバイダーには影響しません。", + "models.collapseAll": "すべて折りたたむ", + "models.expandAll": "すべて展開", + "models.orderHint": "ピッカーの順序: サブエージェントの選択(選択順) → 残りのルーティングモデルはプロバイダー別、次にモデル ID 別のアルファベット順 → ネイティブモデル。表示切り替えはモデルをフィルタするだけで、この順序は変更しません。", "models.custom": "カスタム…", "models.customApply": "適用", "models.customPlaceholder": "トークン (例: 420000)", @@ -374,7 +382,6 @@ export const ja: Record = { "models.allowlistLabel": "選択のみ", "models.allowlistHint": "チェックしたモデルのみカタログに送信します(空 = すべて)。数千のモデルを公開するプロバイダーで有用です。", "models.selectedCount": "{n} 件選択", - "models.orderHint": "ピッカーの順序: サブエージェントの選択(選択順) → 残りのルーティングモデルはプロバイダー別、次にモデル ID 別のアルファベット順 → ネイティブモデル。表示切り替えはモデルをフィルタするだけで、この順序は変更しません。", // subagents "sub.subtitle": "Codex の {cmd} は最初の 5 モデル(優先度順)のみをオーバーライドとして通知します。ここで最大 5 つを選んでください — ネイティブ gpt またはルーティング — opencodex がカタログ優先度を設定し、これらが先頭に来るようにします。他のモデルも正確な名前で呼び出し可能です; これは表示のみを制御します。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 6f8deb367..a1c524895 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -320,6 +320,9 @@ export const ko: Record = { "models.nativeGroupLabel": "OpenAI 네이티브", "models.nativeHint": "프로바이더에서 선택한 풀 또는 직접 계정 옵션으로 서빙되는 passthrough 모델입니다. 끄면 Codex 선택기에서 숨겨지고, 카탈로그 항목은 유지되므로 다시 켜면 그대로 복원됩니다.", "models.active": "{active}/{total} 활성", + "models.workspace.providers": "프로바이더", + "models.workspace.allProviders": "모든 프로바이더", + "models.workspace.mainAria": "모델 세부정보", "models.combosEmpty": "아직 설정된 콤보가 없습니다", "models.combosSetup": "설정하기", "models.combosAdd": "콤보 추가하기", @@ -332,6 +335,7 @@ export const ko: Record = { "models.contextCapped": "350k 제한", "models.contextCapLabel": "컨텍스트 제한", "models.v2Label": "서브에이전트", + "models.shadowCallOriginal": "⚠ 5.4-mini →", "models.v2Mode_v1": "v1", "models.v2Mode_default": "base", "models.v2Mode_v2": "v2", @@ -342,7 +346,7 @@ export const ko: Record = { "models.v2DocsLink": "v1 / v2가 뭔가요?", "dash.multiAgent": "서브에이전트", "models.v2Conflict": "[agents] max_threads가 남아 있어 codex가 부팅을 거부합니다 — config.toml에서 제거하세요", - "models.v2Applied": "multi_agent_v2 변경됨 — 새 세션부터 적용 (피커 갱신은 Codex 앱 재시작)", + "models.v2Applied": "서브에이전트 모드 변경됨 — 새 세션부터 적용 (피커 갱신은 Codex 앱 재시작)", "models.v2ThreadsLabel": "최대 스레드", "models.v2ThreadsDefault": "기본값 (4)", "models.v2ThreadsApplied": "스레드 한도 변경됨 — 새 세션부터 적용", @@ -351,6 +355,10 @@ export const ko: Record = { "models.capValue": "{value} 제한", "models.contextCappedValue": "{value} 제한", "models.setAll": "전체 적용", + "models.setAllHint": "{value} 컨텍스트 상한을 라우팅된 모든 프로바이더에 적용합니다. 네이티브 프로바이더는 영향을 받지 않습니다.", + "models.collapseAll": "모두 접기", + "models.expandAll": "모두 펼치기", + "models.orderHint": "피커 순서: Subagents에서 지정한 순서 → 나머지 라우팅 모델(프로바이더, 모델 ID 순 알파벳 정렬) → 네이티브 모델. 노출 토글은 모델을 필터링할 뿐 이 순서를 바꾸지 않습니다.", "models.custom": "직접 입력…", "models.customApply": "적용", "models.customPlaceholder": "토큰 (예: 420000)", @@ -403,7 +411,6 @@ export const ko: Record = { "models.allowlistLabel": "선택만 노출", "models.allowlistHint": "체크한 모델만 카탈로그에 노출돼요 (비우면 전체). 수천 개 모델을 노출하는 프로바이더에 유용해요.", "models.selectedCount": "{n}개 선택", - "models.orderHint": "피커 순서: Subagents에서 지정한 순서 → 나머지 라우팅 모델(프로바이더, 모델 ID 순 알파벳 정렬) → 네이티브 모델. 노출 토글은 모델을 필터링할 뿐 이 순서를 바꾸지 않습니다.", // subagents "sub.subtitle": "Codex의 {cmd} 는 우선순위 상위 5개 모델만 오버라이드로 노출합니다. 여기서 최대 5개를 선택하면 — 네이티브 gpt 또는 라우팅된 모델 — opencodex가 카탈로그 우선순위를 설정해 정확히 이들이 앞에 옵니다. 다른 모델도 정확한 이름으로 호출할 수 있으며, 이 설정은 표시 항목만 제어합니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index f2a82146f..66a98cc36 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -326,6 +326,9 @@ export const ru: Record = { "models.nativeGroupLabel": "Нативные OpenAI", "models.nativeHint": "Модели сквозного проброса используют режим аккаунта (пул или прямое подключение), выбранный на странице «Провайдеры». Отключение модели скрывает её из селектора Codex (запись в каталоге сохраняется, поэтому при повторном включении она восстанавливается в точности).", "models.active": "{active}/{total} активно", + "models.workspace.providers": "Провайдеры", + "models.workspace.allProviders": "Все провайдеры", + "models.workspace.mainAria": "Сведения о моделях", "models.combosEmpty": "Комбо ещё не настроены", "models.combosSetup": "Настроить", "models.combosAdd": "Добавить комбо", @@ -338,6 +341,7 @@ export const ru: Record = { "models.contextCapped": "Лимит 350k", "models.contextCapLabel": "Лимит контекста", "models.v2Label": "Подагент", + "models.shadowCallOriginal": "⚠ 5.4-mini →", "models.v2DocsLink": "Что такое v1 / v2?", "models.v2Mode_v1": "v1", "models.v2Mode_default": "base", @@ -348,7 +352,7 @@ export const ru: Record = { "models.v2Help": "Управляет мультиагентной поверхностью для всех моделей.\n\nv1: Классический однопоточный агент. Каждая модель использует поверхность взаимодействия v1.\nbase: Вышестоящие значения по умолчанию — sol/terra используют v2, luna использует v1, остальные следуют функциональному флагу codex.\nv2: Многопоточный агент со spawn_agent. Каждая модель использует поверхность взаимодействия v2.\n\nИзменения применяются к новым сессиям.", "dash.multiAgent": "Подагент", "models.v2Conflict": "Задан [agents] max_threads — codex откажется запускаться; удалите его из config.toml", - "models.v2Applied": "multi_agent_v2 обновлён — применяется к новым сессиям (перезапустите приложение Codex, чтобы обновить селектор моделей)", + "models.v2Applied": "Режим подагента обновлён — применяется к новым сессиям (перезапустите приложение Codex, чтобы обновить селектор моделей)", "models.v2ThreadsLabel": "Макс. потоков", "models.v2ThreadsDefault": "по умолчанию (4)", "models.v2ThreadsApplied": "Лимит потоков обновлён — применяется к новым сессиям", @@ -357,6 +361,10 @@ export const ru: Record = { "models.capValue": "Лимит {value}", "models.contextCappedValue": "Лимит {value}", "models.setAll": "Применить ко всем", + "models.setAllHint": "Применяет лимит контекста {value} ко всем маршрутизируемым провайдерам. Нативные провайдеры не затрагиваются.", + "models.collapseAll": "Свернуть все", + "models.expandAll": "Развернуть все", + "models.orderHint": "Порядок в селекторе: модели, выбранные на странице «Подагенты» (в заданном порядке) → остальные маршрутизируемые модели по алфавиту — сначала по провайдеру, затем по ID модели → нативные модели. Переключатели видимости лишь фильтруют модели и не меняют этот порядок.", "models.custom": "Другое…", "models.customApply": "Применить", "models.customPlaceholder": "Токены (напр. 420000)", @@ -409,7 +417,6 @@ export const ru: Record = { "models.allowlistLabel": "Только выбранные", "models.allowlistHint": "В каталог попадают только отмеченные модели (пусто = все). Полезно для провайдеров, предоставляющих тысячи моделей.", "models.selectedCount": "Выбрано: {n}", - "models.orderHint": "Порядок в селекторе: модели, выбранные на странице «Подагенты» (в заданном порядке) → остальные маршрутизируемые модели по алфавиту — сначала по провайдеру, затем по ID модели → нативные модели. Переключатели видимости лишь фильтруют модели и не меняют этот порядок.", // subagents "sub.subtitle": "{cmd} в Codex объявляет как переопределения только первые 5 моделей (по приоритету). Выберите здесь до 5 моделей — нативные gpt или маршрутизируемые — и opencodex задаст им приоритет в каталоге так, чтобы именно они шли первыми. Любую другую модель по-прежнему можно вызвать по её точному имени; эта настройка управляет только тем, что отображается.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index a2b4d04f3..e8e164337 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -320,6 +320,9 @@ export const zh: Record = { "models.nativeGroupLabel": "OpenAI 原生", "models.nativeHint": "Passthrough 模型使用在提供方页面选择的账户池或直连选项。关闭后会从 Codex 选择器中隐藏(目录条目保留,重新开启即可完整恢复)。", "models.active": "{active}/{total} 已启用", + "models.workspace.providers": "提供方", + "models.workspace.allProviders": "所有提供方", + "models.workspace.mainAria": "模型详情", "models.combosEmpty": "尚未配置组合", "models.combosSetup": "设置", "models.combosAdd": "添加组合", @@ -332,6 +335,7 @@ export const zh: Record = { "models.contextCapped": "350k 限制", "models.contextCapLabel": "上下文限制", "models.v2Label": "子代理", + "models.shadowCallOriginal": "⚠ 5.4-mini →", "models.v2Mode_v1": "v1", "models.v2Mode_default": "base", "models.v2Mode_v2": "v2", @@ -342,7 +346,7 @@ export const zh: Record = { "models.v2DocsLink": "v1 / v2 是什么?", "dash.multiAgent": "子代理", "models.v2Conflict": "[agents] max_threads 仍存在 — codex 将拒绝启动,请从 config.toml 移除", - "models.v2Applied": "multi_agent_v2 已更新 — 新会话生效(重启 Codex 应用以刷新选择器)", + "models.v2Applied": "子代理模式已更新 — 新会话生效(重启 Codex 应用以刷新选择器)", "models.v2ThreadsLabel": "最大线程", "models.v2ThreadsDefault": "默认 (4)", "models.v2ThreadsApplied": "线程上限已更新 — 新会话生效", @@ -351,6 +355,10 @@ export const zh: Record = { "models.capValue": "限制 {value}", "models.contextCappedValue": "{value} 限制", "models.setAll": "全部设置", + "models.setAllHint": "将 {value} 上下文上限应用到所有已路由的提供方。原生提供方不受影响。", + "models.collapseAll": "全部折叠", + "models.expandAll": "全部展开", + "models.orderHint": "选择器顺序:Subagents 中的选择(按所选顺序)→ 其余已路由模型(依次按提供方、模型 ID 字母排序)→ 原生模型。可见性开关仅用于筛选,不会改变此顺序。", "models.custom": "自定义…", "models.customApply": "应用", "models.customPlaceholder": "令牌 (例如 420000)", @@ -403,7 +411,6 @@ export const zh: Record = { "models.allowlistLabel": "仅所选", "models.allowlistHint": "仅勾选的模型进入目录(留空 = 全部)。适用于暴露成千上万模型的提供商。", "models.selectedCount": "已选 {n} 个", - "models.orderHint": "选择器顺序:Subagents 中的选择(按所选顺序)→ 其余已路由模型(依次按提供方、模型 ID 字母排序)→ 原生模型。可见性开关仅用于筛选,不会改变此顺序。", // subagents "sub.subtitle": "Codex 的 {cmd} 仅将优先级最高的前 5 个模型作为覆盖项公开。在此最多选择 5 个 — 原生 gpt 或已路由模型 — opencodex 会设置它们的目录优先级,使其正好排在前面。其他模型仍可按确切名称调用;此设置仅控制显示项。", diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index a09dae303..953e05af0 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -1,14 +1,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Switch, Notice, EmptyState, Select } from "../ui"; +import { Switch, Notice, EmptyState, Select, Tooltip } from "../ui"; import { IconChevron, IconBoxes, IconInfo, IconShuffle } from "../icons"; import { useT } from "../i18n/shared"; import type { TFn, TKey } from "../i18n/shared"; +import { readViewMode, type ViewMode } from "../view-mode"; import { modelLabel } from "../model-display"; import { type ComboItem, parseComboList } from "../combo-workspace-data"; import { buildProviderModelGroups, type ConfiguredProviderSummary, type ProviderDiscoverySummary, + type ProviderModelGroup, } from "../models-groups"; interface ModelRow { @@ -75,7 +77,7 @@ function activeModelOptions(models: ModelRow[], disabled: Set): { value: return options; } -export default function Models({ apiBase }: { apiBase: string }) { +export default function Models({ apiBase, viewMode }: { apiBase: string; viewMode?: ViewMode }) { const t: TFn = useT(); const [models, setModels] = useState([]); const [providers, setProviders] = useState([]); @@ -128,6 +130,9 @@ export default function Models({ apiBase }: { apiBase: string }) { try { return localStorage.getItem("ocx-models-combos-open") === "1"; } catch { return false; } }); + // App owns the in-session view mode; fallback to persisted mode for isolated renders/tests. + const workspaceView = (viewMode ?? readViewMode()) === "workspace"; + const [selectedProvider, setSelectedProvider] = useState(null); const toggleCombosOpen = () => { setCombosOpen(prev => { const next = !prev; @@ -191,6 +196,12 @@ export default function Models({ apiBase }: { apiBase: string }) { .then(r => r.json()) as ConfiguredProviderSummary[]; void loadV2(); // best-effort, independent of the models fetch void loadShadowCall(); + const nextGroups = buildProviderModelGroups(data, providerData); + setSelectedProvider(prev => ( + prev !== null && !nextGroups.some(group => group.provider === prev) + ? null + : prev + )); setModels(data); setProviders(providerData); setDisabled(collectDisabledNamespaced(data)); @@ -290,6 +301,13 @@ export default function Models({ apiBase }: { apiBase: string }) { return n; }); }; + const setAllCollapsed = (collapse: boolean) => { + setCollapsed(() => { + const n = collapse ? new Set(groups.map(group => group.provider)) : new Set(); + try { localStorage.setItem("ocx-models-collapsed", JSON.stringify([...n])); } catch { /* quota */ } + return n; + }); + }; const putCap = async (body: Record) => { setBusy(true); @@ -538,54 +556,266 @@ export default function Models({ apiBase }: { apiBase: string }) { if (loading) return
{t("models.loading")}
; - return ( + const renderGroup = (group: ProviderModelGroup) => { + const { provider, rows, native, liveModels, discovery } = group; + const isCollapsed = collapsed.has(provider); + const activeCount = rows.filter(m => !disabled.has(m.namespaced)).length; + const capOn = contextCaps[provider] === contextCapValue; + const isNative = native; + const discoveryFailure = liveModels && discovery?.status === "failed" ? discovery : undefined; + const q = (search[provider] ?? "").trim().toLowerCase(); + const filtered = q ? rows.filter(m => m.id.toLowerCase().includes(q)) : rows; + // Display-only: enabled models float to the top of each provider group so they + // stay findable in long lists. The sort is stable, so the server order is kept + // inside each partition, and this does not affect the picker order above + // (visibility toggles still only filter). + const sorted = [...filtered].sort((a, b) => Number(disabled.has(a.namespaced)) - Number(disabled.has(b.namespaced))); + const shown = limit[provider] ?? PAGE; + const visible = sorted.slice(0, shown); + const remaining = filtered.length - visible.length; + const allOn = rows.every(m => !disabled.has(m.namespaced)); + const allOff = rows.every(m => disabled.has(m.namespaced)); + const bulkToggle = (enable: boolean) => { + const next = new Set(disabled); + for (const m of rows) { if (enable) next.delete(m.namespaced); else next.add(m.namespaced); } + apply(next); + }; + return ( +
+
toggleCollapse(provider)} + className={`row group-head models-provider-head${isCollapsed ? "" : " open"}`}> + + {provider} + {isNative && {t("models.nativeGroupLabel")}} + {discoveryFailure && ( + + {t("models.discoveryFailedBadge")} + + )} + {t("models.active", { active: activeCount, total: rows.length })} +
e.stopPropagation()}> + {!isNative && ( + + )} + + + {!isNative && <> + toggleProviderCap(provider)} disabled={busy} label={t("models.capValue", { value: fmtK(contextCapValue) })} /> + {t("models.capValue", { value: fmtK(contextCapValue) })} + } +
+
+ {!isCollapsed && ( +
+ {isNative &&

{t("models.nativeHint")}

} + {rows.length === 0 && ( + + )} + {rows.length > PAGE / 2 && ( + setSearch(prev => ({ ...prev, [provider]: e.target.value }))} + aria-label={t("models.search")} + /> + )} + {visible.map(m => { + const off = disabled.has(m.namespaced); + return ( +
onRowEnter(m.namespaced, e.currentTarget)} + onMouseLeave={onRowLeave} + onFocus={(e) => onRowFocus(m.namespaced, e.currentTarget)} + onBlur={(e) => { + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setHoveredModel(null); + }} + > +
+ toggle(m.namespaced)} disabled={busy} label={m.native ? m.id : m.namespaced} /> + {m.native ? modelLabel(m.id) : m.namespaced} + {m.custom && ( + + {t("models.customBadge")} + + )} + {m.contextCapped && {t("models.contextCappedValue", { value: fmtK(m.contextCap ?? contextCapValue) })}} +
+ {hoveredModel?.namespaced === m.namespaced && (() => { + const r = hoveredModel.rect; + const tipTop = r.bottom + 4; + const flipUp = tipTop + 360 > window.innerHeight; + return ( +
+
{m.native ? m.id : m.namespaced}
+ {m.displayName &&
{m.displayName}
} + {m.custom && ( + + {t("models.customBadge")} + + )} +
+ {t("models.tipProvider")} + {m.provider} + {(m.contextWindow || m.contextCap) && ( + <> + {t("models.tipContext")} + {fmtK(m.contextWindow ?? m.contextCap ?? 0)} + + )} + {m.inputModalities && m.inputModalities.length > 0 && ( + <> + {t("models.tipModalities")} + {m.inputModalities.join(", ")} + + )} + {t("models.tipStatus")} + {off ? t("models.tipDisabled") : t("models.tipActive")} +
+ {m.custom && m.customId && ( +
+ + +
+ )} +
+ ); + })()} +
+ ); + })} + {remaining > 0 && ( + + )} +
+ )} +
+ ); + }; + + const visibleGroups = selectedProvider + ? groups.filter(group => group.provider === selectedProvider) + : groups; + + const controlsBlock = ( <> -
-

{t("nav.models")}

- {t("models.active", { active: models.length - disabled.size, total: models.length })} -
-

{t("models.subtitle")}

- {status && {status}} +
+
+ {t("models.shadowCallIntercept")} + {t("models.shadowCallOriginal")} + void saveShadowCall({ enabled: !shadowCall?.enabled })} disabled={!shadowCall || shadowCallSaving} label={t("models.shadowCallIntercept")} /> +
+ { setShadowCall(c => c ? { ...c, model: v } : c); void saveShadowCall({ model: v }); }} disabled={!shadowCall || shadowCallSaving || !shadowCall.enabled} label={t("models.shadowCallIntercept")} /> + {v2 && ( +
+ {t("models.v2Label")} +
+ {(["v1", "default", "v2"] as const).map(mode => ( + + ))} +
+ +
+ )}
- {v2 && ( -
- {t("models.v2Label")} -
- {(["v1", "default", "v2"] as const).map(mode => ( - - ))} -
- + {v2 && (v2.enabled || v2.agentsMaxThreadsConflict || v2Note) && ( +
{v2.enabled && ( <> - {t("models.v2ThreadsLabel")} + {t("models.v2ThreadsLabel")} setSearch(prev => ({ ...prev, [provider]: e.target.value }))} - aria-label={t("models.search")} - /> - )} - {visible.map(m => { - const off = disabled.has(m.namespaced); - return ( -
onRowEnter(m.namespaced, e.currentTarget)} - onMouseLeave={onRowLeave} - onFocus={(e) => onRowFocus(m.namespaced, e.currentTarget)} - onBlur={(e) => { - if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setHoveredModel(null); - }} - > -
- toggle(m.namespaced)} disabled={busy} label={m.native ? m.id : m.namespaced} /> - {m.native ? modelLabel(m.id) : m.namespaced} - {m.custom && ( - - {t("models.customBadge")} - - )} - {m.contextCapped && {t("models.contextCappedValue", { value: fmtK(m.contextCap ?? contextCapValue) })}} -
- {hoveredModel?.namespaced === m.namespaced && (() => { - const r = hoveredModel.rect; - const tipTop = r.bottom + 4; - const flipUp = tipTop + 360 > window.innerHeight; - return ( -
-
{m.native ? m.id : m.namespaced}
- {m.displayName &&
{m.displayName}
} - {m.custom && ( - - {t("models.customBadge")} - - )} -
- {t("models.tipProvider")} - {m.provider} - {(m.contextWindow || m.contextCap) && ( - <> - {t("models.tipContext")} - {fmtK(m.contextWindow ?? m.contextCap ?? 0)} - - )} - {m.inputModalities && m.inputModalities.length > 0 && ( - <> - {t("models.tipModalities")} - {m.inputModalities.join(", ")} - - )} - {t("models.tipStatus")} - {off ? t("models.tipDisabled") : t("models.tipActive")} -
- {m.custom && m.customId && ( -
- - -
- )} -
- ); - })()} -
- ); - })} - {remaining > 0 && ( - - )} -
- )} -
- ); - })} + const collapseControls = ( +
+ + +
+ ); + + const emptyStateBlock = ( + <> {groups.length === 0 && ( } title={t("models.noRouted")}> {t("models.noRoutedHint")} )} + + ); + const modalsBlock = ( + <> {v2HelpOpen && (
setV2HelpOpen(false)} onKeyDown={e => { if (e.key === "Escape") setV2HelpOpen(false); }}>
e.stopPropagation()}> @@ -1123,6 +1167,89 @@ export default function Models({ apiBase }: { apiBase: string }) { )} ); + + if (workspaceView) { + return ( +
+
+

{t("nav.models")}

+
+ {t("models.active", { active: models.length - disabled.size, total: models.length })} +
+
+

{t("models.subtitle")}

+ {status && {status}} +
+ +
+ {controlsBlock} + {combosBlock} + {collapseControls} + { + // eslint-disable-next-line react-hooks/refs -- The hover ref is only read by row event handlers nested in this renderer. + visibleGroups.map(group => renderGroup(group)) + } + {groups.length === 0 && emptyStateBlock} +
+
+ {modalsBlock} +
+ ); + } + + return ( + <> +
+

{t("nav.models")}

+
+ {t("models.active", { active: models.length - disabled.size, total: models.length })} +
+
+

{t("models.subtitle")}

+ {status && {status}} + {controlsBlock} + {combosBlock} + {collapseControls} + { + // eslint-disable-next-line react-hooks/refs -- The hover ref is only read by row event handlers nested in this renderer. + groups.map(group => renderGroup(group)) + } + {groups.length === 0 && emptyStateBlock} + {modalsBlock} + + ); } function discoveryFailureReason( diff --git a/gui/src/styles-dashboard-workspace.css b/gui/src/styles-dashboard-workspace.css index ab8001e8f..ffd9f4d12 100644 --- a/gui/src/styles-dashboard-workspace.css +++ b/gui/src/styles-dashboard-workspace.css @@ -138,12 +138,12 @@ } .dash-sidecar-card__row .custom-select { - /* Fixed width so both sidecar triggers stay equal from first paint — - model labels like anthropic/claude-… must not grow the card later. */ - flex: 0 0 14rem; - width: 14rem; - max-width: 14rem; - min-width: 0; + /* Reserve baseline space to prevent row reflow, but keep the trigger + compact instead of forcing a permanently wide fixed width. */ + flex: 0 1 auto; + width: auto; + min-width: clamp(10rem, 24vw, 11.5rem); + max-width: min(100%, 14rem); } .dash-sidecar-card__row .select-trigger { diff --git a/gui/src/styles-models-workspace.css b/gui/src/styles-models-workspace.css new file mode 100644 index 000000000..8866a85e4 --- /dev/null +++ b/gui/src/styles-models-workspace.css @@ -0,0 +1,293 @@ +/* ============================================================================ + models-workspace layout shell + shared Models controls + Namespace: models-workspace- (workspace shell/rail/main) + Shared controls: models-control-*, models-shadow-*, models-v2-* (workspace + classic) + Uses only design tokens from styles.css. No gradients. + ============================================================================ */ + +.main-inner:has(.models-workspace-shell) { + max-width: 1200px; +} + +.models-workspace-shell { + width: 100%; + min-width: 0; + container-type: inline-size; + container-name: models-workspace; +} + +.models-workspace-root { + display: grid; + grid-template-columns: minmax(240px, 280px) minmax(0, 1fr); + gap: var(--space-4); + width: 100%; + max-width: 100%; + min-height: 480px; +} + +/* ── Rail ─────────────────────────────────────────────── */ + +.models-workspace-rail { + display: flex; + flex-direction: column; + gap: var(--space-3); + border-right: 1px solid var(--border); + padding-right: var(--space-3); + min-width: 0; +} + +.models-workspace-rail-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} + +.models-workspace-rail-title { + font-size: var(--text-body); + font-weight: var(--weight-semibold); + color: var(--text); +} + +.models-workspace-rail-count { + font-family: var(--mono); + font-size: 11px; + color: var(--faint); + font-variant-numeric: tabular-nums; +} + +.models-workspace-rail-list { + display: flex; + flex-direction: column; + gap: 2px; + overflow-y: auto; + max-height: 640px; + min-height: 0; +} + +.models-workspace-rail-row { + display: flex; + flex-direction: column; + gap: var(--space-0-5); + width: 100%; + min-height: 46px; + appearance: none; + background: none; + border: none; + border-radius: var(--radius-sm); + padding: var(--space-1-5) var(--space-2); + cursor: pointer; + text-align: left; + color: inherit; + font: inherit; + overflow: hidden; +} + +.models-workspace-rail-row:hover { + background: var(--surface-2); +} + +.models-workspace-rail-row--selected { + background: var(--surface-2); + box-shadow: inset 0 0 0 1px var(--border); +} + +.models-workspace-rail-name { + font-size: var(--text-body); + font-weight: var(--weight-medium); + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.models-workspace-rail-meta { + font-size: var(--text-caption); + color: var(--faint); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-variant-numeric: tabular-nums; +} + +/* ── Main pane ────────────────────────────────────────── */ + +.models-workspace-main { + display: flex; + flex-direction: column; + gap: var(--space-3); + min-width: 0; +} + +/* Controls row: shadow call on the left, sub-agent mode pinned right. + Right group does not move when the model name length changes. */ +.models-control-top-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + column-gap: clamp(var(--space-3), 2vw, var(--space-6)); + row-gap: var(--space-2); + margin-bottom: var(--space-2); + min-width: 0; +} + +.models-shadow-row { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + flex-wrap: nowrap; +} + +.models-shadow-label { + white-space: nowrap; +} + +.models-shadow-warning { + white-space: nowrap; +} + +.models-shadow-model-slot { + flex: 0 1 auto; + min-width: 9.5rem; + max-width: 12.5rem; +} + +.models-shadow-model-slot .custom-select { + width: auto; + min-width: 100%; + max-width: 100%; +} + +.models-shadow-model-slot .select-trigger { + width: auto; + min-width: 100%; + max-width: 100%; + justify-content: space-between; +} + +.models-shadow-model-slot .select-trigger > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.models-v2-mode-row { + display: flex; + align-items: center; + gap: var(--space-2); + flex: 0 0 auto; + min-width: 0; + flex-wrap: nowrap; + justify-self: end; +} + +.models-v2-detail-row { + gap: var(--space-2); + margin-bottom: var(--space-2); + flex-wrap: wrap; + align-items: center; +} + +/* ── Provider group header (Classic + Workspace) ─────── */ + +.models-provider-head { + flex-wrap: wrap; + row-gap: var(--space-2); + column-gap: var(--space-2); + align-items: center; + min-width: 0; +} + +.models-provider-head > span.text-body { + min-width: 0; + overflow-wrap: anywhere; +} + +.models-provider-actions { + margin-left: auto; + flex-wrap: wrap; + justify-content: flex-end; + align-items: center; + gap: 6px; + min-width: 0; + max-width: 100%; +} + +/* ── Responsive ───────────────────────────────────────── */ + +@media (max-width: 1160px) { + .models-control-top-row { + grid-template-columns: 1fr; + } + + .models-shadow-row { + flex: 1 1 100%; + flex-wrap: wrap; + } + + .models-shadow-model-slot { + flex: 1 1 12rem; + width: auto; + min-width: min(100%, 10rem); + max-width: 100%; + } + + .models-v2-mode-row { + flex-wrap: wrap; + justify-self: start; + } +} + +/* + Stack rail above the main pane when the workspace shell is squeezed — + typically by the persistent desktop sidebar at ~900–1024px viewports — + not only at the mobile drawer breakpoint. +*/ +@container models-workspace (max-width: 720px) { + .models-workspace-root { + grid-template-columns: 1fr; + } + + .models-workspace-rail { + border-right: none; + border-bottom: 1px solid var(--border); + padding-right: 0; + padding-bottom: var(--space-3); + } + + .models-workspace-rail-list { + max-height: 240px; + } + + .models-provider-actions { + width: 100%; + margin-left: 0; + justify-content: flex-start; + } +} + +/* Mobile: sidebar is a drawer; still prefer rail-on-top for touch. */ +@media (max-width: 768px) { + .models-workspace-root { + grid-template-columns: 1fr; + } + + .models-workspace-rail { + border-right: none; + border-bottom: 1px solid var(--border); + padding-right: 0; + padding-bottom: var(--space-3); + } + + .models-workspace-rail-list { + max-height: 240px; + } + + .models-provider-actions { + width: 100%; + margin-left: 0; + justify-content: flex-start; + } +} diff --git a/gui/src/styles.css b/gui/src/styles.css index 2fce23943..c44c8ebda 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -13,6 +13,7 @@ @import "./styles/provider-workspace-settings.css"; @import "./styles/provider-overview-dashboard.css"; @import "./styles-combos-workspace.css"; +@import "./styles-models-workspace.css"; @import "./styles-dashboard-workspace.css"; :root { @@ -1209,3 +1210,31 @@ button.prov-account-row.active { cursor: default; } .model-tip-key { color: var(--muted); } .model-tip-val { color: var(--text); font-family: var(--mono); font-size: var(--text-label); } .model-tip-actions { display: flex; gap: 6px; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border-soft); } + +/* ---- hover tooltip (styled replacement for native title) ---- */ +.ocx-tooltip { position: relative; display: inline-flex; align-items: center; } +.ocx-tooltip-bubble { + position: absolute; z-index: var(--z-popover); + width: max-content; + background: color-mix(in oklab, canvas 84%, transparent); + backdrop-filter: blur(20px) saturate(1.4); + -webkit-backdrop-filter: blur(20px) saturate(1.4); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 8px 12px; + box-shadow: 0 8px 32px rgb(0 0 0 / 0.16); + color: var(--text); + font-size: var(--text-label); + line-height: var(--leading-body); + white-space: normal; + pointer-events: none; + animation: ocx-tooltip-in var(--motion-fast) ease-out; +} +.ocx-tooltip-bubble--top { bottom: calc(100% + 8px); left: 50%; transform: translateX(-50%); } +.ocx-tooltip-bubble--bottom { top: calc(100% + 8px); left: 50%; transform: translateX(-50%); } +.ocx-tooltip-bubble--left { right: calc(100% + 8px); top: 50%; transform: translateY(-50%); } +.ocx-tooltip-bubble--right { left: calc(100% + 8px); top: 50%; transform: translateY(-50%); } +@keyframes ocx-tooltip-in { + from { opacity: 0; } + to { opacity: 1; } +} diff --git a/gui/src/ui.tsx b/gui/src/ui.tsx index ce4a534cc..903712b94 100644 --- a/gui/src/ui.tsx +++ b/gui/src/ui.tsx @@ -237,3 +237,47 @@ export function EmptyState({ icon, title, children, className, style }: { icon?:
); } + +/* Hover/focus tooltip — styled replacement for the native `title` attribute. */ +export function Tooltip({ content, children, side = "top", maxWidth = 280 }: { + content: ReactNode; + children: ReactNode; + side?: "top" | "bottom" | "left" | "right"; + maxWidth?: number; +}) { + const [open, setOpen] = useState(false); + const tipId = useId(); + const timer = useRef(null); + + const show = () => { + if (timer.current !== null) window.clearTimeout(timer.current); + timer.current = window.setTimeout(() => setOpen(true), 150); + }; + const hide = () => { + if (timer.current !== null) { window.clearTimeout(timer.current); timer.current = null; } + setOpen(false); + }; + useEffect(() => () => { if (timer.current !== null) window.clearTimeout(timer.current); }, []); + + return ( + { + if (event.key === "Escape") hide(); + }} + aria-describedby={open ? tipId : undefined} + tabIndex={0} + > + {children} + {open && ( + + {content} + + )} + + ); +} diff --git a/gui/src/use-app-route-state.ts b/gui/src/use-app-route-state.ts index ab20196e3..7f7c5ae63 100644 --- a/gui/src/use-app-route-state.ts +++ b/gui/src/use-app-route-state.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { hashBelongsToPage, readPageFromHash, @@ -37,16 +37,16 @@ export function useAppRouteState() { }); const viewModeRef = useRef(viewMode); - const commitViewMode = (next: ViewMode) => { + const commitViewMode = useCallback((next: ViewMode) => { viewModeRef.current = next; setViewMode(next); - }; + }, []); useEffect(() => { viewModeRef.current = viewMode; }, [viewMode]); - const applyHashAction = (rawHash: string) => { + const applyHashAction = useCallback((rawHash: string) => { // Prefer in-memory mode so a storage failure cannot revert a live session toggle. const action = resolveAppHashChange(rawHash, viewModeRef.current); if (action.replaceTo) replaceHash(action.replaceTo); @@ -54,7 +54,7 @@ export function useAppRouteState() { // Route page and view mode update in the same event — never via startTransition. setPageState(action.page); if (action.viewMode) commitViewMode(action.viewMode); - }; + }, [commitViewMode]); const toggleGlobalWorkspace = () => { const next = toggleViewMode(viewModeRef.current); @@ -81,7 +81,7 @@ export function useAppRouteState() { window.removeEventListener("hashchange", onRouteHash); window.removeEventListener("popstate", onRouteHash); }; - }, []); + }, [applyHashAction]); useEffect(() => { const rawHash = normalizeHashPath(window.location.hash); diff --git a/gui/tests/models-provider-head.test.ts b/gui/tests/models-provider-head.test.ts new file mode 100644 index 000000000..ea6572072 --- /dev/null +++ b/gui/tests/models-provider-head.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from "bun:test"; + +test("Models provider headers use wrap-safe classes and stop collapse on action clicks", async () => { + const src = await Bun.file(new URL("../src/pages/Models.tsx", import.meta.url)).text(); + expect(src).toContain("models-provider-head"); + expect(src).toContain("models-provider-actions"); + // Actions live in their own stopPropagation wrapper so All On / caps do not toggle collapse. + expect(src).toMatch(/className="row models-provider-actions"\s+onClick=\{e => e\.stopPropagation\(\)\}/); + // Both Classic and Workspace share renderGroup — no duplicate unclassed group-head for providers. + const providerHeads = src.match(/models-provider-head/g) ?? []; + expect(providerHeads.length).toBeGreaterThanOrEqual(1); + expect(src).toContain("models.allOn"); + expect(src).toContain("models.allOff"); +}); + +test("Models workspace stacks via content-width container query before mobile drawer", async () => { + const css = await Bun.file(new URL("../src/styles-models-workspace.css", import.meta.url)).text(); + expect(css).toContain("container-name: models-workspace"); + expect(css).toContain("container-type: inline-size"); + expect(css).toContain("@container models-workspace (max-width: 720px)"); + expect(css).toContain(".models-provider-head"); + expect(css).toContain(".models-provider-actions"); + expect(css).toMatch(/\.models-provider-head\s*\{[^}]*flex-wrap:\s*wrap/s); + expect(css).toMatch(/\.models-provider-actions\s*\{[^}]*flex-wrap:\s*wrap/s); + // Mobile media rule retained for drawer layouts. + expect(css).toContain("@media (max-width: 768px)"); +});