From 443879172e5981874e2c3885e7433012e69de324 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:24:09 +0200 Subject: [PATCH 01/14] gui: add Models workspace view (provider rail + main pane) Adds a workspace layout for the Models tab following the Providers workspace DNA. The left rail lists an All-providers entry plus each provider with its active/total count; the main pane renders the controls, the selected provider's model group (or all groups), and the empty state. The per-group render, controls, combos, empty state, and modals are extracted into shared blocks so classic and workspace views behave identically. Classic view stays available behind a toggle persisted in localStorage (ocx-models-view). New i18n keys for the rail header and All-providers entry in all six locales. --- gui/src/i18n/de.ts | 2 + gui/src/i18n/en.ts | 2 + gui/src/i18n/ja.ts | 2 + gui/src/i18n/ko.ts | 2 + gui/src/i18n/ru.ts | 2 + gui/src/i18n/zh.ts | 2 + gui/src/pages/Models.tsx | 511 +++++++++++++++++----------- gui/src/styles-models-workspace.css | 136 ++++++++ gui/src/styles.css | 1 + 9 files changed, 458 insertions(+), 202 deletions(-) create mode 100644 gui/src/styles-models-workspace.css diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 770b322c2..cc49b6e9d 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -223,6 +223,8 @@ 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.combosEmpty": "Noch keine Kombos konfiguriert", "models.combosSetup": "Einrichten", "models.combosAdd": "Kombo hinzufügen", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 57e69ffbd..7f3c2e6a3 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -236,6 +236,8 @@ 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.combosEmpty": "No combos configured yet", "models.combosSetup": "Set up", "models.combosAdd": "Add combo", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 3e1eb6a5f..1b0625fb0 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -228,6 +228,8 @@ export const ja: Record = { "models.nativeGroupLabel": "OpenAI ネイティブ", "models.nativeHint": "パススルーモデルはプロバイダーで選択したプールまたはダイレクトアカウントオプションを使用します。一つオフにすると Codex ピッカーから隠します(カタログエントリは保持されるので、再有効化で正確に復元されます)。", "models.active": "{active}/{total} アクティブ", + "models.workspace.providers": "プロバイダー", + "models.workspace.allProviders": "すべてのプロバイダー", "models.combosEmpty": "まだコンボが設定されていません", "models.combosSetup": "セットアップ", "models.combosAdd": "コンボを追加", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index c993e82c5..b3fb4089d 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -231,6 +231,8 @@ export const ko: Record = { "models.nativeGroupLabel": "OpenAI 네이티브", "models.nativeHint": "프로바이더에서 선택한 풀 또는 직접 계정 옵션으로 서빙되는 passthrough 모델입니다. 끄면 Codex 선택기에서 숨겨지고, 카탈로그 항목은 유지되므로 다시 켜면 그대로 복원됩니다.", "models.active": "{active}/{total} 활성", + "models.workspace.providers": "제공자", + "models.workspace.allProviders": "모든 제공자", "models.combosEmpty": "아직 설정된 콤보가 없습니다", "models.combosSetup": "설정하기", "models.combosAdd": "콤보 추가하기", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 4fb70d12b..d8c113cf2 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -236,6 +236,8 @@ export const ru: Record = { "models.nativeGroupLabel": "Нативные OpenAI", "models.nativeHint": "Модели сквозного проброса используют режим аккаунта (пул или прямое подключение), выбранный на странице «Провайдеры». Отключение модели скрывает её из селектора Codex (запись в каталоге сохраняется, поэтому при повторном включении она восстанавливается в точности).", "models.active": "{active}/{total} активно", + "models.workspace.providers": "Провайдеры", + "models.workspace.allProviders": "Все провайдеры", "models.combosEmpty": "Комбо ещё не настроены", "models.combosSetup": "Настроить", "models.combosAdd": "Добавить комбо", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 17e386736..ee6f33c35 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -231,6 +231,8 @@ export const zh: Record = { "models.nativeGroupLabel": "OpenAI 原生", "models.nativeHint": "Passthrough 模型使用在提供方页面选择的账户池或直连选项。关闭后会从 Codex 选择器中隐藏(目录条目保留,重新开启即可完整恢复)。", "models.active": "{active}/{total} 已启用", + "models.workspace.providers": "提供商", + "models.workspace.allProviders": "所有提供商", "models.combosEmpty": "尚未配置组合", "models.combosSetup": "设置", "models.combosAdd": "添加组合", diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 5c36753e1..2061154f5 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -122,6 +122,24 @@ export default function Models({ apiBase }: { apiBase: string }) { try { return localStorage.getItem("ocx-models-combos-open") === "1"; } catch { return false; } }); + // Workspace vs Classic: localStorage is the source of truth (same pattern as Providers). + const [workspaceView, setWorkspaceView] = useState(() => { + try { + return localStorage.getItem("ocx-models-view") === "workspace"; + } catch { + return false; + } + }); + const [selectedProvider, setSelectedProvider] = useState(null); + const toggleWorkspace = () => { + const next = !workspaceView; + try { + localStorage.setItem("ocx-models-view", next ? "workspace" : "classic"); + } catch { + /* ignore */ + } + setWorkspaceView(next); + }; const toggleCombosOpen = () => { setCombosOpen(prev => { const next = !prev; @@ -537,15 +555,205 @@ export default function Models({ apiBase }: { apiBase: string }) { if (loading) return
{t("models.loading")}
; - return ( - <> -
-

{t("nav.models")}

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

{t("models.subtitle")}

- {status && {status}} + const renderGroup = (provider: string, rows: ModelRow[]) => { + const isCollapsed = collapsed.has(provider); + const activeCount = rows.filter(m => !disabled.has(m.namespaced)).length; + const capOn = contextCaps[provider] === contextCapValue; + const isNative = rows.every(m => m.native); + 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${isCollapsed ? "" : " open"}`}> + + {provider} + {isNative && {t("models.nativeGroupLabel")}} + {t("models.active", { active: activeCount, total: rows.length })} +
+
e.stopPropagation()} style={{ gap: 6 }}> + {!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 > 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(([p]) => p === selectedProvider) + : groups; + const controlsBlock = ( + <>
{t("models.shadowCallIntercept")} ⓘ ⚠ 5.4-mini → @@ -682,7 +890,11 @@ export default function Models({ apiBase }: { apiBase: string }) {
+ + ); + const combosBlock = ( + <> {combos !== null && !combosError && combos.length === 0 && (
@@ -727,207 +939,21 @@ export default function Models({ apiBase }: { apiBase: string }) { )}
)} + + ); - { - // eslint-disable-next-line react-hooks/refs -- The hover ref is only read by row event handlers nested in this renderer. - groups.map(([provider, rows]) => { - const isCollapsed = collapsed.has(provider); - const activeCount = rows.filter(m => !disabled.has(m.namespaced)).length; - const capOn = contextCaps[provider] === contextCapValue; - const isNative = rows.every(m => m.native); - 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${isCollapsed ? "" : " open"}`}> - - {provider} - {isNative && {t("models.nativeGroupLabel")}} - {t("models.active", { active: activeCount, total: rows.length })} -
-
e.stopPropagation()} style={{ gap: 6 }}> - {!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 > 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 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()}> @@ -1108,4 +1134,85 @@ 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} + { + // eslint-disable-next-line react-hooks/refs -- The hover ref is only read by row event handlers nested in this renderer. + visibleGroups.map(([provider, rows]) => renderGroup(provider, rows)) + } + {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} + { + // eslint-disable-next-line react-hooks/refs -- The hover ref is only read by row event handlers nested in this renderer. + groups.map(([provider, rows]) => renderGroup(provider, rows)) + } + {groups.length === 0 && emptyStateBlock} + {modalsBlock} + + ); } diff --git a/gui/src/styles-models-workspace.css b/gui/src/styles-models-workspace.css new file mode 100644 index 000000000..3aa7d23b0 --- /dev/null +++ b/gui/src/styles-models-workspace.css @@ -0,0 +1,136 @@ +/* ============================================================================ + models-workspace — isolated stylesheet (mirrors providers-workspace layout DNA) + Namespace: models-workspace- | widgets: mdw- + 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; +} + +.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); + outline: 1px solid 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; +} + +/* ── Responsive ───────────────────────────────────────── */ + +@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; + } +} diff --git a/gui/src/styles.css b/gui/src/styles.css index bea872178..66901f99e 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"; :root { /* default: follow the OS; [data-theme] below pins color-scheme so light-dark() obeys it */ From f541c2d5bf13af42c4c59d0a1b065a276ab5e3fc Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:18:47 +0200 Subject: [PATCH 02/14] gui: add global Workspace/Classic toggle to sidebar Adds a Workspace/Classic toggle to the sidebar footer that switches the whole app's view preference at once. It writes every tab's ocx-*-view localStorage key and bumps a render key so the current page remounts and re-reads its preference immediately. Per-page toggles keep working, and the sidebar label re-reads the preference on navigation so it stays accurate. Future tabs only need to register their key in WORKSPACE_VIEW_KEYS. Also fills in the 10 missing sub.workspace.* keys in ja.ts that the rebase onto dev surfaced. New i18n key app.viewMode in all six locales; reuses existing pws.workspaceToggle/classicToggle labels. --- gui/src/App.tsx | 59 ++++++++++++++++++++++++++++++++++++++++++++-- gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/ja.ts | 11 +++++++++ gui/src/i18n/ko.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/src/icons.tsx | 1 + 8 files changed, 74 insertions(+), 2 deletions(-) diff --git a/gui/src/App.tsx b/gui/src/App.tsx index eb9d5b806..7315fa78a 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -10,7 +10,7 @@ import Storage from "./pages/Storage"; import CodexAuth from "./pages/CodexAuth"; import ApiKeys from "./pages/ApiKeys"; import ClaudeCode from "./pages/ClaudeCode"; -import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconHardDrive, IconKey, IconGithub, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconSparkle, IconX } from "./icons"; +import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconHardDrive, IconKey, IconGithub, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconSparkle, IconX, IconLayoutSidebar } from "./icons"; import { useI18n, useT, LOCALES, type Locale, type TKey } from "./i18n"; import { Select } from "./ui"; import { installApiAuthFetch } from "./api"; @@ -61,6 +61,41 @@ function providersHashForPage(): string { return readProvidersViewPreference() === "workspace" ? "providers/workspace" : "providers"; } +// localStorage keys for every tab that offers a Workspace/Classic view. The +// sidebar's global toggle writes all of them at once so one click switches the +// whole app; each page still reads its own key on mount, so per-page toggles +// keep working and future tabs only need to register their key here. +const WORKSPACE_VIEW_KEYS: readonly string[] = [ + "ocx-providers-view", + "ocx-subagents-view", + "ocx-storage-view", + "ocx-codexauth-view", + "ocx-apikeys-view", + "ocx-claudecode-view", + "ocx-usage-view", + "ocx-logs-view", + "ocx-models-view", + "ocx-dashboard-view", +]; + +function readGlobalWorkspacePreference(): boolean { + try { + return WORKSPACE_VIEW_KEYS.some(key => localStorage.getItem(key) === "workspace"); + } catch { + return false; + } +} + +function writeGlobalWorkspacePreference(workspace: boolean): void { + try { + for (const key of WORKSPACE_VIEW_KEYS) { + localStorage.setItem(key, workspace ? "workspace" : "classic"); + } + } catch { + /* ignore quota / private-mode failures */ + } +} + const NAV: { id: Page; tkey: TKey; Icon: typeof IconGrid }[] = [ { id: "dashboard", tkey: "nav.dashboard", Icon: IconGrid }, { id: "providers", tkey: "nav.providers", Icon: IconServer }, @@ -101,6 +136,20 @@ export default function App() { const sidebarRef = useRef(null); const navWasOpen = useRef(false); + // Global Workspace/Classic preference. Pages read their own localStorage key on + // mount, so toggling here writes every key and bumps `viewBump` to remount the + // current page so it re-reads the preference immediately. + // Read during render (not stored in state): the value only changes via our own + // writes, which bump `viewBump` to re-render, so the label is always accurate — + // including after a per-page toggle followed by navigation. + const workspaceView = readGlobalWorkspacePreference(); + const [viewBump, setViewBump] = useState(0); + const toggleGlobalWorkspace = () => { + const next = !readGlobalWorkspacePreference(); + writeGlobalWorkspacePreference(next); + setViewBump(n => n + 1); + }; + useEffect(() => { // External navigation (hash edit, back/forward) also dismisses the mobile drawer. const onHash = () => { @@ -295,6 +344,12 @@ export default function App() { ))}
+ {claudeEnabled !== null && (

{t("models.subtitle")}

@@ -1200,7 +1190,6 @@ export default function Models({ apiBase }: { apiBase: string }) {

{t("nav.models")}

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

{t("models.subtitle")}

From 0fafcfdb51abf239e7e972e1efd3355d88681ee8 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:30:56 +0200 Subject: [PATCH 04/14] fix(gui): restore combos block in models workspace + fix rail row border clipping --- gui/src/pages/Models.tsx | 1 + gui/src/styles-models-workspace.css | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index c9d1d3186..7a4f3dd53 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -1172,6 +1172,7 @@ export default function Models({ apiBase }: { apiBase: string }) {
{controlsBlock} + {combosBlock} { // eslint-disable-next-line react-hooks/refs -- The hover ref is only read by row event handlers nested in this renderer. visibleGroups.map(([provider, rows]) => renderGroup(provider, rows)) diff --git a/gui/src/styles-models-workspace.css b/gui/src/styles-models-workspace.css index 3aa7d23b0..63984532c 100644 --- a/gui/src/styles-models-workspace.css +++ b/gui/src/styles-models-workspace.css @@ -86,7 +86,7 @@ .models-workspace-rail-row--selected { background: var(--surface-2); - outline: 1px solid var(--border); + box-shadow: inset 0 0 0 1px var(--border); } .models-workspace-rail-name { From 33e05c7a7261799283e22cbd09108edf1866a80c Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:41:43 +0200 Subject: [PATCH 05/14] feat(gui): models collapse/expand all + context for set-all cap switch --- gui/src/i18n/de.ts | 3 +++ gui/src/i18n/en.ts | 3 +++ gui/src/i18n/ja.ts | 3 +++ gui/src/i18n/ko.ts | 3 +++ gui/src/i18n/ru.ts | 3 +++ gui/src/i18n/zh.ts | 3 +++ gui/src/pages/Models.tsx | 21 ++++++++++++++++++++- 7 files changed, 38 insertions(+), 1 deletion(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 0dcbc492c..801f0f656 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -256,6 +256,9 @@ 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.custom": "Benutzerdefiniert…", "models.customApply": "Anwenden", "models.customPlaceholder": "Tokens (z. B. 420000)", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 6bef089a2..892a31757 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -270,6 +270,9 @@ 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.custom": "Custom…", "models.customApply": "Apply", "models.customPlaceholder": "Tokens (e.g. 420000)", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index d0486155e..48ddee081 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -262,6 +262,9 @@ export const ja: Record = { "models.capValue": "上限 {value}", "models.contextCappedValue": "{value} 上限", "models.setAll": "すべて設定", + "models.setAllHint": "{value} のコンテキスト上限をすべてのルーティング済みプロバイダーに適用します。ネイティブプロバイダーには影響しません。", + "models.collapseAll": "すべて折りたたむ", + "models.expandAll": "すべて展開", "models.custom": "カスタム…", "models.customApply": "適用", "models.customPlaceholder": "トークン (例: 420000)", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index efda366fb..15c3b768c 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -264,6 +264,9 @@ export const ko: Record = { "models.capValue": "{value} 제한", "models.contextCappedValue": "{value} 제한", "models.setAll": "전체 적용", + "models.setAllHint": "{value} 컨텍스트 상한을 모든 라우팅 제공업체에 적용합니다. 네이티브 제공업체는 영향을 받지 않습니다.", + "models.collapseAll": "모두 접기", + "models.expandAll": "모두 펼치기", "models.custom": "직접 입력…", "models.customApply": "적용", "models.customPlaceholder": "토큰 (예: 420000)", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 8bf13ac81..5658016c9 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -270,6 +270,9 @@ export const ru: Record = { "models.capValue": "Лимит {value}", "models.contextCappedValue": "Лимит {value}", "models.setAll": "Применить ко всем", + "models.setAllHint": "Применяет лимит контекста {value} ко всем маршрутизируемым провайдерам. Нативные провайдеры не затрагиваются.", + "models.collapseAll": "Свернуть все", + "models.expandAll": "Развернуть все", "models.custom": "Другое…", "models.customApply": "Применить", "models.customPlaceholder": "Токены (напр. 420000)", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 5ae56db62..6f2d85ebb 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -264,6 +264,9 @@ export const zh: Record = { "models.capValue": "限制 {value}", "models.contextCappedValue": "{value} 限制", "models.setAll": "全部设置", + "models.setAllHint": "将 {value} 上下文上限应用到所有路由提供商。原生提供商不受影响。", + "models.collapseAll": "全部折叠", + "models.expandAll": "全部展开", "models.custom": "自定义…", "models.customApply": "应用", "models.customPlaceholder": "令牌 (例如 420000)", diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 7a4f3dd53..b71e0c3e8 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -298,6 +298,13 @@ export default function Models({ apiBase }: { apiBase: string }) { return n; }); }; + const setAllCollapsed = (collapse: boolean) => { + setCollapsed(() => { + const n = collapse ? new Set(groups.map(([p]) => p)) : new Set(); + try { localStorage.setItem("ocx-models-collapsed", JSON.stringify([...n])); } catch { /* quota */ } + return n; + }); + }; const putCap = async (body: Record) => { setBusy(true); @@ -745,6 +752,15 @@ export default function Models({ apiBase }: { apiBase: string }) { const controlsBlock = ( <> +
+ + +
+
{t("models.shadowCallIntercept")} ⓘ ⚠ 5.4-mini → @@ -862,7 +878,10 @@ export default function Models({ apiBase }: { apiBase: string }) { )}
- {t("models.setAll")} + {t("models.setAll")} +
+
+ {t("models.setAllHint", { value: fmtK(contextCapValue) })}
{(() => { From 65663f0933a6c5a543594b9565bf5c256cb2778c Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:54:02 +0200 Subject: [PATCH 06/14] fix(gui): models controls layout (set-all next to cap, collapse/expand placement, drop orderHint) --- gui/src/i18n/de.ts | 1 - gui/src/i18n/en.ts | 1 - gui/src/i18n/ja.ts | 1 - gui/src/i18n/ko.ts | 1 - gui/src/i18n/ru.ts | 1 - gui/src/i18n/zh.ts | 1 - gui/src/pages/Models.tsx | 33 +++++++++++++++------------------ 7 files changed, 15 insertions(+), 24 deletions(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 801f0f656..a2b86a505 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -301,7 +301,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 892a31757..e55282ddd 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -315,7 +315,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 48ddee081..aca0c9e9b 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -280,7 +280,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 15c3b768c..7fc41ae0f 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -309,7 +309,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 5658016c9..e94a3813c 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -315,7 +315,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 6f2d85ebb..13266d041 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -309,7 +309,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 b71e0c3e8..3ba4d72cc 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -752,15 +752,6 @@ export default function Models({ apiBase }: { apiBase: string }) { const controlsBlock = ( <> -
- - -
-
{t("models.shadowCallIntercept")} ⓘ ⚠ 5.4-mini → @@ -876,12 +867,9 @@ export default function Models({ apiBase }: { apiBase: string }) { )} -
- {t("models.setAll")} -
-
- {t("models.setAllHint", { value: fmtK(contextCapValue) })} + {t("models.setAll")} + {t("models.setAllHint", { value: fmtK(contextCapValue) })}
{(() => { @@ -896,10 +884,6 @@ export default function Models({ apiBase }: { apiBase: string }) { ); })()} -
-
); @@ -952,6 +936,17 @@ export default function Models({ apiBase }: { apiBase: string }) { ); + const collapseControls = ( +
+ + +
+ ); + const emptyStateBlock = ( <> {groups.length === 0 && ( @@ -1192,6 +1187,7 @@ export default function Models({ apiBase }: { apiBase: string }) {
{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(([provider, rows]) => renderGroup(provider, rows)) @@ -1216,6 +1212,7 @@ export default function Models({ apiBase }: { apiBase: string }) { {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(([provider, rows]) => renderGroup(provider, rows)) From 0d0772a80278d04aa2b9301005e6ab0baf255a14 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:58:30 +0200 Subject: [PATCH 07/14] fix(gui): drop redundant set-all label next to cap switch --- gui/src/pages/Models.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 3ba4d72cc..c9ae5078a 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -868,7 +868,6 @@ export default function Models({ apiBase }: { apiBase: string }) { )} - {t("models.setAll")} {t("models.setAllHint", { value: fmtK(contextCapValue) })}
From 8f06df4dc99a03a250a0d2dfb113f17bd09ee96e Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 23 Jul 2026 01:04:02 +0200 Subject: [PATCH 08/14] fix(gui): restore models picker-order hint (it explains the visibility switches) --- gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/src/pages/Models.tsx | 4 ++++ 7 files changed, 10 insertions(+) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index a2b86a505..3e6f2d2f3 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -259,6 +259,7 @@ export const de = { "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)", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index e55282ddd..82a564dd5 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -273,6 +273,7 @@ export const en = { "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)", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index aca0c9e9b..29de76ed4 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -265,6 +265,7 @@ export const ja: Record = { "models.setAllHint": "{value} のコンテキスト上限をすべてのルーティング済みプロバイダーに適用します。ネイティブプロバイダーには影響しません。", "models.collapseAll": "すべて折りたたむ", "models.expandAll": "すべて展開", + "models.orderHint": "ピッカーの順序: サブエージェントの選択(選択順) → 残りのルーティングモデルはプロバイダー別、次にモデル ID 別のアルファベット順 → ネイティブモデル。表示切り替えはモデルをフィルタするだけで、この順序は変更しません。", "models.custom": "カスタム…", "models.customApply": "適用", "models.customPlaceholder": "トークン (例: 420000)", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 7fc41ae0f..3ce870360 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -267,6 +267,7 @@ export const ko: Record = { "models.setAllHint": "{value} 컨텍스트 상한을 모든 라우팅 제공업체에 적용합니다. 네이티브 제공업체는 영향을 받지 않습니다.", "models.collapseAll": "모두 접기", "models.expandAll": "모두 펼치기", + "models.orderHint": "피커 순서: Subagents에서 지정한 순서 → 나머지 라우팅 모델(프로바이더, 모델 ID 순 알파벳 정렬) → 네이티브 모델. 노출 토글은 모델을 필터링할 뿐 이 순서를 바꾸지 않습니다.", "models.custom": "직접 입력…", "models.customApply": "적용", "models.customPlaceholder": "토큰 (예: 420000)", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index e94a3813c..81d3017cd 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -273,6 +273,7 @@ export const ru: Record = { "models.setAllHint": "Применяет лимит контекста {value} ко всем маршрутизируемым провайдерам. Нативные провайдеры не затрагиваются.", "models.collapseAll": "Свернуть все", "models.expandAll": "Развернуть все", + "models.orderHint": "Порядок в селекторе: модели, выбранные на странице «Подагенты» (в заданном порядке) → остальные маршрутизируемые модели по алфавиту — сначала по провайдеру, затем по ID модели → нативные модели. Переключатели видимости лишь фильтруют модели и не меняют этот порядок.", "models.custom": "Другое…", "models.customApply": "Применить", "models.customPlaceholder": "Токены (напр. 420000)", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 13266d041..a0b834ee4 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -267,6 +267,7 @@ export const zh: Record = { "models.setAllHint": "将 {value} 上下文上限应用到所有路由提供商。原生提供商不受影响。", "models.collapseAll": "全部折叠", "models.expandAll": "全部展开", + "models.orderHint": "选择器顺序:Subagents 中的选择(按所选顺序)→ 其余已路由模型(依次按提供方、模型 ID 字母排序)→ 原生模型。可见性开关仅用于筛选,不会改变此顺序。", "models.custom": "自定义…", "models.customApply": "应用", "models.customPlaceholder": "令牌 (例如 420000)", diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index c9ae5078a..4cb5af9fc 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -883,6 +883,10 @@ export default function Models({ apiBase }: { apiBase: string }) { ); })()} +
+
); From c9541a1bb163bc539c4677e871184f992d2e3ffa Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 23 Jul 2026 01:09:50 +0200 Subject: [PATCH 09/14] fix(gui): replace raw multi_agent_v2 key with Sub-agent label in toast The v2Applied status message leaked the internal config key multi_agent_v2 (with underscore) to users. Use the localized Sub-agent label instead, across all six locales. --- gui/src/i18n/de.ts | 2 +- gui/src/i18n/en.ts | 2 +- gui/src/i18n/ja.ts | 2 +- gui/src/i18n/ko.ts | 2 +- gui/src/i18n/ru.ts | 2 +- gui/src/i18n/zh.ts | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 3e6f2d2f3..ce7301ff8 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -247,7 +247,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", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 82a564dd5..6848962a9 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -261,7 +261,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", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 29de76ed4..f81dfcd5b 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -253,7 +253,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": "スレッド上限を更新しました — 新規セッションに適用", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 3ce870360..eaf128016 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -255,7 +255,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": "스레드 한도 변경됨 — 새 세션부터 적용", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 81d3017cd..e4067ed16 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -261,7 +261,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": "Лимит потоков обновлён — применяется к новым сессиям", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index a0b834ee4..5ac20a344 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -255,7 +255,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": "线程上限已更新 — 新会话生效", From efc06a5fdeadd1778feeb2e4e6f1cf76b8206b86 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 23 Jul 2026 01:28:32 +0200 Subject: [PATCH 10/14] feat(gui): styled hover tooltip to replace native title popups Add a reusable Tooltip primitive (glass material, border, radius, fade-in) matching the design system, and use it for the Shadow Call Intercept info icon on the Models tab instead of the unstyleable browser title attribute. --- gui/src/pages/Models.tsx | 4 ++-- gui/src/styles.css | 28 ++++++++++++++++++++++++++++ gui/src/ui.tsx | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 4cb5af9fc..3b233bac7 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -1,5 +1,5 @@ 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"; @@ -753,7 +753,7 @@ export default function Models({ apiBase }: { apiBase: string }) { const controlsBlock = ( <>
- {t("models.shadowCallIntercept")} ⓘ + {t("models.shadowCallIntercept")} ⚠ 5.4-mini → void saveShadowCall({ enabled: !shadowCall?.enabled })} disabled={!shadowCall || shadowCallSaving} label={t("models.shadowCallIntercept")} /> p === selectedProvider) + ? groups.filter(group => group.provider === selectedProvider) : groups; const controlsBlock = ( <> -
- {t("models.shadowCallIntercept")} - ⚠ 5.4-mini → - 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")} { setShadowCall(c => c ? { ...c, model: v } : c); void saveShadowCall({ model: v }); }} disabled={!shadowCall || shadowCallSaving || !shadowCall.enabled} label={t("models.shadowCallIntercept")} /> @@ -1208,7 +1214,7 @@ export default function Models({ apiBase, viewMode }: { apiBase: string; viewMod })}
-
+
{controlsBlock} {combosBlock} {collapseControls} @@ -1217,7 +1223,7 @@ export default function Models({ apiBase, viewMode }: { apiBase: string; viewMod visibleGroups.map(group => renderGroup(group)) } {groups.length === 0 && emptyStateBlock} -
+
{modalsBlock}
diff --git a/gui/src/styles-models-workspace.css b/gui/src/styles-models-workspace.css index 37631aad2..c1d4092a1 100644 --- a/gui/src/styles-models-workspace.css +++ b/gui/src/styles-models-workspace.css @@ -1,6 +1,7 @@ /* ============================================================================ - models-workspace — isolated stylesheet (mirrors providers-workspace layout DNA) - Namespace: models-workspace- | widgets: mdw- + 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. ============================================================================ */ @@ -189,22 +190,7 @@ /* ── Responsive ───────────────────────────────────────── */ -@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; - } - +@media (max-width: 1160px) { .models-control-top-row { grid-template-columns: 1fr; } @@ -226,3 +212,20 @@ justify-self: start; } } + +@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; + } +} diff --git a/gui/src/ui.tsx b/gui/src/ui.tsx index 9f9637341..903712b94 100644 --- a/gui/src/ui.tsx +++ b/gui/src/ui.tsx @@ -246,6 +246,7 @@ export function Tooltip({ content, children, side = "top", maxWidth = 280 }: { maxWidth?: number; }) { const [open, setOpen] = useState(false); + const tipId = useId(); const timer = useRef(null); const show = () => { @@ -259,10 +260,21 @@ export function Tooltip({ content, children, side = "top", maxWidth = 280 }: { 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} )} From 8458cafe0caca2d03a030f5be4a12cff186aaf5c Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 25 Jul 2026 06:48:16 +0200 Subject: [PATCH 13/14] fix(gui): satisfy eslint hook-state rules Move provider-selection reconciliation into models reload so we avoid set-state-in-effect while keeping stale selections safe, and memoize route hash handlers so hook dependencies are explicit and lint-clean. --- gui/src/pages/Models.tsx | 12 ++++++------ gui/src/use-app-route-state.ts | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index c21a78ebc..1a675dc57 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -196,6 +196,12 @@ export default function Models({ apiBase, viewMode }: { apiBase: string; viewMod .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)); @@ -233,12 +239,6 @@ export default function Models({ apiBase, viewMode }: { apiBase: string; viewMod [models, providers], ); - useEffect(() => { - if (selectedProvider !== null && !groups.some(group => group.provider === selectedProvider)) { - setSelectedProvider(null); - } - }, [groups, selectedProvider]); - const apply = async (next: Set) => { setBusy(true); busyRef.current = true; 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); From e98e75e34be5576aabe770c230155654cfd933e3 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 25 Jul 2026 07:17:43 +0200 Subject: [PATCH 14/14] fix(gui): stack Models workspace when content width is squeezed Use a models-workspace container query so the provider rail stacks before actions clip beside the desktop sidebar, and wrap provider header actions with Models-specific classes. --- gui/src/pages/Models.tsx | 7 ++- gui/src/styles-models-workspace.css | 62 ++++++++++++++++++++++++++ gui/tests/models-provider-head.test.ts | 27 +++++++++++ 3 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 gui/tests/models-provider-head.test.ts diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 1a675dc57..953e05af0 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -581,9 +581,9 @@ export default function Models({ apiBase, viewMode }: { apiBase: string; viewMod apply(next); }; return ( -
+
toggleCollapse(provider)} - className={`row group-head${isCollapsed ? "" : " open"}`}> + className={`row group-head models-provider-head${isCollapsed ? "" : " open"}`}> {provider} {isNative && {t("models.nativeGroupLabel")}} @@ -597,8 +597,7 @@ export default function Models({ apiBase, viewMode }: { apiBase: string; viewMod )} {t("models.active", { active: activeCount, total: rows.length })} -
-
e.stopPropagation()} style={{ gap: 6 }}> +
e.stopPropagation()}> {!isNative && (