From 66a5b99aaf80e6fbe3c22daf6738de66ed77e905 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:30:51 +0200 Subject: [PATCH 01/23] gui: add Dashboard workspace view (section rail + main pane) Adds a workspace layout for the Dashboard tab following the Providers workspace DNA. The left rail lists the page sections (Overview, Active providers, Available models); the main pane renders the selected section. Section bodies and the update dialog are extracted into shared blocks so classic and workspace views behave identically. Classic view stays available behind a toggle persisted in localStorage (ocx-dashboard-view). New i18n keys for the Overview section and rail header 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/Dashboard.tsx | 1128 +++++++++++++----------- gui/src/styles-dashboard-workspace.css | 114 +++ gui/src/styles.css | 1 + 9 files changed, 734 insertions(+), 521 deletions(-) create mode 100644 gui/src/styles-dashboard-workspace.css diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 770b322c2..c6590dd82 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -20,6 +20,8 @@ export const de = { "theme.system": "System", "lang.label": "Sprache", "dash.subtitle": "Live-Status des lokalen opencodex-Proxys, seiner Anbieter und der in Codex gerouteten Modelle.", + "dash.workspace.overview": "Übersicht", + "dash.workspace.sections": "Abschnitte", "dash.status": "Status", "dash.online": "Online", "dash.offline": "Offline", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 57e69ffbd..4622fc54d 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -29,6 +29,8 @@ export const en = { // dashboard "dash.subtitle": "Live status of the local opencodex proxy, its providers, and the models routed into Codex.", + "dash.workspace.overview": "Overview", + "dash.workspace.sections": "Sections", "dash.status": "Status", "dash.online": "Online", "dash.offline": "Offline", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 3e1eb6a5f..e04c73179 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -29,6 +29,8 @@ export const ja: Record = { // dashboard "dash.subtitle": "ローカル opencodex プロキシ、そのプロバイダー、Codex にルーティングされるモデルのライブ状態です。", + "dash.workspace.overview": "概要", + "dash.workspace.sections": "セクション", "dash.status": "状態", "dash.online": "オンライン", "dash.offline": "オフライン", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index c993e82c5..8e0647723 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -24,6 +24,8 @@ export const ko: Record = { // dashboard "dash.subtitle": "로컬 opencodex 프록시와 프로바이더, 그리고 Codex로 라우팅되는 모델의 실시간 상태입니다.", + "dash.workspace.overview": "개요", + "dash.workspace.sections": "섹션", "dash.status": "상태", "dash.online": "온라인", "dash.offline": "오프라인", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 4fb70d12b..5ad177e7f 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -29,6 +29,8 @@ export const ru: Record = { // dashboard "dash.subtitle": "Актуальное состояние локального прокси opencodex, его провайдеров и моделей, маршрутизируемых в Codex.", + "dash.workspace.overview": "Обзор", + "dash.workspace.sections": "Разделы", "dash.status": "Статус", "dash.online": "В сети", "dash.offline": "Не в сети", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 17e386736..d963a8d2c 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -24,6 +24,8 @@ export const zh: Record = { // dashboard "dash.subtitle": "本地 opencodex 代理、其提供方以及路由到 Codex 的模型的实时状态。", + "dash.workspace.overview": "概览", + "dash.workspace.sections": "部分", "dash.status": "状态", "dash.online": "在线", "dash.offline": "离线", diff --git a/gui/src/pages/Dashboard.tsx b/gui/src/pages/Dashboard.tsx index 61041c509..d06458898 100644 --- a/gui/src/pages/Dashboard.tsx +++ b/gui/src/pages/Dashboard.tsx @@ -175,6 +175,24 @@ function useModalDialog(open: boolean, triggerRef: RefObject { + try { + return localStorage.getItem("ocx-dashboard-view") === "workspace"; + } catch { + return false; + } + }); + const [selectedSection, setSelectedSection] = useState("overview"); + const toggleWorkspace = () => { + const next = !workspaceView; + try { + localStorage.setItem("ocx-dashboard-view", next ? "workspace" : "classic"); + } catch { + /* ignore */ + } + setWorkspaceView(next); + }; const [health, setHealth] = useState(null); const [providers, setProviders] = useState([]); const [models, setModels] = useState([]); @@ -555,560 +573,628 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { } }; - return ( + const overviewSection = ( <> -

{t("nav.dashboard")}

-

{t("dash.subtitle")}

+
+
+
+ {t("dash.multiAgent")} + +
+
+
+ {(["v1", "default", "v2"] as const).map(mode => ( + + ))} +
+
+
+
+
{t("dash.status")}
+
+ {online ? t("dash.online") : t("dash.offline")} +
+
+
{t("dash.version")}
{health?.version ?? "—"}
+
{t("dash.uptime")}
{health ? formatUptime(health.uptime, locale) : "—"}
+
{t("dash.providers")}
{providers.length}
+
+
{t("dash.tokens30d")}
+
{usage30d && usage30d.summary.requests > 0 ? formatTokens(usage30d.summary.totalTokens, locale) : "—"}
+ {usage30d && usage30d.summary.requests > 0 && ( +
+ {t("dash.coverage").replace("{pct}", `${Math.round(usage30d.summary.coverageRatio * 100)}%`)} +
+ )} +
+
-
-
-
- {t("dash.multiAgent")} +{projectConfigWarnings.length > 0 && ( +
+ +
+
{t("dash.projectConfigTitle")}
+
{t("dash.projectConfigHint")}
+
    + {projectConfigWarnings.map(g => ( +
  • + {g.path} — {g.issues.join(", ")} +
    {g.bypass}
    +
  • + ))} +
+
+
+)} + +{maMode !== "v1" && ( +
+
+ + {t("dash.effortCapLabel")} + + + { event.preventDefault(); setEffortCapHelpOpen(false); }} + > -
-
-
- {(["v1", "default", "v2"] as const).map(mode => ( - - ))} -
-
-
-
-
{t("dash.status")}
-
- {online ? t("dash.online") : t("dash.offline")} -
-
-
{t("dash.version")}
{health?.version ?? "—"}
-
{t("dash.uptime")}
{health ? formatUptime(health.uptime, locale) : "—"}
-
{t("dash.providers")}
{providers.length}
-
-
{t("dash.tokens30d")}
-
{usage30d && usage30d.summary.requests > 0 ? formatTokens(usage30d.summary.totalTokens, locale) : "—"}
- {usage30d && usage30d.summary.requests > 0 && ( -
- {t("dash.coverage").replace("{pct}", `${Math.round(usage30d.summary.coverageRatio * 100)}%`)} -
- )} -
-
+
{t("dash.effortCapHelp")}
+ + + + ({ value: e, label: e })), + ]} + onChange={async (v) => { + if (effortCapSaving) return; + setEffortCapSaving(true); + try { + const res = await fetch(`${apiBase}/api/effort-caps`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ subagentEffortCap: v || null }), + }); + if (res.ok) { + const data = await res.json() as { ok: boolean; effortCap?: string | null; subagentEffortCap?: string | null }; + setEffortCap(data.effortCap ?? ""); + setSubagentEffortCap(data.subagentEffortCap ?? ""); + } + } catch { /* ignore */ } + finally { setEffortCapSaving(false); } + }} + disabled={effortCapSaving} + label={t("dash.subagentEffortCapLabel")} + /> +
+
+)} - {projectConfigWarnings.length > 0 && ( -
- -
-
{t("dash.projectConfigTitle")}
-
{t("dash.projectConfigHint")}
-
    - {projectConfigWarnings.map(g => ( -
  • - {g.path} — {g.issues.join(", ")} -
    {g.bypass}
    -
  • - ))} -
-
-
- )} +
+
+ {t("dash.injectionLabel")} + ({ value: e, label: e })), + ]} + onChange={async (v) => { + if (injectionSaving) return; + setInjectionSaving(true); + try { + const res = await fetch(`${apiBase}/api/injection-model`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: injectionModel || null, effort: v || null }), + }); + if (res.ok) { + const data = await res.json() as { model?: string | null; effort?: string | null }; + setInjectionModel(data.model ?? ""); + setInjectionEffort(data.effort ?? ""); + } + } catch { /* ignore */ } + finally { setInjectionSaving(false); } + }} + disabled={injectionSaving} + label={t("dash.injectionEffortLabel")} + /> + )} + {injectionModel && {t("dash.injectionActive")}} +
+
{t("dash.injectionHint")}
+
- {maMode !== "v1" && ( -
-
- - {t("dash.effortCapLabel")} - - - { event.preventDefault(); setEffortCapHelpOpen(false); }} - > - -
{t("dash.effortCapHelp")}
-
-
-
- ({ value: e, label: e })), - ]} - onChange={async (v) => { - if (effortCapSaving) return; - setEffortCapSaving(true); - try { - const res = await fetch(`${apiBase}/api/effort-caps`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ subagentEffortCap: v || null }), - }); - if (res.ok) { - const data = await res.json() as { ok: boolean; effortCap?: string | null; subagentEffortCap?: string | null }; - setEffortCap(data.effortCap ?? ""); - setSubagentEffortCap(data.subagentEffortCap ?? ""); - } - } catch { /* ignore */ } - finally { setEffortCapSaving(false); } - }} - disabled={effortCapSaving} - label={t("dash.subagentEffortCapLabel")} - /> -
-
- )} +
+
+
+
{t("dash.maintenance")}
+
{t("dash.maintenanceHint")}
+
+
+ + +
+
+ {syncResult && ( +
+ + + {t("dash.syncOk", { count: syncResult.added })} + {syncResult.warning ? ` ${syncResult.warning}` : ""} + {syncResult.staleAppServerHint ? ` ${t("dash.syncStaleHint")}` : ""} + +
+ )} + {syncError && ( +
+ {t("dash.syncFailed", { error: syncError })} +
+ )} + {updateJob && ( +
+ {updateJob.status === "failed" ? : } + + {updateJobLabel(updateJob.status, t)} + {updateJob.latestVersion ? ` ${updateJob.currentVersion} -> ${updateJob.latestVersion}.` : ""} + {reconnecting ? ` ${t("dash.updateReconnecting")}` : ""} + {updateJob.error ? ` ${updateJob.error}` : ""} + +
+ )} +
-
-
- {t("dash.injectionLabel")} - ({ value: e, label: e })), - ]} - onChange={async (v) => { - if (injectionSaving) return; - setInjectionSaving(true); - try { - const res = await fetch(`${apiBase}/api/injection-model`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model: injectionModel || null, effort: v || null }), - }); - if (res.ok) { - const data = await res.json() as { model?: string | null; effort?: string | null }; - setInjectionModel(data.model ?? ""); - setInjectionEffort(data.effort ?? ""); - } - } catch { /* ignore */ } - finally { setInjectionSaving(false); } - }} - disabled={injectionSaving} - label={t("dash.injectionEffortLabel")} - /> - )} - {injectionModel && {t("dash.injectionActive")}} -
-
{t("dash.injectionHint")}
-
+
+
+
+
{t("dash.codexAutoStart")}
+
{t("dash.codexAutoStartHint")}
+
+ +
+
-
-
-
-
{t("dash.maintenance")}
-
{t("dash.maintenanceHint")}
-
-
- - -
-
- {syncResult && ( -
- - - {t("dash.syncOk", { count: syncResult.added })} - {syncResult.warning ? ` ${syncResult.warning}` : ""} - {syncResult.staleAppServerHint ? ` ${t("dash.syncStaleHint")}` : ""} - -
- )} - {syncError && ( -
- {t("dash.syncFailed", { error: syncError })} -
- )} - {updateJob && ( -
- {updateJob.status === "failed" ? : } - - {updateJobLabel(updateJob.status, t)} - {updateJob.latestVersion ? ` ${updateJob.currentVersion} -> ${updateJob.latestVersion}.` : ""} - {reconnecting ? ` ${t("dash.updateReconnecting")}` : ""} - {updateJob.error ? ` ${updateJob.error}` : ""} - -
- )} -
+
+
+
+
{t("dash.webSearchSidecar")}
+
{t("dash.webSearchSidecarHint")}
+
+
+ { void saveSidecar({ vision: { model, backend: sidecarBackendForModel(models, model) } }); }} + disabled={!sidecar || sidecarSaving} + label={t("dash.sidecarModel")} + /> +
+
+
-
-
-
-
{t("dash.webSearchSidecar")}
-
{t("dash.webSearchSidecarHint")}
-
-
- ({ value: m.id, label: `${m.provider}/${m.id}` }))]} + onChange={v => { setShadowCall(c => c ? { ...c, model: v } : c); saveShadowCall({ model: v }); }} + disabled={!shadowCall || shadowCallSaving || !shadowCall?.enabled} + label={t("dash.shadowCallModel")} + /> +
+
+
-
-
-
-
{t("dash.visionSidecar")}
-
{t("dash.visionSidecarHint")}
-
-
- ({ value: m.id, label: `${m.provider}/${m.id}` }))]} - onChange={v => { setShadowCall(c => c ? { ...c, model: v } : c); saveShadowCall({ model: v }); }} - disabled={!shadowCall || shadowCallSaving || !shadowCall?.enabled} - label={t("dash.shadowCallModel")} - /> -
-
-
+ const providersSection = ( + <> +
{t("dash.activeProviders")} {providers.length}
+{providers.length === 0 ? ( + } /> +) : ( +
+ + + + {providers.map(p => ( + + + + + + + ))} + +
{t("dash.col.name")}{t("dash.col.adapter")}{t("dash.col.baseUrl")}{t("dash.col.model")}
{p.name}{p.adapter}{p.baseUrl}{p.defaultModel ?? "—"}
+
+)} -
{t("dash.activeProviders")} {providers.length}
- {providers.length === 0 ? ( - } /> - ) : ( -
- - - - {providers.map(p => ( - - - - - - - ))} - -
{t("dash.col.name")}{t("dash.col.adapter")}{t("dash.col.baseUrl")}{t("dash.col.model")}
{p.name}{p.adapter}{p.baseUrl}{p.defaultModel ?? "—"}
-
- )} + + ); -
- {t("dash.availableModels")} {models.length} - {modelsLoading && } -
- {models.length === 0 && !modelsLoading ? ( - - ) : ( -
- {grouped.map(([provider, rows]) => ( -
-
{provider}{rows.length}
-
- {rows.map(m => ( -
-
{m.id}
-
- ))} -
+ const modelsSection = ( + <> +
+ {t("dash.availableModels")} {models.length} + {modelsLoading && } +
+{models.length === 0 && !modelsLoading ? ( + +) : ( +
+ {grouped.map(([provider, rows]) => ( +
+
{provider}{rows.length}
+
+ {rows.map(m => ( +
+
{m.id}
))}
- )} +
+ ))} +
+)} - { event.preventDefault(); closeUpdateDialog(); }} - > -
-
-

{t("dash.updateTitle")}

- +
+
{t("dash.updateDesc")}
+
+ + changeUpdateChannel(v as UpdateChannel)} + )} + {!updateCheck.canUpdate && updateCheck.reason !== "latest_unavailable" && updateCheck.reason !== "source_checkout" && ( +
+ + {t("dash.updateCannotAuto", { reason: updateReasonLabel(updateCheck.reason, t) })} + +
- {updateLoading && } title={t("dash.updateChecking")} />} - {updateError && ( -
{updateError}
- )} - {updateCheck && !updateLoading && ( -
-
-
-
{t("dash.updateInstalled")}
-
{updateCheck.currentVersion}
-
-
-
{t("dash.updateLatest")}
-
{updateCheck.latestVersion ?? "—"}
-
- - {updateCheck.updateAvailable ? t("dash.updateAvailable") : t("dash.updateCurrent")} - -
-
{t("dash.updateCommand")} {updateCheck.command}
- {updateCheck.reason === "source_checkout" && ( -
{t("dash.updateSource")}
- )} - {updateCheck.reason === "latest_unavailable" && ( -
- {t("dash.updateUnavailable")} - -
- )} - {!updateCheck.canUpdate && updateCheck.reason !== "latest_unavailable" && updateCheck.reason !== "source_checkout" && ( -
- - {t("dash.updateCannotAuto", { reason: updateReasonLabel(updateCheck.reason, t) })} - - -
- )} - {updateCheck.canUpdate && ( -
-
-
{t("dash.updateRestart")}
-
{t("dash.updateRestartHint")}
-
- -
- )} + )} + {updateCheck.canUpdate && ( +
+
+
{t("dash.updateRestart")}
+
{t("dash.updateRestartHint")}
- )} -
-
-
-
+ )} +
+ )} +
+ + +
+
+ - { event.preventDefault(); setMaHelpOpen(false); }} - onClick={event => { if (event.target === event.currentTarget) setMaHelpOpen(false); }} - > -
e.stopPropagation()}> -
-

{t("dash.multiAgent")}

- -
-
- {t("models.v2Help")} -
-
- - {t("models.v2DocsLink")} - + { event.preventDefault(); setMaHelpOpen(false); }} + onClick={event => { if (event.target === event.currentTarget) setMaHelpOpen(false); }} +> +
e.stopPropagation()}> +
+

{t("dash.multiAgent")}

+ +
+
+ {t("models.v2Help")} +
+ +
+ +
+
+
+ + ); + + if (workspaceView) { + const sections = [ + { id: "overview", label: t("dash.workspace.overview"), body: overviewSection }, + { id: "providers", label: t("dash.activeProviders"), body: providersSection }, + { id: "models", label: t("dash.availableModels"), body: modelsSection }, + ]; + const selected = sections.find(s => s.id === selectedSection) ?? sections[0]; + return ( +
+
+

{t("nav.dashboard")}

+ +
+

{t("dash.subtitle")}

+
+
+ +
+ {selected.body} +
+ + {updateDialog} + + ); + } + + return ( + <> +
+

{t("nav.dashboard")}

+ +
+

{t("dash.subtitle")}

+ {overviewSection} + {providersSection} + {modelsSection} + {updateDialog} ); } diff --git a/gui/src/styles-dashboard-workspace.css b/gui/src/styles-dashboard-workspace.css new file mode 100644 index 000000000..765f9bf82 --- /dev/null +++ b/gui/src/styles-dashboard-workspace.css @@ -0,0 +1,114 @@ +/* ============================================================================ + dashboard-workspace — isolated stylesheet (mirrors providers-workspace layout DNA) + Namespace: dashboard-workspace- | widgets: dbw- + Uses only design tokens from styles.css. No gradients. + ============================================================================ */ + +.main-inner:has(.dashboard-workspace-shell) { + max-width: 1200px; +} + +.dashboard-workspace-shell { + width: 100%; + min-width: 0; +} + +.dashboard-workspace-root { + display: grid; + grid-template-columns: minmax(220px, 260px) minmax(0, 1fr); + gap: var(--space-4); + width: 100%; + max-width: 100%; + min-height: 480px; +} + +/* ── Rail ─────────────────────────────────────────────── */ + +.dashboard-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; +} + +.dashboard-workspace-rail-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} + +.dashboard-workspace-rail-title { + font-size: var(--text-body); + font-weight: var(--weight-semibold); + color: var(--text); +} + +.dashboard-workspace-rail-list { + display: flex; + flex-direction: column; + gap: 2px; + min-height: 0; +} + +.dashboard-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; +} + +.dashboard-workspace-rail-row:hover { + background: var(--surface-2); +} + +.dashboard-workspace-rail-row--selected { + background: var(--surface-2); + outline: 1px solid var(--border); +} + +.dashboard-workspace-rail-name { + font-size: var(--text-body); + font-weight: var(--weight-medium); + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── Main pane ────────────────────────────────────────── */ + +.dashboard-workspace-main { + display: flex; + flex-direction: column; + gap: var(--space-4); + min-width: 0; +} + +/* ── Responsive ───────────────────────────────────────── */ + +@media (max-width: 768px) { + .dashboard-workspace-root { + grid-template-columns: 1fr; + } + + .dashboard-workspace-rail { + border-right: none; + border-bottom: 1px solid var(--border); + padding-right: 0; + padding-bottom: var(--space-3); + } +} diff --git a/gui/src/styles.css b/gui/src/styles.css index bea872178..58977e84e 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-dashboard-workspace.css"; :root { /* default: follow the OS; [data-theme] below pins color-scheme so light-dark() obeys it */ From 26cf5c17bfc611f8ab7f599d58bc99fca5427bbd Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:50:48 +0200 Subject: [PATCH 02/23] fix(gui): dashboard workspace visual polish (rail header, sidecar grid, spacing) --- gui/src/pages/Dashboard.tsx | 17 ++++++++-------- gui/src/styles-dashboard-workspace.css | 27 +++++++++++++------------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/gui/src/pages/Dashboard.tsx b/gui/src/pages/Dashboard.tsx index d06458898..eb59cfe24 100644 --- a/gui/src/pages/Dashboard.tsx +++ b/gui/src/pages/Dashboard.tsx @@ -877,8 +877,9 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { -
-
+
+
+
{t("dash.webSearchSidecar")}
{t("dash.webSearchSidecarHint")}
@@ -895,8 +896,8 @@ export default function Dashboard({ apiBase }: { apiBase: string }) {
-
-
+
+
{t("dash.visionSidecar")}
{t("dash.visionSidecarHint")}
@@ -912,9 +913,10 @@ export default function Dashboard({ apiBase }: { apiBase: string }) {
+
-
-
+
+
{t("dash.shadowCallIntercept")} @@ -1158,9 +1160,6 @@ export default function Dashboard({ apiBase }: { apiBase: string }) {

{t("dash.subtitle")}

+ + { event.preventDefault(); setEffortCapHelpOpen(false); }} + onClick={event => { if (event.target === event.currentTarget) setEffortCapHelpOpen(false); }} +> +
e.stopPropagation()}> +
+

{t("dash.effortCapLabel")}

+ +
+
+ {t("dash.effortCapHelp")} +
+
+ +
+
+
+ + { event.preventDefault(); setShadowCallHelpOpen(false); }} + onClick={event => { if (event.target === event.currentTarget) setShadowCallHelpOpen(false); }} +> +
e.stopPropagation()}> +
+

{t("dash.shadowCallIntercept")}

+ +
+
+ {t("dash.shadowCallTooltip")} +
+
+ +
+
+
); From b4bf06b09b29305e34a3a9af35ee465dbd503534 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:27:23 +0200 Subject: [PATCH 08/23] fix(gui): let modal backdrop blur show through (lighter overlay tint) --- gui/src/styles.css | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/gui/src/styles.css b/gui/src/styles.css index 58977e84e..43ead378f 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -638,18 +638,20 @@ select.input { appearance: none; } .sidebar { background: var(--rail); } .lang-toggle .select-dropdown { background: var(--rail); } .modal-card { background: var(--surface); } + .modal-overlay { background: light-dark(rgba(17, 19, 28, 0.78), rgba(0, 0, 0, 0.82)); } } @media (prefers-reduced-transparency: reduce) { body::before { display: none; } .sidebar { background: var(--rail); backdrop-filter: none; -webkit-backdrop-filter: none; } .lang-toggle .select-dropdown { background: var(--rail); backdrop-filter: none; -webkit-backdrop-filter: none; } .modal-card { background: var(--surface); backdrop-filter: none; -webkit-backdrop-filter: none; } - .modal-overlay { backdrop-filter: none; -webkit-backdrop-filter: none; } + .modal-overlay { background: light-dark(rgba(17, 19, 28, 0.78), rgba(0, 0, 0, 0.82)); backdrop-filter: none; -webkit-backdrop-filter: none; } } /* ---- modal ---- */ -/* Liquid-glass modal: heavy tint + blur so background content is unreadable */ -.modal-overlay { position: fixed; inset: 0; background: light-dark(rgba(17, 19, 28, 0.78), rgba(0, 0, 0, 0.82)); backdrop-filter: blur(40px) saturate(1.2); -webkit-backdrop-filter: blur(40px) saturate(1.2); display: flex; align-items: flex-start; justify-content: center; padding: 8vh 16px; z-index: var(--z-modal); } +/* Liquid-glass modal: frosted veil — light tint so the 40px blur reads as + glass, while the blur itself keeps background content unreadable */ +.modal-overlay { position: fixed; inset: 0; background: light-dark(rgba(17, 19, 28, 0.40), rgba(0, 0, 0, 0.50)); backdrop-filter: blur(40px) saturate(1.3); -webkit-backdrop-filter: blur(40px) saturate(1.3); display: flex; align-items: flex-start; justify-content: center; padding: 8vh 16px; z-index: var(--z-modal); } /* Native .showModal() UA styles (solid border + ::backdrop) stack on top of .modal-overlay and produce a white frame + double dim. Reset when the dialog IS the overlay. */ dialog.modal-overlay { From 079503bb90224e3667525ef3a93338b9e6449abe Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:35:04 +0200 Subject: [PATCH 09/23] fix(gui): keep Select dropdown in viewport (flip up + right-align shadow call) --- gui/src/pages/Dashboard.tsx | 1 + gui/src/styles.css | 2 ++ gui/src/ui.tsx | 21 +++++++++++++++++++-- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/gui/src/pages/Dashboard.tsx b/gui/src/pages/Dashboard.tsx index d1773a2b8..4af4f6861 100644 --- a/gui/src/pages/Dashboard.tsx +++ b/gui/src/pages/Dashboard.tsx @@ -924,6 +924,7 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { onChange={v => { setShadowCall(c => c ? { ...c, model: v } : c); saveShadowCall({ model: v }); }} disabled={!shadowCall || shadowCallSaving || !shadowCall?.enabled} label={t("dash.shadowCallModel")} + align="right" />
diff --git a/gui/src/styles.css b/gui/src/styles.css index 43ead378f..18ea75c5c 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -557,6 +557,8 @@ select.input { appearance: none; } } .select-dropdown-right { left: auto !important; right: 0; } .select-dropdown-beside { top: 0; left: calc(100% + 6px); right: auto; min-width: 10rem; } +/* Flips above the trigger when there is no room below (set via JS). */ +.select-dropdown-up { top: auto; bottom: calc(100% + 4px); } .select-option { display: block; width: 100%; text-align: left; padding: 7px 12px; border: none; border-radius: var(--radius-sm); diff --git a/gui/src/ui.tsx b/gui/src/ui.tsx index b07647735..26140448f 100644 --- a/gui/src/ui.tsx +++ b/gui/src/ui.tsx @@ -35,6 +35,8 @@ export function Select({ value, options, onChange, disabled, label, style, align dropdownStyle?: CSSProperties; }) { const [open, setOpen] = useState(false); + const [dropUp, setDropUp] = useState(false); + const [maxH, setMaxH] = useState(undefined); const ref = useRef(null); const current = options.find(o => o.value === value); @@ -52,7 +54,22 @@ export function Select({ value, options, onChange, disabled, label, style, align {open && ( -
+
{options.map(o => ( + {open && ( +
+ {rows.map(m => ( + {m.id} + ))} +
+ )} +
+ ); + })} +
+ )} )} diff --git a/gui/src/styles-dashboard-workspace.css b/gui/src/styles-dashboard-workspace.css index a7e3219b2..664a8cf5f 100644 --- a/gui/src/styles-dashboard-workspace.css +++ b/gui/src/styles-dashboard-workspace.css @@ -115,6 +115,67 @@ gap: var(--space-4); } +/* Available models: search + per-provider accordion with model chips */ +.dash-model-acc { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.dash-model-group { + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + overflow: hidden; +} + +.dash-model-head { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + appearance: none; + background: none; + border: none; + padding: 10px 14px; + cursor: pointer; + font: inherit; + color: var(--text); + text-align: left; + transition: background var(--motion-fast); +} + +.dash-model-head:hover { + background: var(--hover); +} + +.dash-model-head .count { + font-family: var(--font-code); + font-weight: var(--weight-medium); + color: var(--faint); + font-size: var(--text-label); +} + +.dash-model-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 4px 14px 14px; + border-top: 1px solid var(--border-soft); + padding-top: 12px; +} + +.dash-model-chip { + font-family: var(--font-code); + font-size: var(--text-label); + padding: 3px 8px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--raised); + color: var(--text); + white-space: nowrap; +} + @media (max-width: 768px) { .dash-sidecar-grid { grid-template-columns: 1fr; From a5bde42c03071ecb9c2a624aefad6a79478827d1 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:31:30 +0200 Subject: [PATCH 11/23] fix(gui): fix rail row border clipping (outline -> inset shadow) --- gui/src/styles-dashboard-workspace.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/src/styles-dashboard-workspace.css b/gui/src/styles-dashboard-workspace.css index 664a8cf5f..28cd39778 100644 --- a/gui/src/styles-dashboard-workspace.css +++ b/gui/src/styles-dashboard-workspace.css @@ -64,7 +64,7 @@ .dashboard-workspace-rail-row--selected { background: var(--surface-2); - outline: 1px solid var(--border); + box-shadow: inset 0 0 0 1px var(--border); } .dashboard-workspace-rail-name { From 3499f3d2e6f7988e54b8320715ea5d9b990cb43c Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:29:03 +0200 Subject: [PATCH 12/23] fix(gui): port upstream dashboard health and guidance into workspace dashboard --- gui/src/pages/Dashboard.tsx | 101 ++++++++++++++++++++++++++++++++--- gui/src/startup-health-ui.ts | 26 +++++++++ 2 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 gui/src/startup-health-ui.ts diff --git a/gui/src/pages/Dashboard.tsx b/gui/src/pages/Dashboard.tsx index d44e87a8a..0b48a8c54 100644 --- a/gui/src/pages/Dashboard.tsx +++ b/gui/src/pages/Dashboard.tsx @@ -3,13 +3,25 @@ import { formatUptime } from "../formatUptime"; import { IconAlert, IconChevron, IconExternal, IconInfo, IconRefresh, IconSearch, IconX } from "../icons"; import { Trans } from "../i18n/provider"; import { useI18n, type TKey } from "../i18n/shared"; +import { settingsPollMayCommit } from "../startup-health-ui"; import { formatTokens } from "../format-tokens"; import { EmptyState, Select } from "../ui"; interface HealthData { status: string; version: string; uptime: number } interface ProviderInfo { name: string; adapter: string; baseUrl: string; defaultModel?: string; hasApiKey: boolean } interface ModelInfo { id: string; provider: string; owned_by?: string } -interface SettingsData { codexAutoStart: boolean; port: number; hostname: string } +interface SettingsData { + codexAutoStart: boolean; + port: number; + hostname: string; + startupHealth?: { + status: "native" | "protected" | "at-risk"; + routingKind: "native" | "opencodex-local" | "custom-local" | "custom-remote" | "unknown"; + autostartEnabled: boolean; + shimCoverage: "full" | "cli-only" | "none"; + diagnosticStale: boolean; + }; +} type SidecarBackend = "openai" | "anthropic"; interface SidecarSetting { backend?: SidecarBackend; model: string } interface SidecarData { webSearch: SidecarSetting; vision: SidecarSetting } @@ -208,6 +220,7 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { const [injectionEfforts, setInjectionEfforts] = useState([]); const [injectionAvailable, setInjectionAvailable] = useState>([]); const [injectionSaving, setInjectionSaving] = useState(false); + const [multiAgentGuidanceEnabled, setMultiAgentGuidanceEnabled] = useState(true); const [effortCap, setEffortCap] = useState(""); const [subagentEffortCap, setSubagentEffortCap] = useState(""); const [effortCapSaving, setEffortCapSaving] = useState(false); @@ -221,6 +234,9 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { const updateRetryRef = useRef(0); const updateRetryTimerRef = useRef(null); const updateRequestEpochRef = useRef(0); + const settingsRequestEpochRef = useRef(0); + const settingsMutationEpochRef = useRef(0); + const settingsMutationInFlightRef = useRef(false); const [updateCheck, setUpdateCheck] = useState(null); const [updateError, setUpdateError] = useState(null); const [updateJob, setUpdateJob] = useState(null); @@ -256,7 +272,17 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { ]); setHealth(await hRes.json()); setProviders(await pRes.json()); - setSettings(await sRes.json()); + const settingsRequestEpoch = settingsRequestEpochRef.current; + const settingsMutationEpoch = settingsMutationEpochRef.current; + const nextSettings = await sRes.json() as SettingsData; + if (settingsPollMayCommit( + { request: settingsRequestEpoch, mutation: settingsMutationEpoch }, + { + request: settingsRequestEpochRef.current, + mutation: settingsMutationEpochRef.current, + mutationInFlight: settingsMutationInFlightRef.current, + }, + )) setSettings(nextSettings); setSidecar(await scRes.json()); // Old servers fall through to the SPA HTML for this route; don't let a parse // failure here take down the whole dashboard. @@ -275,7 +301,8 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { try { const imRes = await fetch(`${apiBase}/api/injection-model`); if (imRes.ok) { - const imData = await imRes.json() as { model?: string | null; effort?: string | null; efforts?: string[]; available?: Array<{ provider: string; model: string; namespaced: string }> }; + const imData = await imRes.json() as { multiAgentGuidanceEnabled?: boolean; model?: string | null; effort?: string | null; efforts?: string[]; available?: Array<{ provider: string; model: string; namespaced: string }> }; + setMultiAgentGuidanceEnabled(imData.multiAgentGuidanceEnabled !== false); setInjectionModel(imData.model ?? ""); setInjectionEffort(imData.effort ?? ""); setInjectionEfforts(imData.efforts ?? []); @@ -296,7 +323,10 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { }; fetchData(); const interval = setInterval(fetchData, 5000); - return () => clearInterval(interval); + return () => { + clearInterval(interval); + settingsRequestEpochRef.current += 1; + }; }, [apiBase]); useEffect(() => { @@ -401,6 +431,11 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { } const online = health?.status === "ok"; + const startupHealth = useMemo(() => { + if (!settings?.startupHealth) return null; + if (settings.startupHealth.diagnosticStale) return "error" as const; + return settings.startupHealth.status; + }, [settings?.startupHealth]); const saveSidecar = async (patch: SidecarPatch) => { if (!sidecar || sidecarSaving) return; @@ -460,6 +495,7 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { if (!settings || settingsSaving) return; const next = !settings.codexAutoStart; setSettingsSaving(true); + settingsMutationInFlightRef.current = true; setSettings({ ...settings, codexAutoStart: next }); try { const res = await fetch(`${apiBase}/api/settings`, { @@ -468,12 +504,14 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { body: JSON.stringify({ codexAutoStart: next }), }); if (!res.ok) throw new Error("save failed"); - const data = await res.json(); - setSettings(prev => prev ? { ...prev, codexAutoStart: data.codexAutoStart } : prev); + const data = await res.json() as { codexAutoStart: boolean; startupHealth?: SettingsData["startupHealth"] }; + settingsMutationEpochRef.current += 1; + setSettings(prev => prev ? { ...prev, codexAutoStart: data.codexAutoStart, startupHealth: data.startupHealth ?? prev.startupHealth } : prev); } catch { setSettings(prev => prev ? { ...prev, codexAutoStart: !next } : prev); setError(true); } finally { + settingsMutationInFlightRef.current = false; setSettingsSaving(false); } }; @@ -636,6 +674,21 @@ export default function Dashboard({ apiBase }: { apiBase: string }) {
+ {startupHealth && ( + + + )} + {projectConfigWarnings.length > 0 && (
@@ -791,6 +844,40 @@ export default function Dashboard({ apiBase }: { apiBase: string }) {
{t("dash.injectionHint")}
+
+
+
+
{t("dash.multiAgentGuidance")}
+
{t("dash.multiAgentGuidanceHint")}
+
+ +
+
+
@@ -1160,7 +1247,7 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { {t("models.v2Help")}
diff --git a/gui/src/startup-health-ui.ts b/gui/src/startup-health-ui.ts new file mode 100644 index 000000000..7cd13478c --- /dev/null +++ b/gui/src/startup-health-ui.ts @@ -0,0 +1,26 @@ +import type { TKey } from "./i18n/shared"; + +export interface StartupRiskDetail { + routingKind: "native" | "opencodex-local" | "custom-local" | "custom-remote" | "unknown"; + shimCoverage: "full" | "cli-only" | "none"; +} + +export function startupRiskDetailKey(health: StartupRiskDetail): TKey { + if (health.routingKind === "custom-local") return "startup.riskDetailCustomLocal"; + if (health.shimCoverage === "cli-only") return "startup.riskDetailWindowsShim"; + return "startup.riskDetail"; +} + +export interface SettingsPollEpoch { + request: number; + mutation: number; +} + +export function settingsPollMayCommit( + started: SettingsPollEpoch, + current: SettingsPollEpoch & { mutationInFlight: boolean }, +): boolean { + return !current.mutationInFlight + && started.request === current.request + && started.mutation === current.mutation; +} From bca3842201f93d6ab419dd31964617ecb3920078 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:55:23 +0200 Subject: [PATCH 13/23] fix(gui): keep dashboard hooks stable when connection errors --- gui/src/pages/Dashboard.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/gui/src/pages/Dashboard.tsx b/gui/src/pages/Dashboard.tsx index 0b48a8c54..a42696cd6 100644 --- a/gui/src/pages/Dashboard.tsx +++ b/gui/src/pages/Dashboard.tsx @@ -421,6 +421,12 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { }, [grouped, modelQuery]); const sidecarModels = useMemo(() => sidecarModelOptions(models), [models]); + const startupHealth = useMemo(() => { + if (!settings?.startupHealth) return null; + if (settings.startupHealth.diagnosticStale) return "error" as const; + return settings.startupHealth.status; + }, [settings?.startupHealth]); + if (error) { return ( } @@ -431,11 +437,6 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { } const online = health?.status === "ok"; - const startupHealth = useMemo(() => { - if (!settings?.startupHealth) return null; - if (settings.startupHealth.diagnosticStale) return "error" as const; - return settings.startupHealth.status; - }, [settings?.startupHealth]); const saveSidecar = async (patch: SidecarPatch) => { if (!sidecar || sidecarSaving) return; From 255fb7e8a68631fb23af464047f1b1fb29b2866f Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 25 Jul 2026 02:36:40 +0200 Subject: [PATCH 14/23] fix(gui): drop Providers classic toggle and harden dashboard selects Remove the per-tab Classic/Workspace buttons from Providers (sidebar owns view mode). Default Select menus to viewport-aware portals, keep language and dialog selects in-tree, restore Codex auto-switch CSS, treat stale startup health as error, and bump the shadow-call mutation epoch only after a successful write. --- gui/src/App.tsx | 1 + gui/src/pages/Dashboard.tsx | 197 ++++++++++++++++--------- gui/src/pages/Providers.tsx | 14 -- gui/src/select-position.ts | 4 +- gui/src/styles-dashboard-workspace.css | 48 ++++++ gui/src/styles.css | 57 +++++-- gui/src/ui.tsx | 5 +- gui/tests/select-portal.test.tsx | 11 +- gui/tests/select-position.test.ts | 2 +- 9 files changed, 239 insertions(+), 100 deletions(-) diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 52cf57732..2ef5937ca 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -380,6 +380,7 @@ export default function App() { onChange={v => setLocale(v as Locale)} label={t("lang.label")} placement="right" + portal={false} style={{ flex: 1, minWidth: 0, width: "100%" }} />
diff --git a/gui/src/pages/Dashboard.tsx b/gui/src/pages/Dashboard.tsx index a42696cd6..d31c5c7c2 100644 --- a/gui/src/pages/Dashboard.tsx +++ b/gui/src/pages/Dashboard.tsx @@ -8,6 +8,7 @@ import { formatTokens } from "../format-tokens"; import { EmptyState, Select } from "../ui"; interface HealthData { status: string; version: string; uptime: number } +type StartupHealthStatus = "native" | "protected" | "at-risk" | "error"; interface ProviderInfo { name: string; adapter: string; baseUrl: string; defaultModel?: string; hasApiKey: boolean } interface ModelInfo { id: string; provider: string; owned_by?: string } interface SettingsData { @@ -199,6 +200,7 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { const [modelQuery, setModelQuery] = useState(""); const [expandedProviders, setExpandedProviders] = useState>(new Set()); const [health, setHealth] = useState(null); + const [startupHealth, setStartupHealth] = useState(null); const [providers, setProviders] = useState([]); const [models, setModels] = useState([]); const [settings, setSettings] = useState(null); @@ -211,6 +213,7 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { const [settingsSaving, setSettingsSaving] = useState(false); const [syncing, setSyncing] = useState(false); const [maMode, setMaMode] = useState<"v1" | "default" | "v2">("default"); + const [maModeResolved, setMaModeResolved] = useState(false); const [maBusy, setMaBusy] = useState(false); const [maHelpOpen, setMaHelpOpen] = useState(false); const [effortCapHelpOpen, setEffortCapHelpOpen] = useState(false); @@ -237,6 +240,9 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { const settingsRequestEpochRef = useRef(0); const settingsMutationEpochRef = useRef(0); const settingsMutationInFlightRef = useRef(false); + const shadowCallRequestEpochRef = useRef(0); + const shadowCallMutationEpochRef = useRef(0); + const shadowCallMutationInFlightRef = useRef(false); const [updateCheck, setUpdateCheck] = useState(null); const [updateError, setUpdateError] = useState(null); const [updateJob, setUpdateJob] = useState(null); @@ -259,6 +265,29 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { } }, []); + useEffect(() => { + let cancelled = false; + const readStartupHealth = async () => { + try { + const response = await fetch(`${apiBase}/api/startup-health`); + if (!response.ok) throw new Error("startup health unavailable"); + const data = await response.json() as { status?: unknown; diagnosticStale?: unknown }; + const status = data.status; + const validStatus = status === "native" || status === "protected" || status === "at-risk"; + if (!validStatus) throw new Error("invalid startup health response"); + if (!cancelled) setStartupHealth(data.diagnosticStale === true ? "error" : status); + } catch { + if (!cancelled) setStartupHealth("error"); + } + }; + void readStartupHealth(); + const interval = window.setInterval(() => { void readStartupHealth(); }, 30_000); + return () => { + cancelled = true; + window.clearInterval(interval); + }; + }, [apiBase]); + useEffect(() => { const fetchData = async () => { try { @@ -274,6 +303,8 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { setProviders(await pRes.json()); const settingsRequestEpoch = settingsRequestEpochRef.current; const settingsMutationEpoch = settingsMutationEpochRef.current; + const shadowRequestEpoch = shadowCallRequestEpochRef.current; + const shadowMutationEpoch = shadowCallMutationEpochRef.current; const nextSettings = await sRes.json() as SettingsData; if (settingsPollMayCommit( { request: settingsRequestEpoch, mutation: settingsMutationEpoch }, @@ -282,11 +313,33 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { mutation: settingsMutationEpochRef.current, mutationInFlight: settingsMutationInFlightRef.current, }, - )) setSettings(nextSettings); + )) { + setSettings(nextSettings); + const seeded = nextSettings.startupHealth; + if (seeded) { + setStartupHealth(seeded.diagnosticStale ? "error" : seeded.status); + } + } setSidecar(await scRes.json()); // Old servers fall through to the SPA HTML for this route; don't let a parse // failure here take down the whole dashboard. - try { if (shRes.ok) setShadowCall(await shRes.json()); } catch { setShadowCall(null); } + try { + if (shRes.ok) { + const nextShadow = await shRes.json() as ShadowCallData; + // Ignore polls that raced a user toggle — otherwise the switch flips back + // to the pre-write value for a few seconds until the next poll. + if (settingsPollMayCommit( + { request: shadowRequestEpoch, mutation: shadowMutationEpoch }, + { + request: shadowCallRequestEpochRef.current, + mutation: shadowCallMutationEpochRef.current, + mutationInFlight: shadowCallMutationInFlightRef.current, + }, + )) { + setShadowCall(nextShadow); + } + } + } catch { setShadowCall(null); } try { setUsage30d(uRes.ok ? await uRes.json() : null); } catch { setUsage30d(null); } setError(false); // Best-effort v2 mode fetch (independent of core health) @@ -298,6 +351,7 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { else setMaMode("default"); } } catch { /* old server */ } + finally { setMaModeResolved(true); } try { const imRes = await fetch(`${apiBase}/api/injection-model`); if (imRes.ok) { @@ -317,8 +371,16 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { setSubagentEffortCap(ecData.subagentEffortCap ?? ""); } } catch { /* old server */ } + try { + const pcRes = await fetch(`${apiBase}/api/diagnostics/project-config`); + const pcData = pcRes.ok ? await pcRes.json() as { grouped?: ProjectCodexConfigGroup[] } : null; + setProjectConfigWarnings(pcData?.grouped ?? []); + } catch { + setProjectConfigWarnings([]); + } } catch { setError(true); + setMaModeResolved(true); } }; fetchData(); @@ -326,6 +388,7 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { return () => { clearInterval(interval); settingsRequestEpochRef.current += 1; + shadowCallRequestEpochRef.current += 1; }; }, [apiBase]); @@ -339,7 +402,6 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { setProjectConfigWarnings([]); } }; - void fetchDiagnostics(); const interval = setInterval(() => void fetchDiagnostics(), 30_000); return () => clearInterval(interval); }, [apiBase]); @@ -421,12 +483,6 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { }, [grouped, modelQuery]); const sidecarModels = useMemo(() => sidecarModelOptions(models), [models]); - const startupHealth = useMemo(() => { - if (!settings?.startupHealth) return null; - if (settings.startupHealth.diagnosticStale) return "error" as const; - return settings.startupHealth.status; - }, [settings?.startupHealth]); - if (error) { return ( } @@ -464,16 +520,25 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { async function saveShadowCall(patch: Partial) { if (!shadowCall || shadowCallSaving) return; - setShadowCallSaving(true); + const previous = shadowCall; const updated = { ...shadowCall, ...patch }; + setShadowCallSaving(true); + shadowCallMutationInFlightRef.current = true; setShadowCall(updated); try { - await fetch(`${apiBase}/api/shadow-call-settings`, { + const res = await fetch(`${apiBase}/api/shadow-call-settings`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch), }); + if (!res.ok) throw new Error("shadow-call save failed"); + // Bump only after a successful write so a poll that started mid-request + // (still carrying the pre-mutation epoch) cannot overwrite optimistic UI. + shadowCallMutationEpochRef.current += 1; + } catch { + setShadowCall(previous); } finally { + shadowCallMutationInFlightRef.current = false; setShadowCallSaving(false); } } @@ -620,6 +685,7 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { const overviewSection = (
+
@@ -667,28 +733,36 @@ export default function Dashboard({ apiBase }: { apiBase: string }) {
{t("dash.tokens30d")}
{usage30d && usage30d.summary.requests > 0 ? formatTokens(usage30d.summary.totalTokens, locale) : "—"}
- {usage30d && usage30d.summary.requests > 0 && ( -
- {t("dash.coverage").replace("{pct}", `${Math.round(usage30d.summary.coverageRatio * 100)}%`)} -
- )} +
+ {usage30d && usage30d.summary.requests > 0 + ? t("dash.coverage").replace("{pct}", `${Math.round(usage30d.summary.coverageRatio * 100)}%`) + : "\u00a0"} +
- {startupHealth && ( - - - )} + +
{projectConfigWarnings.length > 0 && (
@@ -708,7 +782,7 @@ export default function Dashboard({ apiBase }: { apiBase: string }) {
)} -{maMode !== "v1" && ( +{maModeResolved && maMode !== "v1" && (
@@ -843,10 +917,7 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { {injectionModel && {t("dash.injectionActive")}}
{t("dash.injectionHint")}
-
- -
-
+
{t("dash.multiAgentGuidance")}
{t("dash.multiAgentGuidanceHint")}
@@ -951,40 +1022,32 @@ export default function Dashboard({ apiBase }: { apiBase: string }) {
-
-
-
-
{t("dash.webSearchSidecar")}
-
{t("dash.webSearchSidecarHint")}
-
-
- { void saveSidecar({ webSearch: { model, backend: sidecarBackendForModel(models, model) } }); }} + disabled={!sidecar || sidecarSaving} + label={t("dash.sidecarModel")} + />
+
{t("dash.webSearchSidecarHint")}
-
-
-
-
{t("dash.visionSidecar")}
-
{t("dash.visionSidecarHint")}
-
-
- { void saveSidecar({ vision: { model, backend: sidecarBackendForModel(models, model) } }); }} + disabled={!sidecar || sidecarSaving} + label={t("dash.sidecarModel")} + />
+
{t("dash.visionSidecarHint")}
@@ -1021,7 +1084,7 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { {}} label="Backend" portal />, + {}} label="Language" placement="right" />, + + + + + + {enabled && model ? Active : null} +
+ ); +} + +test("guidance-off keeps stored selection but hides Active and disables controls", async () => { + const { createRoot } = await import("react-dom/client"); + const host = document.createElement("div"); + document.body.append(host); + let root!: Root; + function Shell() { + const [enabled, setEnabled] = useState(false); + useEffect(() => {}, []); + return ( +
+ + +
+ ); + } + await act(async () => { + root = createRoot(host); + root.render(); + }); + const model = host.querySelector('select[aria-label="model"]')!; + expect(model.disabled).toBe(true); + expect(model.value).toBe("claude"); + expect(host.textContent).not.toContain("Active"); + await act(async () => { host.querySelector("button")!.click(); }); + expect(model.disabled).toBe(false); + expect(host.textContent).toContain("Active"); + await act(async () => { root.unmount(); }); +}); diff --git a/gui/tests/select-keyboard.test.tsx b/gui/tests/select-keyboard.test.tsx new file mode 100644 index 000000000..17d2623d2 --- /dev/null +++ b/gui/tests/select-keyboard.test.tsx @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { Select } from "../src/ui"; + +const globals = ["document", "window", "navigator", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map((key) => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +const OPTIONS = [ + { value: "a", label: "Alpha" }, + { value: "b", label: "Beta" }, + { value: "c", label: "Charlie" }, +]; + +async function mountSelect(node: React.ReactElement): Promise<{ root: Root; trigger: HTMLButtonElement }> { + const { createRoot } = await import("react-dom/client"); + const host = document.createElement("div"); + document.body.append(host); + let root!: Root; + await act(async () => { + root = createRoot(host); + root.render(node); + }); + const trigger = host.querySelector("button.select-trigger")!; + return { root, trigger }; +} + +function key(target: HTMLElement, name: string) { + target.dispatchEvent(new testWindow.KeyboardEvent("keydown", { key: name, bubbles: true }) as unknown as KeyboardEvent); +} + +test("ArrowDown opens and Enter selects the active option (portal)", async () => { + let value = "a"; + const { root, trigger } = await mountSelect( + { value = next; }} label="Lang" portal={false} placement="right" />, + ); + await act(async () => { key(trigger, "End"); }); + await act(async () => { key(trigger, "Enter"); }); + expect(value).toBe("c"); + await act(async () => { key(trigger, "Home"); }); + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + await act(async () => { key(trigger, "Escape"); }); + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + expect(document.activeElement).toBe(trigger); + await act(async () => { root.unmount(); }); +}); + +test("disabled Select does not open from keyboard or click", async () => { + const { root, trigger } = await mountSelect( + {}} label="Pick" />, + ); + await act(async () => { key(trigger, "ArrowDown"); }); + expect(document.body.querySelector('[role="listbox"]')).not.toBeNull(); + await act(async () => { + document.body.dispatchEvent(new testWindow.MouseEvent("mousedown", { bubbles: true }) as unknown as MouseEvent); + }); + expect(document.body.querySelector('[role="listbox"]')).toBeNull(); + await act(async () => { root.unmount(); }); +}); diff --git a/gui/tests/view-mode-remount.test.tsx b/gui/tests/view-mode-remount.test.tsx new file mode 100644 index 000000000..f6fec8120 --- /dev/null +++ b/gui/tests/view-mode-remount.test.tsx @@ -0,0 +1,79 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act, useEffect, useState } from "react"; +import type { Root } from "react-dom/client"; +import type { ViewMode } from "../src/view-mode"; + +const globals = ["document", "window", "navigator", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map((key) => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +test("toggling viewMode does not remount an unsupported stateful page", async () => { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + let mounts = 0; + + function StatefulUnsupportedPage() { + useEffect(() => { + mounts += 1; + }, []); + return ; + } + + function SupportedPage({ viewMode }: { viewMode: ViewMode }) { + return
{viewMode}
; + } + + function Shell() { + const [viewMode, setViewMode] = useState("classic"); + return ( +
+ + + +
+ ); + } + + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render(); + }); + + const input = container.querySelector('input[aria-label="draft"]')!; + input.value = "keep-me"; + expect(input.value).toBe("keep-me"); + expect(mounts).toBe(1); + + await act(async () => { + container.querySelector("button")!.click(); + }); + + expect(container.querySelector('[data-testid="supported"]')?.textContent).toBe("workspace"); + expect(container.querySelector('input[aria-label="draft"]')?.value).toBe("keep-me"); + expect(mounts).toBe(1); + + await act(async () => { root.unmount(); }); +}); diff --git a/gui/tests/view-mode.test.ts b/gui/tests/view-mode.test.ts new file mode 100644 index 000000000..613d3e92d --- /dev/null +++ b/gui/tests/view-mode.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test"; +import { + GLOBAL_VIEW_KEY, + LEGACY_GLOBAL_VIEW_KEY, + ensureMigratedViewMode, + migrateLegacyViewMode, + providersHashAfterGlobalToggle, + providersHashForViewMode, + readViewMode, + toggleViewMode, + viewModeFromProvidersHash, + writeViewMode, + type StorageLike, +} from "../src/view-mode"; + +function memoryStorage(initial: Record = {}): StorageLike & { data: Record } { + const data = { ...initial }; + return { + data, + getItem(key) { + return Object.prototype.hasOwnProperty.call(data, key) ? data[key]! : null; + }, + setItem(key, value) { + data[key] = value; + }, + }; +} + +class ThrowingStorage implements StorageLike { + getItem(): string | null { + throw new Error("blocked"); + } + setItem(): void { + throw new Error("blocked"); + } +} + +describe("view-mode preference", () => { + test("defaults to classic when nothing is stored", () => { + expect(readViewMode(memoryStorage())).toBe("classic"); + }); + + test("reads canonical classic and workspace", () => { + expect(readViewMode(memoryStorage({ [GLOBAL_VIEW_KEY]: "classic" }))).toBe("classic"); + expect(readViewMode(memoryStorage({ [GLOBAL_VIEW_KEY]: "workspace" }))).toBe("workspace"); + }); + + test("accepts the interim legacy global key", () => { + expect(readViewMode(memoryStorage({ [LEGACY_GLOBAL_VIEW_KEY]: "workspace" }))).toBe("workspace"); + }); + + test("migrates providers-only workspace preference", () => { + const storage = memoryStorage({ "ocx-providers-view": "workspace" }); + expect(migrateLegacyViewMode(storage)).toBe("workspace"); + expect(ensureMigratedViewMode(storage)).toBe("workspace"); + expect(storage.data[GLOBAL_VIEW_KEY]).toBe("workspace"); + expect(storage.data["ocx-dashboard-view"]).toBe("workspace"); + }); + + test("mixed old keys prefer dashboard then providers", () => { + expect(migrateLegacyViewMode(memoryStorage({ + "ocx-dashboard-view": "classic", + "ocx-providers-view": "workspace", + }))).toBe("classic"); + expect(migrateLegacyViewMode(memoryStorage({ + "ocx-providers-view": "workspace", + }))).toBe("workspace"); + }); + + test("storage access throwing falls back to classic", () => { + expect(readViewMode(new ThrowingStorage())).toBe("classic"); + writeViewMode("workspace", new ThrowingStorage()); // must not throw + }); + + test("toggle updates canonical key", () => { + const storage = memoryStorage(); + const next = toggleViewMode(readViewMode(storage)); + writeViewMode(next, storage); + expect(next).toBe("workspace"); + expect(storage.data[GLOBAL_VIEW_KEY]).toBe("workspace"); + expect(readViewMode(storage)).toBe("workspace"); + }); +}); + +describe("providers hash sync helpers", () => { + test("maps both toggle directions while Providers is active", () => { + expect(providersHashAfterGlobalToggle("#providers", "workspace", true)).toBe("providers/workspace"); + expect(providersHashAfterGlobalToggle("#providers/workspace", "classic", true)).toBe("providers"); + expect(providersHashAfterGlobalToggle("#providers/workspace", "workspace", true)).toBeNull(); + expect(providersHashAfterGlobalToggle("#providers", "workspace", false)).toBeNull(); + }); + + test("round-trips hash helpers", () => { + expect(providersHashForViewMode("workspace")).toBe("providers/workspace"); + expect(viewModeFromProvidersHash("providers/workspace")).toBe("workspace"); + expect(viewModeFromProvidersHash("providers")).toBe("classic"); + expect(viewModeFromProvidersHash("dashboard")).toBeNull(); + }); +}); diff --git a/tests/startup-health-ui.test.ts b/tests/startup-health-ui.test.ts index 7d6d93987..4356b7dcb 100644 --- a/tests/startup-health-ui.test.ts +++ b/tests/startup-health-ui.test.ts @@ -1,5 +1,12 @@ import { describe, expect, test } from "bun:test"; -import { settingsPollMayCommit, startupRiskDetailKey } from "../gui/src/startup-health-ui"; +import { + PROJECT_CONFIG_DIAGNOSTICS_POLL_MS, + beginPollEpochs, + mapStartupHealthProbe, + seedStartupHealthFromSettings, + settingsPollMayCommit, + startupRiskDetailKey, +} from "../gui/src/startup-health-ui"; describe("startup health UI decisions", () => { test("selects the shared risk-detail message", () => { @@ -18,4 +25,77 @@ describe("startup health UI decisions", () => { expect(settingsPollMayCommit(started, { request: 4, mutation: 3, mutationInFlight: false })).toBe(false); expect(settingsPollMayCommit(started, { request: 4, mutation: 2, mutationInFlight: true })).toBe(false); }); + + test("maps stale diagnostics to error and rejects invalid payloads", () => { + expect(mapStartupHealthProbe({ status: "protected", diagnosticStale: true })).toBe("error"); + expect(mapStartupHealthProbe({ status: "native", diagnosticStale: false })).toBe("native"); + expect(mapStartupHealthProbe({ status: "nope" })).toBeNull(); + }); + + test("settings may only seed while startup health is still unknown", () => { + expect(seedStartupHealthFromSettings(null, { status: "protected", diagnosticStale: false })).toBe("protected"); + expect(seedStartupHealthFromSettings("error", { status: "protected", diagnosticStale: false })).toBe("error"); + expect(seedStartupHealthFromSettings(null, { status: "at-risk", diagnosticStale: true })).toBe("error"); + }); +}); + +describe("dashboard poll epochs", () => { + test("captures and bumps request epochs before fetches", () => { + const refs = { + settingsRequest: { current: 0 }, + settingsMutation: { current: 2 }, + shadowRequest: { current: 0 }, + shadowMutation: { current: 4 }, + }; + const first = beginPollEpochs(refs); + const second = beginPollEpochs(refs); + expect(first.settings).toEqual({ request: 1, mutation: 2 }); + expect(second.settings).toEqual({ request: 2, mutation: 2 }); + expect(first.shadow).toEqual({ request: 1, mutation: 4 }); + expect(second.shadow.request).toBe(2); + }); + + test("stale poll after successful mutation cannot commit", () => { + const started = beginPollEpochs({ + settingsRequest: { current: 3 }, + settingsMutation: { current: 1 }, + shadowRequest: { current: 3 }, + shadowMutation: { current: 1 }, + }); + expect(settingsPollMayCommit(started.settings, { + request: started.settings.request, + mutation: 2, + mutationInFlight: false, + })).toBe(false); + expect(settingsPollMayCommit(started.shadow, { + request: started.shadow.request, + mutation: 2, + mutationInFlight: false, + })).toBe(false); + }); + + test("overlapping polls resolving out of order keep only the latest request identity", () => { + const refs = { + settingsRequest: { current: 0 }, + settingsMutation: { current: 0 }, + shadowRequest: { current: 0 }, + shadowMutation: { current: 0 }, + }; + const older = beginPollEpochs(refs); + const newer = beginPollEpochs(refs); + expect(settingsPollMayCommit(older.settings, { + request: refs.settingsRequest.current, + mutation: 0, + mutationInFlight: false, + })).toBe(false); + expect(settingsPollMayCommit(newer.settings, { + request: refs.settingsRequest.current, + mutation: 0, + mutationInFlight: false, + })).toBe(true); + }); + + test("project-config diagnostics uses a single 30s owner cadence", () => { + expect(PROJECT_CONFIG_DIAGNOSTICS_POLL_MS).toBe(30_000); + }); }); From 5ff127f3f59f611760dd0fb89c7158ec532d8a3f Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 25 Jul 2026 03:29:55 +0200 Subject: [PATCH 18/23] fix(gui): clear App/Select lint for viewMode deep-link and option ids --- gui/src/App.tsx | 29 ++++++++++++++++++----------- gui/src/ui.tsx | 2 +- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 49e3bd18f..0cba2fbb8 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, startTransition } from "react"; import Dashboard from "./pages/Dashboard"; import Providers from "./pages/Providers"; import Models from "./pages/Models"; @@ -113,7 +113,19 @@ export default function App() { // Shared Classic/Workspace mode. Passed as a prop to pages that support it — // never remount the shared page container (that discards unsaved drafts). - const [viewMode, setViewMode] = useState(() => ensureMigratedViewMode()); + const [viewMode, setViewMode] = useState(() => { + const migrated = ensureMigratedViewMode(); + try { + const rawHash = window.location.hash.replace(/^#\/?/, ""); + if (rawHash === "providers/workspace") { + writeViewMode("workspace"); + return "workspace"; + } + } catch { + /* ignore */ + } + return migrated; + }); const toggleGlobalWorkspace = () => { const next = toggleViewMode(viewMode); writeViewMode(next); @@ -144,13 +156,13 @@ export default function App() { const fromHash = viewModeFromProvidersHash(rawHash); if (fromHash === "workspace") { writeViewMode("workspace"); - setViewMode("workspace"); + startTransition(() => setViewMode("workspace")); } else if (rawHash === "providers" && preferred === "workspace") { window.location.hash = "providers/workspace"; return; } else if (fromHash === "classic") { writeViewMode("classic"); - setViewMode("classic"); + startTransition(() => setViewMode("classic")); } } setPageState(nextPage); @@ -168,13 +180,8 @@ export default function App() { return; } if (page === "providers") { - // Honor an explicit workspace deep link on first load before normalizing - // to the saved preference (bookmarks/shared links must not open Classic). - if (rawHash === "providers/workspace") { - writeViewMode("workspace"); - setViewMode("workspace"); - return; - } + // Deep-link bookmarks are applied in the viewMode initializer; here we only + // normalize the hash to the shared React state (no sync setState). const wanted = providersHashForViewMode(viewMode); if (rawHash !== wanted) window.location.hash = wanted; return; diff --git a/gui/src/ui.tsx b/gui/src/ui.tsx index c54f92274..b874b88f7 100644 --- a/gui/src/ui.tsx +++ b/gui/src/ui.tsx @@ -45,7 +45,7 @@ export function Select({ value, options, onChange, disabled, label, style, align const ref = useRef(null); const triggerRef = useRef(null); const menuRef = useRef(null); - const optionId = useCallback((index: number) => `${listboxId}-opt-${index}`, [listboxId]); + const optionId = useCallback((index: number) => `${listboxId}-${index}`, [listboxId]); const current = options.find(o => o.value === value); const close = useCallback((restoreFocus = false) => { From e691b76efd7b48bd88844ea07acb1c273e011758 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 25 Jul 2026 03:57:00 +0200 Subject: [PATCH 19/23] fix(gui): replace Providers hash passively; make Select a combobox (#428) Use history.replaceState for preference/hash reconciliation so Back is not trapped, keep navigateHash for deliberate sidebar/toggle navigation, and expose a select-only combobox ARIA contract with Tab committing the active option. --- gui/src/App.tsx | 59 +++-- gui/src/hash-routing.ts | 60 +++++ gui/src/pages/Providers.tsx | 10 +- gui/src/ui.tsx | 28 ++- gui/tests/providers-hash-history.test.tsx | 281 ++++++++++++++++++++++ gui/tests/select-keyboard.test.tsx | 144 ++++++++++- 6 files changed, 533 insertions(+), 49 deletions(-) create mode 100644 gui/src/hash-routing.ts create mode 100644 gui/tests/providers-hash-history.test.tsx diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 0cba2fbb8..6c38680d0 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -22,10 +22,10 @@ import { providersHashForViewMode, readViewMode, toggleViewMode, - viewModeFromProvidersHash, writeViewMode, type ViewMode, } from "./view-mode"; +import { navigateHash, normalizeHashPath, replaceHash, resolveProvidersHashChange } from "./hash-routing"; installApiAuthFetch(); @@ -50,7 +50,7 @@ const PAGE_TKEY: Record = { }; function readPageFromHash(): Page { - const raw = location.hash.replace(/^#\/?/, ""); + const raw = normalizeHashPath(location.hash); // Sub-views use a "/" suffix (e.g. #providers/workspace); the first segment is the page id. const pageId = raw.split("/")[0] as Page; // Legacy: Debug used to be a standalone page; it now lives as a tab on Logs. @@ -116,7 +116,7 @@ export default function App() { const [viewMode, setViewMode] = useState(() => { const migrated = ensureMigratedViewMode(); try { - const rawHash = window.location.hash.replace(/^#\/?/, ""); + const rawHash = normalizeHashPath(window.location.hash); if (rawHash === "providers/workspace") { writeViewMode("workspace"); return "workspace"; @@ -130,39 +130,50 @@ export default function App() { const next = toggleViewMode(viewMode); writeViewMode(next); setViewMode(next); + // Deliberate Classic/Workspace toggle while Providers is open — push history. const wanted = providersHashAfterGlobalToggle(window.location.hash, next, page === "providers"); - if (wanted) window.location.hash = wanted; + if (wanted) navigateHash(wanted); }; useEffect(() => { // External navigation (hash edit, back/forward) also dismisses the mobile drawer. const onHash = () => { const nextPage = readPageFromHash(); - const rawHash = window.location.hash.replace(/^#\/?/, ""); + const rawHash = normalizeHashPath(window.location.hash); setNavOpen(false); - // Legacy #debug deep links → the Debug tab on Logs. + // Legacy #debug deep links → the Debug tab on Logs (passive; no extra history entry). if (rawHash === "debug" || rawHash.startsWith("debug/")) { - window.location.hash = "logs/debug"; + replaceHash("logs/debug"); + setPageState("logs"); return; } if (!hashBelongsToPage(rawHash, nextPage)) { - window.location.hash = nextPage === "providers" ? providersHashForPage() : nextPage; + // Passive correction of malformed hashes belonging to this page family. + replaceHash(nextPage === "providers" ? providersHashForPage() : nextPage); + setPageState(nextPage === "providers" ? "providers" : nextPage); + if (nextPage === "providers") { + startTransition(() => setViewMode(readViewMode())); + } return; } // Preference is source of truth for Classic/Workspace. Bare #providers must not // wipe a saved workspace choice (that regressed when leaving Providers and returning). if (nextPage === "providers") { const preferred = readViewMode(); - const fromHash = viewModeFromProvidersHash(rawHash); - if (fromHash === "workspace") { - writeViewMode("workspace"); - startTransition(() => setViewMode("workspace")); - } else if (rawHash === "providers" && preferred === "workspace") { - window.location.hash = "providers/workspace"; + const resolved = resolveProvidersHashChange(rawHash, preferred); + if (resolved.replaceTo) { + // Passive normalize — replaceState so Back is not trapped in a hash loop. + replaceHash(resolved.replaceTo); + if (resolved.viewMode) { + writeViewMode(resolved.viewMode); + startTransition(() => setViewMode(resolved.viewMode!)); + } + setPageState("providers"); return; - } else if (fromHash === "classic") { - writeViewMode("classic"); - startTransition(() => setViewMode("classic")); + } + if (resolved.viewMode) { + writeViewMode(resolved.viewMode); + startTransition(() => setViewMode(resolved.viewMode!)); } } setPageState(nextPage); @@ -172,22 +183,22 @@ export default function App() { }, []); useEffect(() => { - const rawHash = window.location.hash.replace(/^#\/?/, ""); + const rawHash = normalizeHashPath(window.location.hash); // Legacy #debug deep links must resolve before generic normalization // (otherwise the hash collapses to bare #logs and the tab choice is lost). if (rawHash === "debug" || rawHash.startsWith("debug/")) { - window.location.hash = "logs/debug"; + replaceHash("logs/debug"); return; } if (page === "providers") { // Deep-link bookmarks are applied in the viewMode initializer; here we only - // normalize the hash to the shared React state (no sync setState). + // normalize the hash to the shared React state (passive replace — no push). const wanted = providersHashForViewMode(viewMode); - if (rawHash !== wanted) window.location.hash = wanted; + if (rawHash !== wanted) replaceHash(wanted); return; } if (!hashBelongsToPage(rawHash, page)) { - window.location.hash = page; + replaceHash(page); } }, [page, viewMode]); @@ -317,8 +328,8 @@ export default function App() {
))} @@ -192,18 +205,19 @@ export function Select({ value, options, onChange, disabled, label, style, align + + +
+ ); +} + +describe("Providers hash history integration", () => { + async function mount(initialMode: ViewMode, initialHash: string) { + testWindow.location.hash = initialHash; + writeViewMode(initialMode); + testWindow.localStorage.setItem(GLOBAL_VIEW_KEY, initialMode); + const { createRoot } = await import("react-dom/client"); + const host = document.createElement("div"); + document.body.append(host); + let root!: Root; + const historyBefore = testWindow.history.length; + await act(async () => { + root = createRoot(host); + root.render(); + }); + return { root, host, historyBefore }; + } + + test("stored workspace + #providers passively normalizes without growing history", async () => { + const { root, host, historyBefore } = await mount("workspace", "#providers"); + expect(normalizeHashPath(window.location.hash)).toBe("providers/workspace"); + expect(host.querySelector('[data-testid="mode"]')?.textContent).toBe("workspace"); + expect(testWindow.history.length).toBe(historyBefore); + await act(async () => { root.unmount(); }); + }); + + test("direct #providers/workspace deep link forces workspace without duplicate history", async () => { + const { root, host, historyBefore } = await mount("classic", "#providers/workspace"); + expect(normalizeHashPath(window.location.hash)).toBe("providers/workspace"); + expect(host.querySelector('[data-testid="mode"]')?.textContent).toBe("workspace"); + expect(readViewMode()).toBe("workspace"); + expect(testWindow.history.length).toBe(historyBefore); + await act(async () => { root.unmount(); }); + }); + + test("explicit toggle Classic→Workspace pushes one history entry", async () => { + const { root, host } = await mount("classic", "#providers"); + const before = testWindow.history.length; + await act(async () => { + host.querySelector('[data-testid="toggle"]')!.click(); + }); + expect(normalizeHashPath(window.location.hash)).toBe("providers/workspace"); + expect(host.querySelector('[data-testid="mode"]')?.textContent).toBe("workspace"); + expect(readViewMode()).toBe("workspace"); + expect(testWindow.history.length).toBe(before + 1); + await act(async () => { root.unmount(); }); + }); + + test("explicit toggle Workspace→Classic pushes one history entry", async () => { + const { root, host } = await mount("workspace", "#providers/workspace"); + const before = testWindow.history.length; + await act(async () => { + host.querySelector('[data-testid="toggle"]')!.click(); + }); + expect(normalizeHashPath(window.location.hash)).toBe("providers"); + expect(host.querySelector('[data-testid="mode"]')?.textContent).toBe("classic"); + expect(testWindow.history.length).toBe(before + 1); + await act(async () => { root.unmount(); }); + }); + + test("Back after passive normalization can leave Providers without bouncing", async () => { + writeViewMode("workspace"); + testWindow.location.hash = "#dashboard"; + const { root, host } = await mount("workspace", "#dashboard"); + await act(async () => { + host.querySelector('[data-testid="go-providers"]')!.click(); + }); + // Deliberate nav lands on preferred workspace hash. + expect(normalizeHashPath(window.location.hash)).toBe("providers/workspace"); + + // Simulate a stale Back target of bare #providers (old history entry). + await act(async () => { + testWindow.location.hash = "#providers"; + window.dispatchEvent(new testWindow.HashChangeEvent("hashchange")); + }); + // Passive replace back to workspace — history length must not grow from that rewrite. + const afterNormalize = testWindow.history.length; + expect(normalizeHashPath(window.location.hash)).toBe("providers/workspace"); + + await act(async () => { + host.querySelector('[data-testid="go-dashboard"]')!.click(); + }); + expect(normalizeHashPath(window.location.hash)).toBe("dashboard"); + expect(host.querySelector('[data-testid="page"]')?.textContent).toBe("dashboard"); + + // Returning via deliberate Back-like assign to bare providers must normalize again without a loop of pushes. + await act(async () => { + testWindow.location.hash = "#providers"; + window.dispatchEvent(new testWindow.HashChangeEvent("hashchange")); + }); + expect(normalizeHashPath(window.location.hash)).toBe("providers/workspace"); + expect(testWindow.history.length).toBeLessThanOrEqual(afterNormalize + 2); + await act(async () => { root.unmount(); }); + }); + + test("non-Providers routes are unaffected by viewMode", async () => { + const { root, host } = await mount("workspace", "#dashboard"); + expect(normalizeHashPath(window.location.hash)).toBe("dashboard"); + await act(async () => { + host.querySelector('[data-testid="toggle"]')!.click(); + }); + expect(normalizeHashPath(window.location.hash)).toBe("dashboard"); + expect(host.querySelector('[data-testid="mode"]')?.textContent).toBe("classic"); + await act(async () => { root.unmount(); }); + }); +}); diff --git a/gui/tests/select-keyboard.test.tsx b/gui/tests/select-keyboard.test.tsx index 17d2623d2..837ec2de0 100644 --- a/gui/tests/select-keyboard.test.tsx +++ b/gui/tests/select-keyboard.test.tsx @@ -1,6 +1,6 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { Window } from "happy-dom"; -import { act } from "react"; +import { act, useState } from "react"; import type { Root } from "react-dom/client"; import { Select } from "../src/ui"; @@ -32,7 +32,7 @@ const OPTIONS = [ { value: "c", label: "Charlie" }, ]; -async function mountSelect(node: React.ReactElement): Promise<{ root: Root; trigger: HTMLButtonElement }> { +async function mountSelect(node: React.ReactElement): Promise<{ root: Root; trigger: HTMLButtonElement; host: HTMLDivElement }> { const { createRoot } = await import("react-dom/client"); const host = document.createElement("div"); document.body.append(host); @@ -42,25 +42,100 @@ async function mountSelect(node: React.ReactElement): Promise<{ root: Root; trig root.render(node); }); const trigger = host.querySelector("button.select-trigger")!; - return { root, trigger }; + return { root, trigger, host }; } function key(target: HTMLElement, name: string) { target.dispatchEvent(new testWindow.KeyboardEvent("keydown", { key: name, bubbles: true }) as unknown as KeyboardEvent); } +function listbox() { + return document.body.querySelector('[role="listbox"]'); +} + +async function assertComboboxContract(portal: boolean) { + function Harness() { + const [value, setValue] = useState("a"); + return ( +
+
{value}
+ { value = next; }} label="Pick" />, ); await act(async () => { key(trigger, "ArrowDown"); }); - expect(document.body.querySelector('[role="listbox"]')).not.toBeNull(); + expect(listbox()).not.toBeNull(); expect(trigger.getAttribute("aria-activedescendant")).toBeTruthy(); await act(async () => { key(trigger, "ArrowDown"); }); await act(async () => { key(trigger, "Enter"); }); expect(value).toBe("b"); - expect(document.body.querySelector('[role="listbox"]')).toBeNull(); + expect(listbox()).toBeNull(); expect(document.activeElement).toBe(trigger); await act(async () => { root.unmount(); }); }); @@ -86,9 +161,20 @@ test("disabled Select does not open from keyboard or click", async () => { {}} label="Empty" />, + ); + await act(async () => { key(trigger, "ArrowDown"); }); + expect(listbox()).toBeNull(); + await act(async () => { trigger.click(); }); + expect(listbox()).toBeNull(); await act(async () => { root.unmount(); }); }); @@ -97,10 +183,50 @@ test("outside click closes the menu", async () => { +
+ ); + } + const { root, host } = await mountSelect(); + const trigger = host.querySelector("button.select-trigger")!; + await act(async () => { host.querySelector('[data-testid="set-c"]')!.click(); }); + await act(async () => { key(trigger, "ArrowDown"); }); + const activeId = trigger.getAttribute("aria-activedescendant")!; + expect(document.getElementById(activeId)?.textContent).toContain("Charlie"); + await act(async () => { root.unmount(); }); +}); + +test("shrinking options clamps active descendant", async () => { + function Harness() { + const [opts, setOpts] = useState(OPTIONS); + return ( +
+ + +
+ ); + } + const { root, host } = await mountSelect(); + const trigger = host.querySelector("button.select-trigger")!; + await act(async () => { key(trigger, "ArrowDown"); }); + const option = listbox()!.querySelectorAll('[role="option"]')[1]!; + await act(async () => { option.click(); }); + expect(host.querySelector('[data-testid="value"]')!.textContent).toBe("b"); + expect(listbox()).toBeNull(); + expect(document.activeElement).toBe(trigger); + await act(async () => { root.unmount(); }); +}); + test("external value change updates active option on next open", async () => { function Harness() { const [value, setValue] = useState("a"); From 9e664671f754ec621060a84b0a947160992fafef Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 25 Jul 2026 05:08:58 +0200 Subject: [PATCH 21/23] fix(gui): treat Classic/Workspace as preference, not history navigation Replace Providers view-mode hashes in place so Back returns to the prior page, keep page+viewMode updates atomic, and test production useAppRouteState with real history.back/forward. --- gui/src/App.tsx | 133 +---------- gui/src/app-routing.ts | 120 ++++++++++ gui/src/use-app-route-state.ts | 98 ++++++++ gui/src/view-mode.ts | 8 +- gui/tests/dashboard-contracts.test.ts | 12 +- gui/tests/providers-hash-history.test.tsx | 271 ++++++++++++---------- gui/tests/view-mode.test.ts | 10 +- 7 files changed, 387 insertions(+), 265 deletions(-) create mode 100644 gui/src/app-routing.ts create mode 100644 gui/src/use-app-route-state.ts diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 6c38680d0..1d6cfbc91 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, startTransition } from "react"; +import { useEffect, useRef, useState } from "react"; import Dashboard from "./pages/Dashboard"; import Providers from "./pages/Providers"; import Models from "./pages/Models"; @@ -16,24 +16,13 @@ import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconH import { useI18n, useT, LOCALES, type Locale, type TKey } from "./i18n"; import { Select, Switch } from "./ui"; import { installApiAuthFetch } from "./api"; -import { - ensureMigratedViewMode, - providersHashAfterGlobalToggle, - providersHashForViewMode, - readViewMode, - toggleViewMode, - writeViewMode, - type ViewMode, -} from "./view-mode"; -import { navigateHash, normalizeHashPath, replaceHash, resolveProvidersHashChange } from "./hash-routing"; +import { type Page } from "./app-routing"; +import { useAppRouteState } from "./use-app-route-state"; installApiAuthFetch(); -type Page = "dashboard" | "startup" | "providers" | "models" | "combos" | "subagents" | "logs" | "usage" | "storage" | "codex-auth" | "api" | "claude"; type Theme = "light" | "dark" | "system"; -const VALID_PAGES = new Set(["dashboard", "startup", "providers", "models", "combos", "subagents", "logs", "usage", "storage", "codex-auth", "api", "claude"]); - const PAGE_TKEY: Record = { dashboard: "nav.dashboard", startup: "nav.startup", @@ -49,28 +38,9 @@ const PAGE_TKEY: Record = { claude: "nav.claude", }; -function readPageFromHash(): Page { - const raw = normalizeHashPath(location.hash); - // Sub-views use a "/" suffix (e.g. #providers/workspace); the first segment is the page id. - const pageId = raw.split("/")[0] as Page; - // Legacy: Debug used to be a standalone page; it now lives as a tab on Logs. - if (pageId === ("debug" as Page)) return "logs"; - return VALID_PAGES.has(pageId) ? pageId : "dashboard"; -} - -function hashBelongsToPage(rawHash: string, page: Page): boolean { - return rawHash === page - || (page === "providers" && rawHash === "providers/workspace") - || (page === "logs" && rawHash === "logs/debug"); -} - const API_BASE = import.meta.env.VITE_API_BASE || ""; const THEME_KEY = "ocx-theme"; -function providersHashForPage(): string { - return providersHashForViewMode(readViewMode()); -} - const NAV: { id: Page; tkey: TKey; Icon: typeof IconGrid }[] = [ { id: "dashboard", tkey: "nav.dashboard", Icon: IconGrid }, { id: "providers", tkey: "nav.providers", Icon: IconServer }, @@ -99,7 +69,7 @@ function readStoredTheme(): Theme { } export default function App() { - const [page, setPageState] = useState(readPageFromHash); + const { page, viewMode, toggleGlobalWorkspace, navigateToPage } = useAppRouteState(); const [theme, setTheme] = useState(readStoredTheme); const [runtimeVersion, setRuntimeVersion] = useState(null); const { locale, setLocale } = useI18n(); @@ -111,97 +81,17 @@ export default function App() { const sidebarRef = useRef(null); const navWasOpen = useRef(false); - // Shared Classic/Workspace mode. Passed as a prop to pages that support it — - // never remount the shared page container (that discards unsaved drafts). - const [viewMode, setViewMode] = useState(() => { - const migrated = ensureMigratedViewMode(); - try { - const rawHash = normalizeHashPath(window.location.hash); - if (rawHash === "providers/workspace") { - writeViewMode("workspace"); - return "workspace"; - } - } catch { - /* ignore */ - } - return migrated; - }); - const toggleGlobalWorkspace = () => { - const next = toggleViewMode(viewMode); - writeViewMode(next); - setViewMode(next); - // Deliberate Classic/Workspace toggle while Providers is open — push history. - const wanted = providersHashAfterGlobalToggle(window.location.hash, next, page === "providers"); - if (wanted) navigateHash(wanted); - }; - useEffect(() => { // External navigation (hash edit, back/forward) also dismisses the mobile drawer. - const onHash = () => { - const nextPage = readPageFromHash(); - const rawHash = normalizeHashPath(window.location.hash); - setNavOpen(false); - // Legacy #debug deep links → the Debug tab on Logs (passive; no extra history entry). - if (rawHash === "debug" || rawHash.startsWith("debug/")) { - replaceHash("logs/debug"); - setPageState("logs"); - return; - } - if (!hashBelongsToPage(rawHash, nextPage)) { - // Passive correction of malformed hashes belonging to this page family. - replaceHash(nextPage === "providers" ? providersHashForPage() : nextPage); - setPageState(nextPage === "providers" ? "providers" : nextPage); - if (nextPage === "providers") { - startTransition(() => setViewMode(readViewMode())); - } - return; - } - // Preference is source of truth for Classic/Workspace. Bare #providers must not - // wipe a saved workspace choice (that regressed when leaving Providers and returning). - if (nextPage === "providers") { - const preferred = readViewMode(); - const resolved = resolveProvidersHashChange(rawHash, preferred); - if (resolved.replaceTo) { - // Passive normalize — replaceState so Back is not trapped in a hash loop. - replaceHash(resolved.replaceTo); - if (resolved.viewMode) { - writeViewMode(resolved.viewMode); - startTransition(() => setViewMode(resolved.viewMode!)); - } - setPageState("providers"); - return; - } - if (resolved.viewMode) { - writeViewMode(resolved.viewMode); - startTransition(() => setViewMode(resolved.viewMode!)); - } - } - setPageState(nextPage); + const dismissNav = () => setNavOpen(false); + window.addEventListener("hashchange", dismissNav); + window.addEventListener("popstate", dismissNav); + return () => { + window.removeEventListener("hashchange", dismissNav); + window.removeEventListener("popstate", dismissNav); }; - window.addEventListener("hashchange", onHash); - return () => window.removeEventListener("hashchange", onHash); }, []); - useEffect(() => { - const rawHash = normalizeHashPath(window.location.hash); - // Legacy #debug deep links must resolve before generic normalization - // (otherwise the hash collapses to bare #logs and the tab choice is lost). - if (rawHash === "debug" || rawHash.startsWith("debug/")) { - replaceHash("logs/debug"); - return; - } - if (page === "providers") { - // Deep-link bookmarks are applied in the viewMode initializer; here we only - // normalize the hash to the shared React state (passive replace — no push). - const wanted = providersHashForViewMode(viewMode); - if (rawHash !== wanted) replaceHash(wanted); - return; - } - if (!hashBelongsToPage(rawHash, page)) { - replaceHash(page); - } - }, [page, viewMode]); - useEffect(() => { const el = document.documentElement; if (theme === "system") { el.removeAttribute("data-theme"); localStorage.removeItem(THEME_KEY); } @@ -329,8 +219,7 @@ export default function App() { - - + + + + + + +
); } -describe("Providers hash history integration", () => { - async function mount(initialMode: ViewMode, initialHash: string) { - testWindow.location.hash = initialHash; +async function awaitNavigation(action: () => void): Promise { + await act(async () => { + action(); + // happy-dom may deliver popstate/hashchange just after history.back/forward. + await new Promise((resolve) => setTimeout(resolve, 40)); + }); +} + +async function historyBack(): Promise { + await awaitNavigation(() => { + testWindow.history.back(); + }); +} + +async function historyForward(): Promise { + await awaitNavigation(() => { + testWindow.history.forward(); + }); +} + +describe("production App route state (useAppRouteState)", () => { +async function mount(initialMode: ViewMode, initialHash: string) { writeViewMode(initialMode); testWindow.localStorage.setItem(GLOBAL_VIEW_KEY, initialMode); + // Seed history with the initial hash via assignment (one entry), before React mounts. + const raw = initialHash.replace(/^#/, ""); + if (normalizeHashPath(testWindow.location.hash) !== raw) { + testWindow.location.hash = raw; + } + // Allow happy-dom to settle the hash before the route hook reads window.location. + await Promise.resolve(); const { createRoot } = await import("react-dom/client"); const host = document.createElement("div"); document.body.append(host); let root!: Root; + const renders: Array<{ page: string; mode: ViewMode }> = []; const historyBefore = testWindow.history.length; await act(async () => { root = createRoot(host); - root.render(); + root.render( renders.push(snap)} />); }); - return { root, host, historyBefore }; + // Allow the sync effect to passively replaceHash when needed. + await act(async () => { + await Promise.resolve(); + }); + return { root, host, historyBefore, renders }; } - test("stored workspace + #providers passively normalizes without growing history", async () => { - const { root, host, historyBefore } = await mount("workspace", "#providers"); + test("1. stored Workspace + bare #providers normalizes without growing history", async () => { + const { root, host, historyBefore, renders } = await mount("workspace", "#providers"); expect(normalizeHashPath(window.location.hash)).toBe("providers/workspace"); expect(host.querySelector('[data-testid="mode"]')?.textContent).toBe("workspace"); + expect(host.querySelector('[data-testid="page"]')?.textContent).toBe("providers"); expect(testWindow.history.length).toBe(historyBefore); + expect(renders[0]).toEqual({ page: "providers", mode: "workspace" }); await act(async () => { root.unmount(); }); }); - test("direct #providers/workspace deep link forces workspace without duplicate history", async () => { + test("2. explicit #providers/workspace deep link forces Workspace without duplicate history", async () => { const { root, host, historyBefore } = await mount("classic", "#providers/workspace"); expect(normalizeHashPath(window.location.hash)).toBe("providers/workspace"); expect(host.querySelector('[data-testid="mode"]')?.textContent).toBe("workspace"); @@ -208,7 +174,7 @@ describe("Providers hash history integration", () => { await act(async () => { root.unmount(); }); }); - test("explicit toggle Classic→Workspace pushes one history entry", async () => { + test("3. global toggle Classic→Workspace replaces hash (no history push)", async () => { const { root, host } = await mount("classic", "#providers"); const before = testWindow.history.length; await act(async () => { @@ -217,11 +183,11 @@ describe("Providers hash history integration", () => { expect(normalizeHashPath(window.location.hash)).toBe("providers/workspace"); expect(host.querySelector('[data-testid="mode"]')?.textContent).toBe("workspace"); expect(readViewMode()).toBe("workspace"); - expect(testWindow.history.length).toBe(before + 1); + expect(testWindow.history.length).toBe(before); await act(async () => { root.unmount(); }); }); - test("explicit toggle Workspace→Classic pushes one history entry", async () => { + test("4. global toggle Workspace→Classic replaces hash (no history push)", async () => { const { root, host } = await mount("workspace", "#providers/workspace"); const before = testWindow.history.length; await act(async () => { @@ -229,53 +195,100 @@ describe("Providers hash history integration", () => { }); expect(normalizeHashPath(window.location.hash)).toBe("providers"); expect(host.querySelector('[data-testid="mode"]')?.textContent).toBe("classic"); - expect(testWindow.history.length).toBe(before + 1); + expect(readViewMode()).toBe("classic"); + expect(testWindow.history.length).toBe(before); await act(async () => { root.unmount(); }); }); - test("Back after passive normalization can leave Providers without bouncing", async () => { - writeViewMode("workspace"); - testWindow.location.hash = "#dashboard"; - const { root, host } = await mount("workspace", "#dashboard"); + test("5–6. page nav + Workspace toggle: Back returns to Dashboard; Forward restores Providers Workspace", async () => { + const { root, host } = await mount("classic", "#dashboard"); await act(async () => { host.querySelector('[data-testid="go-providers"]')!.click(); }); - // Deliberate nav lands on preferred workspace hash. - expect(normalizeHashPath(window.location.hash)).toBe("providers/workspace"); + expect(normalizeHashPath(window.location.hash)).toBe("providers"); + expect(host.querySelector('[data-testid="page"]')?.textContent).toBe("providers"); - // Simulate a stale Back target of bare #providers (old history entry). + const afterProviders = testWindow.history.length; await act(async () => { - testWindow.location.hash = "#providers"; - window.dispatchEvent(new testWindow.HashChangeEvent("hashchange")); + host.querySelector('[data-testid="toggle"]')!.click(); }); - // Passive replace back to workspace — history length must not grow from that rewrite. - const afterNormalize = testWindow.history.length; expect(normalizeHashPath(window.location.hash)).toBe("providers/workspace"); + expect(testWindow.history.length).toBe(afterProviders); - await act(async () => { - host.querySelector('[data-testid="go-dashboard"]')!.click(); - }); + await historyBack(); expect(normalizeHashPath(window.location.hash)).toBe("dashboard"); expect(host.querySelector('[data-testid="page"]')?.textContent).toBe("dashboard"); - // Returning via deliberate Back-like assign to bare providers must normalize again without a loop of pushes. - await act(async () => { - testWindow.location.hash = "#providers"; - window.dispatchEvent(new testWindow.HashChangeEvent("hashchange")); - }); + await historyForward(); expect(normalizeHashPath(window.location.hash)).toBe("providers/workspace"); - expect(testWindow.history.length).toBeLessThanOrEqual(afterNormalize + 2); + expect(host.querySelector('[data-testid="page"]')?.textContent).toBe("providers"); + expect(host.querySelector('[data-testid="mode"]')?.textContent).toBe("workspace"); await act(async () => { root.unmount(); }); }); - test("non-Providers routes are unaffected by viewMode", async () => { - const { root, host } = await mount("workspace", "#dashboard"); + test("7. stale #providers/#providers/workspace history does not trap Back", async () => { + writeViewMode("workspace"); + // Build a legacy stack that still contains both Classic and Workspace Providers entries. + testWindow.location.hash = "dashboard"; + navigateHash("providers", testWindow as unknown as Window); + navigateHash("providers/workspace", testWindow as unknown as Window); + expect(testWindow.history.length).toBeGreaterThanOrEqual(3); + + const { root, host } = await mount("workspace", "#providers/workspace"); + const lengthBeforeBack = testWindow.history.length; + + await historyBack(); + // Landing on bare #providers must passively replace to workspace without appending. + expect(normalizeHashPath(window.location.hash)).toBe("providers/workspace"); + expect(host.querySelector('[data-testid="mode"]')?.textContent).toBe("workspace"); + expect(testWindow.history.length).toBeLessThanOrEqual(lengthBeforeBack); + + await historyBack(); expect(normalizeHashPath(window.location.hash)).toBe("dashboard"); + expect(host.querySelector('[data-testid="page"]')?.textContent).toBe("dashboard"); + await act(async () => { root.unmount(); }); + }); + + test("8. non-Providers routes keep their hash when view mode changes", async () => { + const { root, host } = await mount("workspace", "#dashboard"); + for (const id of ["go-dashboard", "go-claude", "go-models", "go-logs"] as const) { + await act(async () => { + host.querySelector(`[data-testid="${id}"]`)!.click(); + }); + const beforeHash = normalizeHashPath(window.location.hash); + await act(async () => { + host.querySelector('[data-testid="toggle"]')!.click(); + }); + expect(normalizeHashPath(window.location.hash)).toBe(beforeHash); + } + await act(async () => { root.unmount(); }); + }); + + test("9. unsaved local state survives a global view-mode toggle", async () => { + const { root, host } = await mount("classic", "#dashboard"); + const input = host.querySelector('input[aria-label="unsaved-draft"]')!; + input.value = "keep-me"; + expect(input.value).toBe("keep-me"); await act(async () => { host.querySelector('[data-testid="toggle"]')!.click(); }); - expect(normalizeHashPath(window.location.hash)).toBe("dashboard"); - expect(host.querySelector('[data-testid="mode"]')?.textContent).toBe("classic"); + expect(host.querySelector('[data-testid="mode"]')?.textContent).toBe("workspace"); + // Uncontrolled input retains DOM value across viewMode updates (no remount). + expect(host.querySelector('input[aria-label="unsaved-draft"]')?.value).toBe("keep-me"); await act(async () => { root.unmount(); }); }); + + test("Providers first paint matches final route contract for workspace deep link", async () => { + const { root, renders } = await mount("classic", "#providers/workspace"); + const providersRenders = renders.filter(r => r.page === "providers"); + expect(providersRenders.length).toBeGreaterThan(0); + expect(providersRenders[0]?.mode).toBe("workspace"); + expect(providersRenders.every(r => r.mode === "workspace")).toBe(true); + await act(async () => { root.unmount(); }); + }); + + test("preferred providers hash helper stays aligned with view mode", () => { + expect(providersHashForViewMode("workspace")).toBe("providers/workspace"); + expect(providersHashForViewMode("classic")).toBe("providers"); + }); }); diff --git a/gui/tests/view-mode.test.ts b/gui/tests/view-mode.test.ts index 613d3e92d..47d20ce75 100644 --- a/gui/tests/view-mode.test.ts +++ b/gui/tests/view-mode.test.ts @@ -4,7 +4,7 @@ import { LEGACY_GLOBAL_VIEW_KEY, ensureMigratedViewMode, migrateLegacyViewMode, - providersHashAfterGlobalToggle, + providersHashForGlobalViewChange, providersHashForViewMode, readViewMode, toggleViewMode, @@ -84,10 +84,10 @@ describe("view-mode preference", () => { describe("providers hash sync helpers", () => { test("maps both toggle directions while Providers is active", () => { - expect(providersHashAfterGlobalToggle("#providers", "workspace", true)).toBe("providers/workspace"); - expect(providersHashAfterGlobalToggle("#providers/workspace", "classic", true)).toBe("providers"); - expect(providersHashAfterGlobalToggle("#providers/workspace", "workspace", true)).toBeNull(); - expect(providersHashAfterGlobalToggle("#providers", "workspace", false)).toBeNull(); + expect(providersHashForGlobalViewChange("#providers", "workspace", true)).toBe("providers/workspace"); + expect(providersHashForGlobalViewChange("#providers/workspace", "classic", true)).toBe("providers"); + expect(providersHashForGlobalViewChange("#providers/workspace", "workspace", true)).toBeNull(); + expect(providersHashForGlobalViewChange("#providers", "workspace", false)).toBeNull(); }); test("round-trips hash helpers", () => { From 051ee809bc9afd62195222a16bdfa140ff7117e4 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 25 Jul 2026 05:15:04 +0200 Subject: [PATCH 22/23] fix(gui): retarget Providers workspace subroute contract after route extract Assert the exact providers/workspace hash ownership against app-routing and useAppRouteState instead of scraped App.tsx literals. --- tests/provider-workspace-rail.test.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/tests/provider-workspace-rail.test.ts b/tests/provider-workspace-rail.test.ts index 7c33ac2cb..bf193f0d4 100644 --- a/tests/provider-workspace-rail.test.ts +++ b/tests/provider-workspace-rail.test.ts @@ -73,10 +73,21 @@ describe("provider rail source contract", () => { }); test("preserves only the exact workspace subroute on page synchronization", async () => { + const { hashBelongsToPage } = await import("../gui/src/app-routing"); + // Exact subroute only — unknown Providers suffixes must not count as belonging. + expect(hashBelongsToPage("providers/workspace", "providers")).toBe(true); + expect(hashBelongsToPage("providers", "providers")).toBe(true); + expect(hashBelongsToPage("providers/other", "providers")).toBe(false); + expect(hashBelongsToPage("providers/workspace/extra", "providers")).toBe(false); + + const routing = await Bun.file("gui/src/app-routing.ts").text(); + const routeState = await Bun.file("gui/src/use-app-route-state.ts").text(); const app = await Bun.file("gui/src/App.tsx").text(); - expect(app).toContain('rawHash === "providers/workspace"'); - expect(app).toContain("hashBelongsToPage(rawHash, nextPage)"); - expect(app).toContain("hashBelongsToPage(rawHash, page)"); - expect(app).not.toContain("window.location.hash !== nextHash"); + expect(routing).toContain('rawHash === "providers/workspace"'); + expect(routing).toContain("hashBelongsToPage(rawHash, nextPage)"); + expect(routeState).toContain('rawHash === "providers/workspace"'); + expect(routeState).toContain("hashBelongsToPage(rawHash, page)"); + expect(app).toContain("useAppRouteState"); + expect(`${routing}\n${routeState}\n${app}`).not.toContain("window.location.hash !== nextHash"); }); }); From 421eb446eb175c79e083c8ad6d59d605dd9d230b Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 25 Jul 2026 05:27:43 +0200 Subject: [PATCH 23/23] fix(gui): show Select keyboard focus and keep session viewMode without storage Style select-option-active so keyboard navigation is visible without hover, and drive Providers hashes from in-memory viewMode so a localStorage failure cannot revert a live Classic/Workspace toggle. --- gui/src/styles.css | 15 ++- gui/src/use-app-route-state.ts | 25 ++-- gui/tests/dashboard-contracts.test.ts | 22 ++-- gui/tests/providers-hash-history.test.tsx | 133 ++++++++++++++++++++++ gui/tests/select-keyboard.test.tsx | 59 ++++++++++ 5 files changed, 235 insertions(+), 19 deletions(-) diff --git a/gui/src/styles.css b/gui/src/styles.css index a96e9dd53..2fce23943 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -657,11 +657,22 @@ select.input { appearance: none; } display: block; width: 100%; text-align: left; padding: 7px 12px; border: none; border-radius: var(--radius-sm); background: transparent; color: var(--text); font: inherit; font-size: var(--text-control); line-height: var(--leading-ui); - cursor: pointer; transition: background var(--motion-fast); + cursor: pointer; transition: background var(--motion-fast), box-shadow var(--motion-fast); white-space: nowrap; } -.select-option:hover { background: var(--hover); } +/* Pointer hover alone — softer than keyboard focus so the two cues stay distinct. */ +.select-option:hover:not(.select-option-active) { background: var(--hover); } +/* Selected value (committed). */ .select-option.active { background: var(--accent-soft); color: var(--accent); font-weight: var(--weight-semibold); } +/* Keyboard / aria-activedescendant focus — visible without relying on :hover. */ +.select-option-active { + background: var(--hover); + box-shadow: inset 2px 0 0 var(--accent); +} +.select-option.active.select-option-active { + background: var(--accent-soft); + box-shadow: inset 2px 0 0 var(--accent); +} /* ---- switch ---- */ .switch { diff --git a/gui/src/use-app-route-state.ts b/gui/src/use-app-route-state.ts index 29c5596e5..ab20196e3 100644 --- a/gui/src/use-app-route-state.ts +++ b/gui/src/use-app-route-state.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { hashBelongsToPage, readPageFromHash, @@ -10,7 +10,6 @@ import { ensureMigratedViewMode, providersHashForGlobalViewChange, providersHashForViewMode, - readViewMode, toggleViewMode, writeViewMode, type ViewMode, @@ -19,6 +18,7 @@ import { /** * Production App route + Classic/Workspace ownership. * Hash page changes push history; view-mode sync on Providers replaces the current entry. + * After init, React `viewMode` is authoritative for the session — storage is persistence only. */ export function useAppRouteState() { const [page, setPageState] = useState(readPageFromHash); @@ -35,27 +35,38 @@ export function useAppRouteState() { } return migrated; }); + const viewModeRef = useRef(viewMode); + + const commitViewMode = (next: ViewMode) => { + viewModeRef.current = next; + setViewMode(next); + }; + + useEffect(() => { + viewModeRef.current = viewMode; + }, [viewMode]); const applyHashAction = (rawHash: string) => { - const action = resolveAppHashChange(rawHash, readViewMode()); + // 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); if (action.persistViewMode) writeViewMode(action.persistViewMode); // Route page and view mode update in the same event — never via startTransition. setPageState(action.page); - if (action.viewMode) setViewMode(action.viewMode); + if (action.viewMode) commitViewMode(action.viewMode); }; const toggleGlobalWorkspace = () => { - const next = toggleViewMode(viewMode); + const next = toggleViewMode(viewModeRef.current); writeViewMode(next); - setViewMode(next); + commitViewMode(next); // Preference change, not page navigation: replace the Providers hash in place. const wanted = providersHashForGlobalViewChange(window.location.hash, next, page === "providers"); if (wanted) replaceHash(wanted); }; const navigateToPage = (id: Page) => { - navigateHash(id === "providers" ? providersHashForViewMode(readViewMode()) : id); + navigateHash(id === "providers" ? providersHashForViewMode(viewModeRef.current) : id); setPageState(id); }; diff --git a/gui/tests/dashboard-contracts.test.ts b/gui/tests/dashboard-contracts.test.ts index 95e7296e1..e18b2eb99 100644 --- a/gui/tests/dashboard-contracts.test.ts +++ b/gui/tests/dashboard-contracts.test.ts @@ -1,10 +1,12 @@ import { expect, test } from "bun:test"; import { PROJECT_CONFIG_DIAGNOSTICS_POLL_MS } from "../src/startup-health-ui"; -test("Dashboard owns project-config diagnostics on a dedicated poll interval", async () => { +test("project-config diagnostics poll cadence is owned by the shared constant", () => { expect(PROJECT_CONFIG_DIAGNOSTICS_POLL_MS).toBe(30_000); +}); + +test("Dashboard wires a single project-config diagnostics owner outside the settings poll", async () => { const src = await Bun.file(new URL("../src/pages/Dashboard.tsx", import.meta.url)).text(); - // Single endpoint owner: the dedicated diagnostics effect, not the five-second settings poll. expect(src.match(/diagnostics\/project-config/g)?.length ?? 0).toBe(1); expect(src).toContain("PROJECT_CONFIG_DIAGNOSTICS_POLL_MS"); const fetchDataStart = src.indexOf("const fetchData = async () => {"); @@ -14,17 +16,17 @@ test("Dashboard owns project-config diagnostics on a dedicated poll interval", a expect(src.slice(fetchDataStart, fetchDataEnd)).not.toContain("diagnostics/project-config"); }); -test("Dashboard workspace uses a labelled section instead of nested main", async () => { +test("Dashboard workspace pane is a labelled section, not a nested main landmark", async () => { const src = await Bun.file(new URL("../src/pages/Dashboard.tsx", import.meta.url)).text(); - expect(src).toContain('className="dashboard-workspace-main"'); - expect(src).toContain("aria-label={selected.label}"); - expect(src).toContain('aria-label={t("dash.workspace.sections")}'); + expect(src).toContain("dashboard-workspace-main"); + expect(src).toContain("dash.workspace.sections"); expect(src).not.toMatch(/]*dashboard-workspace-main/); + expect(src).toMatch(/<(section)\b[^>]*dashboard-workspace-main/); }); -test("multi-agent guidance disables injection controls when off", async () => { +test("multi-agent guidance gates injection controls and Active badge on the enabled flag", async () => { const src = await Bun.file(new URL("../src/pages/Dashboard.tsx", import.meta.url)).text(); - expect(src).toContain("disabled={injectionSaving || !multiAgentGuidanceEnabled}"); - expect(src).toContain("injectionModel && multiAgentGuidanceEnabled &&"); - expect(src).toContain("t(`models.v2Mode_${mode}` as TKey)"); + expect(src).toContain("!multiAgentGuidanceEnabled"); + expect(src).toContain("multiAgentGuidanceEnabled &&"); + expect(src).toContain("models.v2Mode_"); }); diff --git a/gui/tests/providers-hash-history.test.tsx b/gui/tests/providers-hash-history.test.tsx index a69f8a81f..59055a5de 100644 --- a/gui/tests/providers-hash-history.test.tsx +++ b/gui/tests/providers-hash-history.test.tsx @@ -292,3 +292,136 @@ async function mount(initialMode: ViewMode, initialHash: string) { expect(providersHashForViewMode("classic")).toBe("providers"); }); }); + +describe("runtime viewMode survives storage failure", () => { + function installThrowingStorage() { + const throwing = { + getItem() { throw new Error("blocked"); }, + setItem() { throw new Error("blocked"); }, + removeItem() { throw new Error("blocked"); }, + clear() { throw new Error("blocked"); }, + key() { throw new Error("blocked"); }, + get length() { throw new Error("blocked"); }, + }; + Object.defineProperty(globalThis, "localStorage", { configurable: true, value: throwing }); + Object.defineProperty(testWindow, "localStorage", { configurable: true, value: throwing }); + } + + async function mountThrowing(initialHash: string) { + installThrowingStorage(); + if (normalizeHashPath(testWindow.location.hash) !== initialHash.replace(/^#/, "")) { + testWindow.location.hash = initialHash.replace(/^#/, ""); + } + await Promise.resolve(); + const { createRoot } = await import("react-dom/client"); + const host = document.createElement("div"); + document.body.append(host); + let root!: Root; + await act(async () => { + root = createRoot(host); + root.render(); + }); + await act(async () => { await Promise.resolve(); }); + return { root, host }; + } + + test("getItem failure initializes Classic without crashing", async () => { + const { root, host } = await mountThrowing("#dashboard"); + expect(host.querySelector('[data-testid="mode"]')?.textContent).toBe("classic"); + expect(host.querySelector('[data-testid="page"]')?.textContent).toBe("dashboard"); + await act(async () => { root.unmount(); }); + }); + + test("toggle Workspace while setItem throws keeps in-memory Workspace on Providers", async () => { + const { root, host } = await mountThrowing("#dashboard"); + await act(async () => { + host.querySelector('[data-testid="toggle"]')!.click(); + }); + expect(host.querySelector('[data-testid="mode"]')?.textContent).toBe("workspace"); + // Storage still fails — readViewMode must not win over React state. + expect(readViewMode()).toBe("classic"); + + await act(async () => { + host.querySelector('[data-testid="go-providers"]')!.click(); + }); + expect(normalizeHashPath(window.location.hash)).toBe("providers/workspace"); + expect(host.querySelector('[data-testid="page"]')?.textContent).toBe("providers"); + expect(host.querySelector('[data-testid="mode"]')?.textContent).toBe("workspace"); + await act(async () => { root.unmount(); }); + }); + + test("toggle Classic while storage throws updates Providers hash from React state", async () => { + const { root, host } = await mountThrowing("#dashboard"); + await act(async () => { + host.querySelector('[data-testid="toggle"]')!.click(); + }); + await act(async () => { + host.querySelector('[data-testid="go-providers"]')!.click(); + }); + expect(normalizeHashPath(window.location.hash)).toBe("providers/workspace"); + + await act(async () => { + host.querySelector('[data-testid="toggle"]')!.click(); + }); + expect(host.querySelector('[data-testid="mode"]')?.textContent).toBe("classic"); + expect(normalizeHashPath(window.location.hash)).toBe("providers"); + await act(async () => { root.unmount(); }); + }); + + test("Back/Forward after failed persistence stay coherent with in-memory mode", async () => { + const { root, host } = await mountThrowing("#dashboard"); + await act(async () => { + host.querySelector('[data-testid="toggle"]')!.click(); + }); + await act(async () => { + host.querySelector('[data-testid="go-providers"]')!.click(); + }); + expect(normalizeHashPath(window.location.hash)).toBe("providers/workspace"); + + await historyBack(); + expect(normalizeHashPath(window.location.hash)).toBe("dashboard"); + expect(host.querySelector('[data-testid="mode"]')?.textContent).toBe("workspace"); + + await historyForward(); + expect(normalizeHashPath(window.location.hash)).toBe("providers/workspace"); + expect(host.querySelector('[data-testid="page"]')?.textContent).toBe("providers"); + expect(host.querySelector('[data-testid="mode"]')?.textContent).toBe("workspace"); + await act(async () => { root.unmount(); }); + }); + + test("explicit #providers/workspace deep link works while storage throws", async () => { + const { root, host } = await mountThrowing("#providers/workspace"); + expect(host.querySelector('[data-testid="mode"]')?.textContent).toBe("workspace"); + expect(normalizeHashPath(window.location.hash)).toBe("providers/workspace"); + await act(async () => { root.unmount(); }); + }); + + test("multiple toggles keep the latest React mode when storage throws", async () => { + const { root, host } = await mountThrowing("#dashboard"); + for (let i = 0; i < 3; i++) { + await act(async () => { + host.querySelector('[data-testid="toggle"]')!.click(); + }); + } + expect(host.querySelector('[data-testid="mode"]')?.textContent).toBe("workspace"); + await act(async () => { + host.querySelector('[data-testid="go-providers"]')!.click(); + }); + expect(normalizeHashPath(window.location.hash)).toBe("providers/workspace"); + await act(async () => { root.unmount(); }); + }); + + test("non-Providers hashes stay put when toggling with throwing storage", async () => { + const { root, host } = await mountThrowing("#models"); + await act(async () => { + host.querySelector('[data-testid="go-models"]')!.click(); + }); + expect(normalizeHashPath(window.location.hash)).toBe("models"); + await act(async () => { + host.querySelector('[data-testid="toggle"]')!.click(); + }); + expect(normalizeHashPath(window.location.hash)).toBe("models"); + expect(host.querySelector('[data-testid="mode"]')?.textContent).toBe("workspace"); + await act(async () => { root.unmount(); }); + }); +}); diff --git a/gui/tests/select-keyboard.test.tsx b/gui/tests/select-keyboard.test.tsx index 6bf3058fb..c4b1991b2 100644 --- a/gui/tests/select-keyboard.test.tsx +++ b/gui/tests/select-keyboard.test.tsx @@ -249,5 +249,64 @@ test("shrinking options clamps active descendant", async () => { expect(activeId).toBeTruthy(); expect(document.getElementById(activeId!)).not.toBeNull(); expect(listbox()!.querySelectorAll('[role="option"]').length).toBe(1); + expect(document.getElementById(activeId!)?.classList.contains("select-option-active")).toBe(true); await act(async () => { root.unmount(); }); }); + +async function assertKeyboardActiveClassMoves(portal: boolean) { + function Harness() { + const [value, setValue] = useState("a"); + return ( +
+
{value}
+