From 2891af3af4ec65b75a216011b9ee2ccc2a59e684 Mon Sep 17 00:00:00 2001 From: MisterWanted Date: Sun, 6 Sep 2026 21:25:28 +0200 Subject: [PATCH 01/10] feat(gui): establish Orbit workspace identity and motion navigation --- .../docs/getting-started/quickstart.md | 6 + gui/DESIGN.md | 92 +++-- gui/bun.lock | 12 + gui/package.json | 2 + gui/src/App.tsx | 326 ++++++++++++------ gui/src/components/WorkspaceNavigation.tsx | 57 +++ gui/src/main.tsx | 8 +- gui/src/styles/workspace-orbit.css | 231 +++++++++++++ gui/tests/sidebar-codex-auth.test.ts | 6 +- gui/tests/workspace-navigation.test.tsx | 265 ++++++++++++++ 10 files changed, 872 insertions(+), 133 deletions(-) create mode 100644 gui/src/components/WorkspaceNavigation.tsx create mode 100644 gui/src/styles/workspace-orbit.css create mode 100644 gui/tests/workspace-navigation.test.tsx diff --git a/docs-site/src/content/docs/getting-started/quickstart.md b/docs-site/src/content/docs/getting-started/quickstart.md index 11a9a7a1..bcdf8202 100644 --- a/docs-site/src/content/docs/getting-started/quickstart.md +++ b/docs-site/src/content/docs/getting-started/quickstart.md @@ -63,6 +63,12 @@ curl -sS "http://127.0.0.1:/healthz" The proxy exposes `/healthz` and `/v1/*` for any Responses-compatible client. You do not need Codex installed for this step. +The pending Orbit dashboard redesign keeps the same routes and API. Its desktop navigation +uses a side rail; smaller screens keep all destinations in a horizontal navigation strip. +Overview, Providers & accounts, Models, Traffic, Usage and System remain available, including +existing bookmarked hashes. Navigation motion respects reduced-motion preferences. This +interface work does not change provider credentials or the proxy's deployment state. + ## 4. Use Codex (optional) If you enabled Codex injection in step 1, Codex talks to opencodex transparently: diff --git a/gui/DESIGN.md b/gui/DESIGN.md index cde54a9b..2e217f5f 100644 --- a/gui/DESIGN.md +++ b/gui/DESIGN.md @@ -1,8 +1,60 @@ -# gui/DESIGN.md — de ontwerptaal van het dashboard +# OCX Orbit — nieuwe productidentiteit + +## Actuele autoriteit: expliciete redesignopdracht 2026-09-06 + +Joep heeft voor OCX expliciet een volledig nieuwe identiteit gevraagd: niet de +bestaande ChefGroep/Signaal-stijl, maar moderner, componentgedreven, met betere +verhoudingen en meer motion. Deze sectie vervangt daarom de visuele voorschriften +hieronder voor de nieuwe interface. De historische Signaal-beschrijving blijft +alleen migratiecontext, niet een reden om nieuw werk terug te draaien. Andere +ChefGroep-producten en het gedeelde design-system worden hiermee niet gewijzigd. + +### Richting en componenten + +Orbit is een model-workspace: contrastrijke inktvlakken, heldere lichte oppervlakken, +mint als interactieaccent en Manrope als interfaceletter. JetBrains Mono blijft +voor modelnamen en technische gegevens. Geen marketinghero boven dagelijkse data. + +- `WorkspaceNavigation`: één getypeerde bestemmingencatalogus, iconen, labels, + actieve pagina en een gedeelde Motion-selectie. Bestaande hashes blijven werken. +- Bestaande controls blijven hun toegankelijke, geteste gedrag behouden. Hun + geometrie en uiterlijk komen uit gedeelde tokens, niet uit per-scherm hacks. +- Motion for React (`LazyMotion`) verzorgt gedeelde layoutovergangen. CSS behandelt + eenvoudige hover/press-feedback. Geen React-state-updates per animatieframe. +- Nieuwe dependencies zijn exact gepind en vereisen de bestaande security-review. +- ThreeUI blijft voorlopig eigenaar van de bestaande publieke landing. Die landing + is nog niet hetzelfde als een volledig herontworpen productpagina. + +### Geometrie en responsiviteit + +Werkvlak maximaal 1440px; navigatie 224px (190px op kleinere desktops); header 72px. +Controls 32/38/44px, touch minimaal 44px; radii 8/10/16/20px. Typografie 12/13/14/15/ +30/38px. Mobiel krijgt horizontale navigatie met alle labels behouden. Geen +afgesneden werkpanelen, paginabrede horizontale overflow of onbereikbare acties. + +### Toestanden en motion + +Echte API-status blijft leidend: laden, leeg, gedeeltelijk, fout en offline mogen +niet worden vervangen door mooie voorbeeldcijfers. Selectie beweegt met een +onderbreekbare spring; toetsenbordnavigatie en reduced-motion slaan die beweging +over. Focus, contrast en labels blijven zichtbaar in licht én donker. + +### Implementatiestatus en acceptatie + +De eerste slice vervangt de shell, navigatie, typografie en gedeelde tokens. +Detailpanelen, volledige componentmigratie, landing, screenshots op 1440/1024/390px +en alle interactietoestanden blijven expliciete vervolgstappen. Niet als volledig +redesign of live release presenteren zolang die niet aantoonbaar zijn afgerond. +Tokens: `src/styles/workspace-orbit.css`; die worden na de bestaande laag geladen. +Geen nieuwe parallelle app, backend, routing- of authenticatielaag. + +--- + +# Historisch: Signaal-binding vóór de redesignopdracht > De levende ontwerp- en smaakgids voor het opencodex-dashboard (`gui/`). > Dit is de ChefGroep-taal (v3 "Signaal"): een stil, warm, mat instrument. -> Bron van waarheid voor de *taal*: [`GroepOnline/design-system`](https://github.com/GroepOnline/design-system) +> Bron van waarheid voor de _taal_: [`GroepOnline/design-system`](https://github.com/GroepOnline/design-system) > (`tokens.css`, `DESIGN.md`, `motion-spec.md`). Dit bestand legt vast hoe die > taal in dít dashboard leeft, en — belangrijker — **hoe je 'm uitbreidt zonder > 'm te breken**. @@ -31,14 +83,14 @@ aan" zegt is blauw: links, focus-ring, actieve nav, geselecteerde tab, toggles, selectie. De **primaire knop** blijft juist monochroom (tekst↔achtergrond omgekeerd) — dat is de shadcn-conventie, geen tweede accent. -| Rol | Token | -|---|---| -| Achtergrond / rail / kaart | `--bg` · `--rail` · `--surface` · `--raised` | -| Lijnen | `--border` (sterk) · `--border-soft` (hairline) | -| Tekst | `--text` · `--muted` · `--faint` | -| Primaire actie | `--accent` (+ `--accent-ink`) | -| Het accent | `--accent-blue` · `--accent-blue-ink` · `--accent-soft` (ring/tint) | -| Semantiek | `--green` (git/PR/toestemming) · `--amber` (wacht-op-jou) · `--red` (destructief) | +| Rol | Token | +| -------------------------- | --------------------------------------------------------------------------------- | +| Achtergrond / rail / kaart | `--bg` · `--rail` · `--surface` · `--raised` | +| Lijnen | `--border` (sterk) · `--border-soft` (hairline) | +| Tekst | `--text` · `--muted` · `--faint` | +| Primaire actie | `--accent` (+ `--accent-ink`) | +| Het accent | `--accent-blue` · `--accent-blue-ink` · `--accent-soft` (ring/tint) | +| Semantiek | `--green` (git/PR/toestemming) · `--amber` (wacht-op-jou) · `--red` (destructief) | Regels: groen/amber/rood zijn **gereserveerd**, nooit decoratie. Neutraal is warm, nooit koudgrijs. Dark mode is basalt-warm, geen zuiver zwart. Elke token @@ -53,16 +105,16 @@ is `light-dark(licht, donker)` — schrijf beide kanten, altijd. diffs, tellers). Geen Archivo/Inter/Geist — design-system v3 §11. - Eén type-ladder — gebruik de tokens, nooit losse px: -| Token | px | Gebruik | -|---|---|---| -| `--text-micro` | 10.5 | meta, tellers, caps-labels | -| `--text-caption` | 11.5 | labels, captions | -| `--text-label` | 12.5 | secundair / beschrijvingen | -| `--text-control` | 13.5 | **UI-standaard** (body van de app) | -| `--text-body` | 15 | leestekst | -| `--text-subtitle` | 15 | kleine titels | -| `--text-title` | 24 | paginatitels | -| `--text-display` | 28 | hero-getallen (product-extensie, geen §11-trede) | +| Token | px | Gebruik | +| ----------------- | ---- | ------------------------------------------------ | +| `--text-micro` | 10.5 | meta, tellers, caps-labels | +| `--text-caption` | 11.5 | labels, captions | +| `--text-label` | 12.5 | secundair / beschrijvingen | +| `--text-control` | 13.5 | **UI-standaard** (body van de app) | +| `--text-body` | 15 | leestekst | +| `--text-subtitle` | 15 | kleine titels | +| `--text-title` | 24 | paginatitels | +| `--text-display` | 28 | hero-getallen (product-extensie, geen §11-trede) | - Koppen: gewicht 500, `letter-spacing: var(--tracking-tight)` (−0.02em), `text-wrap: balance`. Leading via `--leading-*` (tight 1.2 / ui 1.45 / diff --git a/gui/bun.lock b/gui/bun.lock index 3f47f566..d0caf5bb 100644 --- a/gui/bun.lock +++ b/gui/bun.lock @@ -7,8 +7,10 @@ "dependencies": { "@designcodeio/threeui": "^1.1.0", "@fontsource-variable/jetbrains-mono": "^5.3.0", + "@fontsource-variable/manrope": "5.3.0", "@fontsource/instrument-serif": "^5.3.0", "@tanstack/react-virtual": "^3.14.5", + "motion": "13.2.0", "posthog-js": "^1.422.5", "react": "^19.2.7", "react-dom": "^19.2.7", @@ -95,6 +97,8 @@ "@fontsource-variable/jetbrains-mono": ["@fontsource-variable/jetbrains-mono@5.3.0", "", {}, "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw=="], + "@fontsource-variable/manrope": ["@fontsource-variable/manrope@5.3.0", "", {}, "sha512-6D5dgokHsWDDMtmXHznKa0hK229NN+1a4BLPmUCLqcO1Pw5EEhWY5RFt0AcXnVRAljFFPfRtLkJePQj6LSsV6g=="], + "@fontsource/instrument-serif": ["@fontsource/instrument-serif@5.3.0", "", {}, "sha512-mDiaIg0u67sYV59fie92Wz4sM8UiVlbL7fLxnFPCKkX15DMASEnQTREbaP5S5/3DCcsoAOcQ0sV9E4AeY4QqYQ=="], "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], @@ -285,6 +289,8 @@ "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], + "framer-motion": ["framer-motion@13.2.0", "", { "dependencies": { "motion-dom": "^13.2.0", "motion-utils": "^13.0.0", "tslib": "^2.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-9E33ebgMaO33w1nN/jEdW8z3/GO483fMi4rqbMG9rt83XgW9QLKRe4NcmJ8s+fQ3O34++UHrIQwlIWGIWTITjA=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], @@ -355,6 +361,12 @@ "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "motion": ["motion@13.2.0", "", { "dependencies": { "framer-motion": "^13.2.0", "tslib": "^2.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-4Hrb5vD6HhjFstLUiCmWvtpsw+WTpP4R+QXfSDYZBz7+uxE/LrRg3aV0ReJxHrTRffHhbIE6svEqnotngXvesQ=="], + + "motion-dom": ["motion-dom@13.2.0", "", { "dependencies": { "motion-utils": "^13.0.0" } }, "sha512-N6gdSoWRDk0Rh/fVtlqUtLs+fEN3ELFZI3cn3IQE9Mnf3E+Mh8wjO6MstzCOPFh4Yf0L1as5m2eUyYWj8ylVSQ=="], + + "motion-utils": ["motion-utils@13.0.0", "", {}, "sha512-7DnN7TmbLcYXcG4RVadXIihWlyuM9afoUww8Y5Agg431kGKiuL2/OMyP4mJ5wLz+pvN3t5ySClLOaVXJ+wekRQ=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], diff --git a/gui/package.json b/gui/package.json index cfd38f21..4d5228e2 100644 --- a/gui/package.json +++ b/gui/package.json @@ -16,8 +16,10 @@ "dependencies": { "@designcodeio/threeui": "^1.1.0", "@fontsource-variable/jetbrains-mono": "^5.3.0", + "@fontsource-variable/manrope": "5.3.0", "@fontsource/instrument-serif": "^5.3.0", "@tanstack/react-virtual": "^3.14.5", + "motion": "13.2.0", "posthog-js": "^1.422.5", "react": "^19.2.7", "react-dom": "^19.2.7", diff --git a/gui/src/App.tsx b/gui/src/App.tsx index ece1d65e..3ed6c5a8 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -8,6 +8,18 @@ import { installApiAuthFetch } from "./api"; import { canonicalHashFor, type View } from "./app-routing"; import { useAppRouteState } from "./use-app-route-state"; import { requestProxyStop } from "./stop-proxy"; +import { domAnimation, LazyMotion, MotionConfig } from "motion/react"; +import WorkspaceNavigation, { + type WorkspaceDestination, +} from "./components/WorkspaceNavigation"; +import { + IconActivity, + IconBoxes, + IconGrid, + IconList, + IconMonitor, + IconServer, +} from "./icons"; const Providers = lazy(() => import("./pages/Providers")); const Models = lazy(() => import("./pages/Models")); @@ -31,24 +43,19 @@ type Theme = "light" | "dark" | "system"; const API_BASE = import.meta.env.VITE_API_BASE || ""; const THEME_KEY = "ocx-theme"; -/** Five views (design-system v2 IA, 2026-08). Dashboard is home. */ -const VIEW_TABS: { view: View; tkey: TKey }[] = [ - { view: "dashboard", tkey: "nav.dashboard" }, - { view: "leveranciers", tkey: "nav.providers" }, - { view: "modellen", tkey: "nav.models" }, - { view: "verkeer", tkey: "nav.verkeer" }, - { view: "verbruik", tkey: "nav.usage" }, - { view: "systeem", tkey: "nav.systeem" }, +const VIEW_TABS: WorkspaceDestination[] = [ + { view: "dashboard", tkey: "nav.dashboard", icon: IconGrid }, + { view: "leveranciers", tkey: "nav.providers", icon: IconServer }, + { view: "modellen", tkey: "nav.models", icon: IconBoxes }, + { view: "verkeer", tkey: "nav.verkeer", icon: IconList }, + { view: "verbruik", tkey: "nav.usage", icon: IconActivity }, + { view: "systeem", tkey: "nav.systeem", icon: IconMonitor }, ]; /** Sub-tabs per view; `null` is the view's home target. */ const SUB_TABS: Record = { - landing: [ - { sub: null, tkey: "nav.dashboard" }, - ], - dashboard: [ - { sub: null, tkey: "nav.dashboard" }, - ], + landing: [{ sub: null, tkey: "nav.dashboard" }], + dashboard: [{ sub: null, tkey: "nav.dashboard" }], leveranciers: [ { sub: null, tkey: "sub.overview" }, { sub: "claude", tkey: "nav.claude" }, @@ -63,9 +70,7 @@ const SUB_TABS: Record = { { sub: null, tkey: "sub.logs" }, { sub: "debug", tkey: "sub.debug" }, ], - verbruik: [ - { sub: null, tkey: "nav.usage" }, - ], + verbruik: [{ sub: null, tkey: "nav.usage" }], systeem: [ { sub: null, tkey: "sub.status" }, { sub: "storage", tkey: "nav.storage" }, @@ -116,14 +121,27 @@ export default function App() { detailsLabel={t("errorBoundary.details")} reloadLabel={t("errorBoundary.reload")} > - {t("common.loading")}}> + + {t("common.loading")} + + } + > ); } - return ; + return ( + + ); } function DashboardShell({ @@ -153,10 +171,15 @@ function DashboardShell({ const displayedVersion: string = healthPoll.data?.version ?? __APP_VERSION__; // null = first poll still in flight: no stamp until the first verdict. - const proxyOnline: boolean | null = healthPoll.error ? false : healthPoll.data ? true : null; + const proxyOnline: boolean | null = healthPoll.error + ? false + : healthPoll.data + ? true + : null; const activeTkey = - SUB_TABS[route.view].find(s => s.sub === route.sub)?.tkey ?? "nav.providers"; + SUB_TABS[route.view].find((s) => s.sub === route.sub)?.tkey ?? + "nav.providers"; const brand = (
@@ -167,89 +190,142 @@ function DashboardShell({ ); return ( -
-
- {brand} -
- {proxyOnline !== null && ( - - {proxyOnline ? : } - {t(proxyOnline ? "proxy.online" : "proxy.offline")} - + + +
+
+ {brand} +
+ {proxyOnline !== null && ( + + {proxyOnline ? ( + + ) : ( + + )} + {t(proxyOnline ? "proxy.online" : "proxy.offline")} + + )} + +
+
+ + {proxyOnline === false && ( +
+ + {t("offline.banner")} + +
)} - -
-
- {proxyOnline === false && ( -
- - {t("offline.banner")} - -
- )} + navigateTo({ view, sub: null })} + /> - +
+
+ +
+ + + {t("common.loading")} +
+ } + > + {route.view === "dashboard" && ( + + )} + {route.view === "leveranciers" && route.sub === null && ( + + )} + {route.view === "leveranciers" && + route.sub === "claude" && } + {route.view === "leveranciers" && route.sub === "grok" && ( + + )} + {route.view === "modellen" && route.sub === null && ( + + )} + {route.view === "modellen" && route.sub === "combos" && ( + + )} + {route.view === "modellen" && route.sub === "subagents" && ( + + )} + {route.view === "verkeer" && route.sub === "debug" && ( + + )} + {route.view === "verkeer" && route.sub !== "debug" && ( + + )} + {route.view === "verbruik" && } + {route.view === "systeem" && route.sub === null && ( + + )} + {route.view === "systeem" && route.sub === "storage" && ( + + )} + {route.view === "systeem" && route.sub === "api" && ( + + )} + + +
+ {route.view === "systeem" && route.sub === null && } +
+ -
-
- -
- - {t("common.loading")}
}> - {route.view === "dashboard" && } - {route.view === "leveranciers" && route.sub === null && } - {route.view === "leveranciers" && route.sub === "claude" && } - {route.view === "leveranciers" && route.sub === "grok" && } - {route.view === "modellen" && route.sub === null && } - {route.view === "modellen" && route.sub === "combos" && } - {route.view === "modellen" && route.sub === "subagents" && } - {route.view === "verkeer" && route.sub === "debug" && } - {route.view === "verkeer" && route.sub !== "debug" && } - {route.view === "verbruik" && } - {route.view === "systeem" && route.sub === null && } - {route.view === "systeem" && route.sub === "storage" && } - {route.view === "systeem" && route.sub === "api" && } - - -
- {route.view === "systeem" && route.sub === null && } + {settingsOpen && ( + setSettingsOpen(false)} + /> + )}
- - - {settingsOpen && ( - setSettingsOpen(false)} /> - )} - + + ); } @@ -264,7 +340,9 @@ function DangerZone() { useEffect(() => { if (!confirming) return; cancelRef.current?.focus(); - const onKey = (e: KeyboardEvent) => { if (e.key === "Escape" && !stopping) setConfirming(false); }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape" && !stopping) setConfirming(false); + }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [confirming, stopping]); @@ -272,7 +350,8 @@ function DangerZone() { const handleStop = async () => { setStopping(true); const outcome = await requestProxyStop(API_BASE, { - formatFailure: status => t("dash.stopFailed", { status: String(status) }), + formatFailure: (status) => + t("dash.stopFailed", { status: String(status) }), }); // Refusals and restore failures return normally instead of dropping the connection. // In both cases the proxy did not reach a clean-stop result, so surface the server's @@ -286,32 +365,59 @@ function DangerZone() { return (
-

{t("danger.title")}

+

+ {t("danger.title")} +

{t("danger.stopAction")}
{t("danger.stopBody")}
-
{confirming && ( -
{ if (e.target === e.currentTarget && !stopping) setConfirming(false); }}> +
{ + if (e.target === e.currentTarget && !stopping) setConfirming(false); + }} + >

{t("danger.stopTitle")}

-

{t("danger.stopBody")}

+

+ {t("danger.stopBody")} +

- -
diff --git a/gui/src/components/WorkspaceNavigation.tsx b/gui/src/components/WorkspaceNavigation.tsx new file mode 100644 index 00000000..1a469dca --- /dev/null +++ b/gui/src/components/WorkspaceNavigation.tsx @@ -0,0 +1,57 @@ +import { LayoutGroup, m, useReducedMotion } from "motion/react"; +import { useState, type ComponentType } from "react"; +import type { View } from "../app-routing"; +import { useT, type TKey } from "../i18n/shared"; + +export interface WorkspaceDestination { + view: View; + tkey: TKey; + icon: ComponentType<{ size?: 13 | 15 | 18 | 24; "aria-hidden"?: boolean }>; +} + +export default function WorkspaceNavigation({ + destinations, + active, + onNavigate, +}: { + destinations: readonly WorkspaceDestination[]; + active: View; + onNavigate: (view: View) => void; +}) { + const t = useT(); + const reduceMotion = useReducedMotion(); + const [keyboardNavigation, setKeyboardNavigation] = useState(false); + return ( + + ); +} diff --git a/gui/src/main.tsx b/gui/src/main.tsx index 2c622ae1..b704a9d5 100644 --- a/gui/src/main.tsx +++ b/gui/src/main.tsx @@ -10,6 +10,8 @@ import { LanguageProvider } from "./i18n/provider"; import { initPostHog } from "./posthog"; import "./styles.css"; import "./landing/landing.css"; +import "@fontsource-variable/manrope"; +import "./styles/workspace-orbit.css"; initPostHog(); @@ -23,12 +25,14 @@ try { document.documentElement.setAttribute("data-style", style); localStorage.setItem("ocx-style", style); } -} catch { /* ignore */ } +} catch { + /* ignore */ +} ReactDOM.createRoot(document.getElementById("root")!).render( - + , ); diff --git a/gui/src/styles/workspace-orbit.css b/gui/src/styles/workspace-orbit.css new file mode 100644 index 00000000..3a837ee6 --- /dev/null +++ b/gui/src/styles/workspace-orbit.css @@ -0,0 +1,231 @@ +/* OCX Orbit. Product-scoped redesign authorized 2026-09-06; see gui/DESIGN.md. + Existing component contracts inherit these tokens, not a second CSS framework. */ +:root, +:root[data-style] { + --bg: light-dark(#eef1f6, #0c1018); + --rail: light-dark(#e8edf4, #101620); + --surface: light-dark(#ffffff, #151d29); + --raised: light-dark(#e3eaf2, #1e2939); + --raised-hover: light-dark(#d8e2ee, #29384b); + --text: light-dark(#152232, #edf3fc); + --muted: light-dark(#506078, #adbbcf); + --faint: light-dark(#61718a, #8b9bb3); + --border: light-dark(#bac7d9, #36475e); + --border-soft: light-dark(#d6deea, #263448); + --hover: light-dark(#e5edf6, #223146); + --accent: light-dark(#086454, #73e5cc); + --accent-hover: light-dark(#064e42, #98efdc); + --accent-ink: light-dark(#ffffff, #102a24); + --accent-blue: light-dark(#087361, #73e5cc); + --accent-blue-ink: light-dark(#086454, #98efdc); + --accent-soft: light-dark(#d9f1e9, #163e37); + --accent-ring: light-dark(#087361, #73e5cc); + --font-ui: "Manrope Variable", system-ui, sans-serif; + --font: var(--font-ui); + --text-control: 14px; + --text-caption: 12px; + --text-label: 13px; + --text-title: 30px; + --text-display: 38px; + --radius-xs: 8px; + --radius-sm: 10px; + --radius: 16px; + --radius-lg: 20px; + --control-sm: 32px; + --control-md: 38px; + --control-lg: 44px; + --workspace-rail: 224px; + --workspace-header: 72px; + --workspace-measure: 1440px; + --workspace-shadow: 0 10px 36px light-dark(#2035570c, #00000024); +} + +.ocx-workspace { + display: grid; + grid-template-columns: var(--workspace-rail) minmax(0, 1fr); + grid-template-rows: var(--workspace-header) auto 1fr; + grid-template-areas: "header header" "navigation banner" "navigation content"; + min-height: 100dvh; +} + +.ocx-workspace .topbar { + grid-area: header; + height: var(--workspace-header); + padding: 0 var(--space-6); + background: var(--surface); + border-bottom: 1px solid var(--border-soft); +} +.ocx-workspace .brand { + gap: var(--space-3); +} +.ocx-workspace .brand .name { + font-weight: 700; + font-size: var(--text-subtitle); + letter-spacing: -0.045em; +} +.ocx-workspace .brand .ver { + padding: var(--space-1) var(--space-2); + border: 1px solid var(--border-soft); + border-radius: var(--radius-xs); + color: var(--muted); +} +.ocx-workspace .offline-banner { + grid-area: banner; + margin: 0; +} +.ocx-workspace .workspace-navigation { + grid-area: navigation; + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-6) var(--space-3); + border: 0; + border-right: 1px solid var(--border-soft); + background: var(--rail); + overflow: visible; +} +.workspace-navigation .workspace-destination { + position: relative; + isolation: isolate; + display: flex; + align-items: center; + gap: var(--space-3); + min-height: var(--control-touch); + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-sm); + text-align: left; + color: var(--muted); + transition: + color var(--motion-fast), + transform var(--motion-fast); +} +.workspace-navigation .workspace-destination > svg { + flex-shrink: 0; +} +.workspace-navigation .workspace-destination.active { + color: var(--accent-blue-ink); +} +.workspace-navigation .workspace-destination.active::after { + content: none; +} +.workspace-selection { + position: absolute; + inset: 0; + z-index: -1; + border-radius: inherit; + border: 1px solid var(--border-soft); + background: var(--surface); + box-shadow: var(--workspace-shadow); +} +.workspace-destination:focus-visible { + outline: 2px solid var(--accent-ring); + outline-offset: 3px; +} +.ocx-workspace .main { + grid-area: content; + min-width: 0; +} +.ocx-workspace .main-inner { + max-width: var(--workspace-measure); + padding: var(--space-6) clamp(var(--space-4), 3vw, var(--space-12)) + var(--space-12); +} +.ocx-workspace .main-inner.main-inner--combos { + min-height: calc(100dvh - var(--workspace-header)); + height: auto; + padding: var(--space-4); + overflow: visible; +} +.ocx-workspace .sub-tabs { + width: fit-content; + max-width: 100%; + gap: var(--space-1); + padding: var(--space-1); + margin-bottom: var(--space-8); + border: 1px solid var(--border-soft); + border-radius: var(--radius-sm); + background: var(--rail); + overflow-x: auto; +} +.ocx-workspace .sub-tab { + min-height: var(--control-md); + padding: var(--space-2) var(--space-4); + border-radius: var(--radius-xs); +} +.ocx-workspace .sub-tab.active { + background: var(--surface); + color: var(--text); + box-shadow: var(--workspace-shadow); +} +.ocx-workspace .sub-tab.active::after { + content: none; +} +.ocx-workspace .page-head { + margin-bottom: var(--space-4); +} +.ocx-workspace .page-head h2 { + font-weight: 650; + letter-spacing: -0.04em; +} +.ocx-workspace .card { + border-radius: var(--radius); + box-shadow: var(--workspace-shadow); +} +.ocx-workspace .btn { + min-height: var(--control-md); + border-radius: var(--radius-sm); +} +.ocx-workspace .gbtn { + min-width: var(--control-md); + min-height: var(--control-md); + border-radius: var(--radius-sm); +} +.ocx-workspace .stamp { + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-pill); +} + +@media (max-width: 1024px) { + :root { + --workspace-rail: 190px; + } + .workspace-navigation .workspace-destination { + padding: var(--space-3); + gap: var(--space-2); + } +} +@media (max-width: 760px) { + .ocx-workspace { + display: flex; + flex-direction: column; + } + .ocx-workspace .topbar { + height: var(--space-16); + padding: 0 var(--space-4); + } + .ocx-workspace .workspace-navigation { + flex-direction: row; + padding: var(--space-2); + gap: var(--space-1); + border-right: 0; + border-bottom: 1px solid var(--border-soft); + overflow-x: auto; + } + .workspace-navigation .workspace-destination { + flex-shrink: 0; + } + .ocx-workspace .main-inner { + padding: var(--space-4); + } + .ocx-workspace .sub-tabs { + margin-bottom: var(--space-6); + } + .ocx-workspace .brand .ver { + display: none; + } +} +@media (prefers-reduced-motion: reduce) { + .workspace-destination { + transition: none; + } +} diff --git a/gui/tests/sidebar-codex-auth.test.ts b/gui/tests/sidebar-codex-auth.test.ts index 71b0244c..3e693271 100644 --- a/gui/tests/sidebar-codex-auth.test.ts +++ b/gui/tests/sidebar-codex-auth.test.ts @@ -10,7 +10,11 @@ import { resolveAppHashChange } from "../src/app-routing"; test("the view tabs expose one provider-independent account destination", async () => { const src = await Bun.file(new URL("../src/App.tsx", import.meta.url)).text(); - expect(src).toContain('{ view: "leveranciers", tkey: "nav.providers" }'); + expect( + src.match( + /\{\s*view:\s*"leveranciers",\s*tkey:\s*"nav.providers",\s*icon:\s*IconServer,?\s*\}/g, + ), + ).toHaveLength(1); expect(src).not.toContain('"codex-auth"'); expect(src).not.toContain('route.sub === "codex-auth"'); }); diff --git a/gui/tests/workspace-navigation.test.tsx b/gui/tests/workspace-navigation.test.tsx new file mode 100644 index 00000000..fcf18f8d --- /dev/null +++ b/gui/tests/workspace-navigation.test.tsx @@ -0,0 +1,265 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; +import { Window } from "happy-dom"; +import { act, useState, type ReactNode } from "react"; +import type { Root } from "react-dom/client"; +import type { View } from "../src/app-routing"; +import type { WorkspaceDestination } from "../src/components/WorkspaceNavigation"; +import { LanguageProvider } from "../src/i18n/provider"; +import { seedDicts } from "./helpers/locales"; + +const GLOBALS = [ + "window", + "document", + "navigator", + "localStorage", + "HTMLElement", + "Element", + "SVGElement", + "IS_REACT_ACT_ENVIRONMENT", +] as const; +const previous = new Map(); +let testWindow: Window; +let root: Root; +let host: HTMLDivElement; +let WorkspaceNavigation: typeof import("../src/components/WorkspaceNavigation").default; +let motion: typeof import("motion/react"); + +const Icon: WorkspaceDestination["icon"] = (props) => ( + +); +const destinations: readonly WorkspaceDestination[] = [ + { view: "dashboard", tkey: "nav.dashboard", icon: Icon }, + { view: "leveranciers", tkey: "nav.providers", icon: Icon }, + { view: "modellen", tkey: "nav.models", icon: Icon }, + { view: "verkeer", tkey: "nav.verkeer", icon: Icon }, + { view: "verbruik", tkey: "nav.usage", icon: Icon }, + { view: "systeem", tkey: "nav.systeem", icon: Icon }, +]; + +beforeAll(async () => { + for (const key of GLOBALS) + previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); + testWindow = new Window({ + url: "http://localhost/", + settings: { device: { prefersReducedMotion: "no-preference" } }, + }); + Object.defineProperties( + globalThis, + Object.fromEntries( + GLOBALS.map((key) => [ + key, + { + configurable: true, + writable: true, + value: + key === "IS_REACT_ACT_ENVIRONMENT" + ? true + : key === "window" + ? testWindow + : Reflect.get(testWindow, key), + }, + ]), + ), + ); + await seedDicts(); + // Import Motion after the browser environment exists; do not mock its renderer/hooks. + motion = await import("motion/react"); + WorkspaceNavigation = (await import("../src/components/WorkspaceNavigation")) + .default; +}); + +beforeEach(async () => { + testWindow.localStorage.setItem("ocx-lang", "en"); + testWindow.happyDOM.settings.device.prefersReducedMotion = "no-preference"; + testWindow.dispatchEvent(new testWindow.Event("resize")); + host = document.createElement("div"); + document.body.append(host); + root = (await import("react-dom/client")).createRoot(host); +}); + +afterEach(() => { + if (root) act(() => root.unmount()); + host?.remove(); +}); + +afterAll(() => { + testWindow.happyDOM.settings.device.prefersReducedMotion = "no-preference"; + testWindow.dispatchEvent(new testWindow.Event("resize")); + testWindow.close(); + for (const key of GLOBALS) { + const descriptor = previous.get(key); + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } +}); + +async function render(children: ReactNode) { + const { LazyMotion, domAnimation, MotionConfig } = motion; + await act(async () => { + root.render( + + + {children} + + , + ); + }); +} + +function buttons() { + return [...host.querySelectorAll("nav button")]; +} + +function StatefulNavigation({ + onNavigate, +}: { + onNavigate: (view: View) => void; +}) { + const [active, setActive] = useState("dashboard"); + return ( + { + onNavigate(view); + setActive(view); + }} + /> + ); +} + +describe("WorkspaceNavigation behavior", () => { + test("preserves English destination labels and moves the sole aria-current marker with active", async () => { + const onNavigate = () => {}; + await render( + , + ); + expect(host.querySelector("nav")?.getAttribute("aria-label")).toBe("Views"); + expect(buttons().map((button) => button.textContent)).toEqual([ + "Overview", + "Providers & accounts", + "Models", + "Traffic", + "Usage", + "System", + ]); + expect(host.querySelectorAll('[aria-current="page"]')).toHaveLength(1); + expect(buttons()[0].getAttribute("aria-current")).toBe("page"); + await render( + , + ); + expect(host.querySelectorAll('[aria-current="page"]')).toHaveLength(1); + expect(buttons()[0].hasAttribute("aria-current")).toBe(false); + expect(buttons()[2].getAttribute("aria-current")).toBe("page"); + for (const button of buttons()) { + expect(button.type).toBe("button"); + expect(button.querySelector("svg")?.getAttribute("aria-hidden")).toBe( + "true", + ); + } + }); + + test("preserves Dutch labels and routes every pointer destination exactly once", async () => { + testWindow.localStorage.setItem("ocx-lang", "nl"); + const visits: View[] = []; + await render( + visits.push(view)} />, + ); + expect(buttons().map((button) => button.textContent)).toEqual([ + "Overzicht", + "Leveranciers", + "Modellen", + "Verkeer", + "Verbruik", + "Systeem", + ]); + for (const [index, destination] of destinations.entries()) { + await act(async () => { + buttons()[index].dispatchEvent( + new testWindow.MouseEvent("click", { + bubbles: true, + detail: 1, + }) as unknown as MouseEvent, + ); + }); + expect(visits).toEqual( + destinations.slice(0, index + 1).map((item) => item.view), + ); + expect(buttons()[index].getAttribute("aria-current")).toBe("page"); + expect(host.querySelectorAll('[aria-current="page"]')).toHaveLength(1); + expect(visits.at(-1)).toBe(destination.view); + } + }); + + test("keyboard-origin click navigates once while preserving focus and native button semantics", async () => { + const visits: View[] = []; + await render( + visits.push(view)} />, + ); + const target = buttons()[2]; + target.focus(); + expect(document.activeElement).toBe(target); + expect(target.tabIndex).toBe(0); + // Browser keyboard activation produces click detail=0. Happy-dom does not + // implement Enter/Space's native default action, so synthesize that click only. + await act(async () => { + target.dispatchEvent( + new testWindow.MouseEvent("click", { + bubbles: true, + detail: 0, + }) as unknown as MouseEvent, + ); + }); + expect(visits).toEqual(["modellen"]); + expect(target.getAttribute("aria-current")).toBe("page"); + expect(document.activeElement).toBe(target); + expect(host.querySelectorAll('[aria-current="page"]')).toHaveLength(1); + }); + + test("renders and navigates with the real reduced-motion preference enabled", async () => { + testWindow.happyDOM.settings.device.prefersReducedMotion = "reduce"; + // Happy-dom notifies MediaQueryList listeners on resize, including Motion's. + testWindow.dispatchEvent(new testWindow.Event("resize")); + expect( + testWindow.matchMedia("(prefers-reduced-motion: reduce)").matches, + ).toBe(true); + function PreferenceProbe() { + return ( + {String(motion.useReducedMotion())} + ); + } + const visits: View[] = []; + await render( + <> + + visits.push(view)} /> + , + ); + expect(host.querySelector("[data-reduced-motion]")?.textContent).toBe( + "true", + ); + expect(buttons()).toHaveLength(6); + await act(async () => { + buttons()[5].click(); + }); + expect(visits).toEqual(["systeem"]); + expect(buttons()[5].getAttribute("aria-current")).toBe("page"); + expect(buttons()[5].querySelector('[aria-hidden="true"]')).not.toBeNull(); + }); +}); From 340d23abd94d9ce217fad0c50ad67093a34955bd Mon Sep 17 00:00:00 2001 From: MisterWanted Date: Sun, 6 Sep 2026 21:27:19 +0200 Subject: [PATCH 02/10] test(gui): preserve Grok route assertion across JSX formatting --- gui/tests/grok-page.test.ts | 41 +++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/gui/tests/grok-page.test.ts b/gui/tests/grok-page.test.ts index f581116c..a5f6a6a2 100644 --- a/gui/tests/grok-page.test.ts +++ b/gui/tests/grok-page.test.ts @@ -31,22 +31,45 @@ test("the Grok page is routable and present in the nav", async () => { const app = await read("../src/App.tsx"); // New IA: Grok lives as a Leveranciers sub-view; the bare #grok hash is a legacy redirect. expect(routing).toContain('grok: "leveranciers/grok"'); - expect(app).toContain('route.sub === "grok" && '); + expect(app).toMatch( + /route\.view === "leveranciers" && route\.sub === "grok" && \(\s*\s*\)/, + ); expect(app).toContain('{ sub: "grok", tkey: "nav.grok" }'); }); test("every locale carries the Grok keys", () => { - const keys = ["nav.grok", "grok.title", "grok.subtitle", "grok.loading", "grok.loadFail", - "grok.notConfiguredTitle", "grok.notConfiguredHint", "grok.endpoint", - "grok.colModel", "grok.colAlias", "grok.colContext", - "grok.groupNative", "grok.groupRouted", "grok.enabledCount", - "grok.saved", "grok.savedApplied", "grok.saveFailed", "grok.applyFailed", - "grok.applySkipped", "grok.saveApply", "grok.saving", "grok.applying", - "grok.unsaved", "grok.upToDate", "grok.toggleModel"]; + const keys = [ + "nav.grok", + "grok.title", + "grok.subtitle", + "grok.loading", + "grok.loadFail", + "grok.notConfiguredTitle", + "grok.notConfiguredHint", + "grok.endpoint", + "grok.colModel", + "grok.colAlias", + "grok.colContext", + "grok.groupNative", + "grok.groupRouted", + "grok.enabledCount", + "grok.saved", + "grok.savedApplied", + "grok.saveFailed", + "grok.applyFailed", + "grok.applySkipped", + "grok.saveApply", + "grok.saving", + "grok.applying", + "grok.unsaved", + "grok.upToDate", + "grok.toggleModel", + ]; const missing: string[] = []; for (const [locale, dict] of localeDicts()) { for (const key of keys) { - if (!(dict[key as keyof typeof dict] ?? "").trim()) missing.push(`${locale}:${key}`); + if (!(dict[key as keyof typeof dict] ?? "").trim()) + missing.push(`${locale}:${key}`); } } expect(missing).toEqual([]); From 261ca85cb60e7eea88a6611fea2d424529f0746e Mon Sep 17 00:00:00 2001 From: MisterWanted Date: Sun, 6 Sep 2026 22:14:23 +0200 Subject: [PATCH 03/10] feat(gui): adopt shadcn primitives and correct workspace interaction patterns --- .../docs/getting-started/quickstart.md | 8 +- gui/AGENTS.md | 7 +- gui/DESIGN.md | 52 ++- gui/UX-CONTRACT.md | 83 +++++ gui/bun.lock | 83 +++++ gui/components.json | 20 ++ gui/package.json | 5 + gui/premium-ui.json | 15 + gui/src/App.tsx | 63 ++-- gui/src/components/MetricList.tsx | 26 ++ gui/src/components/SettingsSheet.tsx | 162 +++++---- gui/src/components/WorkspaceNavigation.tsx | 7 +- gui/src/components/primitives/button.tsx | 57 ++++ gui/src/components/primitives/empty.tsx | 103 ++++++ gui/src/components/primitives/sheet.tsx | 134 ++++++++ .../components/primitives/toggle-group.tsx | 89 +++++ .../components/primitives/toggle-variants.ts | 22 ++ gui/src/components/primitives/toggle.tsx | 21 ++ gui/src/i18n/en.ts | 2 + gui/src/i18n/nl.ts | 2 + gui/src/main.tsx | 3 +- gui/src/pages/Dashboard.tsx | 140 ++++---- gui/src/styles/app-base.css | 2 + gui/src/styles/primitives.css | 91 +++++ gui/src/styles/workspace-orbit.css | 252 +++++++++++--- gui/src/ui.tsx | 245 +++++++++++--- gui/tests/dashboard-data-states.test.tsx | 232 +++++++++++++ gui/tests/settings-sheet.test.tsx | 316 ++++++++++++++++++ gui/tsconfig.app.json | 1 + gui/tsconfig.json | 1 + gui/vite.config.ts | 88 +++-- 31 files changed, 2031 insertions(+), 301 deletions(-) create mode 100644 gui/UX-CONTRACT.md create mode 100644 gui/components.json create mode 100644 gui/premium-ui.json create mode 100644 gui/src/components/MetricList.tsx create mode 100644 gui/src/components/primitives/button.tsx create mode 100644 gui/src/components/primitives/empty.tsx create mode 100644 gui/src/components/primitives/sheet.tsx create mode 100644 gui/src/components/primitives/toggle-group.tsx create mode 100644 gui/src/components/primitives/toggle-variants.ts create mode 100644 gui/src/components/primitives/toggle.tsx create mode 100644 gui/src/styles/app-base.css create mode 100644 gui/src/styles/primitives.css create mode 100644 gui/tests/dashboard-data-states.test.tsx create mode 100644 gui/tests/settings-sheet.test.tsx diff --git a/docs-site/src/content/docs/getting-started/quickstart.md b/docs-site/src/content/docs/getting-started/quickstart.md index bcdf8202..a3b8553f 100644 --- a/docs-site/src/content/docs/getting-started/quickstart.md +++ b/docs-site/src/content/docs/getting-started/quickstart.md @@ -63,11 +63,15 @@ curl -sS "http://127.0.0.1:/healthz" The proxy exposes `/healthz` and `/v1/*` for any Responses-compatible client. You do not need Codex installed for this step. -The pending Orbit dashboard redesign keeps the same routes and API. Its desktop navigation -uses a side rail; smaller screens keep all destinations in a horizontal navigation strip. +The pending dashboard redesign keeps the same routes and API. Its desktop navigation +uses a side rail; smaller screens show all six destinations in a labelled two-row grid. Overview, Providers & accounts, Models, Traffic, Usage and System remain available, including existing bookmarked hashes. Navigation motion respects reduced-motion preferences. This interface work does not change provider credentials or the proxy's deployment state. +Settings opens a keyboard-accessible panel for language and light/dark/system theme. +The legacy skin selector is removed; stored skin preferences are retained for compatibility. +Unavailable dashboard readings display a dash rather than an invented zero; failures remain +visible alongside any previously fetched readings. ## 4. Use Codex (optional) diff --git a/gui/AGENTS.md b/gui/AGENTS.md index 9f413539..3b8db2b9 100644 --- a/gui/AGENTS.md +++ b/gui/AGENTS.md @@ -45,7 +45,7 @@ Run all of the following for every functional `gui/` change: ```bash cd gui -bun test tests +bun run test bun run lint bun run build ``` @@ -58,3 +58,8 @@ bun run lint:i18n ``` Run the repository-level checks required by any non-GUI files changed in the same work. + +Use the package test script, including its existing `--isolate` option. DOM and +server-rendering tests need separate module caches: Base UI chooses its layout +effect implementation at import time. Do not remove isolation or mock that +behavior to make shared-cache test runs pass. diff --git a/gui/DESIGN.md b/gui/DESIGN.md index 2e217f5f..b6344e67 100644 --- a/gui/DESIGN.md +++ b/gui/DESIGN.md @@ -1,4 +1,4 @@ -# OCX Orbit — nieuwe productidentiteit +# OCX — productinterface in herontwerp ## Actuele autoriteit: expliciete redesignopdracht 2026-09-06 @@ -11,14 +11,19 @@ ChefGroep-producten en het gedeelde design-system worden hiermee niet gewijzigd. ### Richting en componenten -Orbit is een model-workspace: contrastrijke inktvlakken, heldere lichte oppervlakken, -mint als interactieaccent en Manrope als interfaceletter. JetBrains Mono blijft -voor modelnamen en technische gegevens. Geen marketinghero boven dagelijkse data. +De eerste inkt/mint-richting (werknaam Orbit) is door Joep afgewezen als generiek. +De huidige correctie is geen goedgekeurde nieuwe merkidentiteit. Het product heet +opencodex; we voegen geen nieuw merk of parallelle app toe. De werkinterface gebruikt +neutrale lichte/grafietoppervlakken, blauw voor acties en Manrope voor interfacecopy. +JetBrains Mono blijft voor machinedata. Geen marketinghero boven dagelijkse data, +decoratieve metriekkaarten of herhaalde navigatie zonder extra bestemming. - `WorkspaceNavigation`: één getypeerde bestemmingencatalogus, iconen, labels, actieve pagina en een gedeelde Motion-selectie. Bestaande hashes blijven werken. -- Bestaande controls blijven hun toegankelijke, geteste gedrag behouden. Hun - geometrie en uiterlijk komen uit gedeelde tokens, niet uit per-scherm hacks. +- Shadcn `base-nova` op Base UI levert de gedeelde Button, Sheet, Empty en + ToggleGroup in `src/components/primitives/`. Geen volledig preset of tweede + palet. Bestaande controls, waaronder de authored Select in `src/ui.tsx`, blijven + eigenaar totdat hun workflow bewust wordt gemigreerd. Zie `UX-CONTRACT.md`. - Motion for React (`LazyMotion`) verzorgt gedeelde layoutovergangen. CSS behandelt eenvoudige hover/press-feedback. Geen React-state-updates per animatieframe. - Nieuwe dependencies zijn exact gepind en vereisen de bestaande security-review. @@ -27,9 +32,10 @@ voor modelnamen en technische gegevens. Geen marketinghero boven dagelijkse data ### Geometrie en responsiviteit -Werkvlak maximaal 1440px; navigatie 224px (190px op kleinere desktops); header 72px. -Controls 32/38/44px, touch minimaal 44px; radii 8/10/16/20px. Typografie 12/13/14/15/ -30/38px. Mobiel krijgt horizontale navigatie met alle labels behouden. Geen +Werkvlak maximaal 1440px; navigatie 224px (190px op kleinere desktops); header 64px. +Controls 32/38/44px, primaire touch-acties minimaal 44px; radii 8/10/12/16px. +Typografie 12/13/14/15/28/32px. Mobiel krijgt zes gelabelde bestemmingen in een +3×2-raster in plaats van een horizontaal verborgen rij. Geen afgesneden werkpanelen, paginabrede horizontale overflow of onbereikbare acties. ### Toestanden en motion @@ -37,13 +43,35 @@ afgesneden werkpanelen, paginabrede horizontale overflow of onbereikbare acties. Echte API-status blijft leidend: laden, leeg, gedeeltelijk, fout en offline mogen niet worden vervangen door mooie voorbeeldcijfers. Selectie beweegt met een onderbreekbare spring; toetsenbordnavigatie en reduced-motion slaan die beweging -over. Focus, contrast en labels blijven zichtbaar in licht én donker. +over. Geen automatische page-reveal of pressed-translate in nieuwe controls. +Sheet opent maximaal 200ms via transform/opacity; toetsenbordopening zonder +verplaatsing, reduced-motion alleen korte opacity-feedback. Focus, contrast en +labels blijven zichtbaar in licht én donker. Onbekende waarden zijn geen nullen. + +### Token-eigendom en adapters + +Runtime CSS is canoniek, niet een gegenereerde tweede tokenbron: + +| Eigenaar | Verantwoordelijkheid | +| -------------------------------- | --------------------------------------------------------------------------------- | +| `src/styles.css` | Bestaande reset, spacing, semantische status en legacy controls | +| `src/styles/workspace-orbit.css` | Actuele kleur-, type-, geometrie- en scrollbarrollen; bestandsnaam is geen merk | +| `src/styles/app-base.css` | Cascadevolgorde; legacy CSS in eigen laag | +| `src/styles/primitives.css` | Tailwind-semantieken verwijzen naar runtime-rollen; geen preflight of eigen palet | +| `components.json` | Officiële shadcn-registry, Base UI-style en gedeelde componentpaden | + +Tailwind `text-xs/sm/base` verwijzen naar `--text-caption/control/body` met hun +line-height. Geen rem-afhankelijke tweede letterladder. Historische `devin/strak` +waarden blijven leesbaar voor compatibiliteit, maar de settings-skinselector is +verwijderd: de nieuwe tokens oversturen beide skins en twee gelijke keuzes zijn +misleidend. Licht/donker/systeem en EN/NL blijven beschikbaar. ### Implementatiestatus en acceptatie De eerste slice vervangt de shell, navigatie, typografie en gedeelde tokens. -Detailpanelen, volledige componentmigratie, landing, screenshots op 1440/1024/390px -en alle interactietoestanden blijven expliciete vervolgstappen. Niet als volledig +Detailpanelen, volledige componentmigratie, landing en volledige visuele acceptatie +blijven expliciete vervolgstappen. Brave-browserchecks van deze slice zijn geen +productie- of brede toegankelijkheidscertificering. Niet als volledig redesign of live release presenteren zolang die niet aantoonbaar zijn afgerond. Tokens: `src/styles/workspace-orbit.css`; die worden na de bestaande laag geladen. Geen nieuwe parallelle app, backend, routing- of authenticatielaag. diff --git a/gui/UX-CONTRACT.md b/gui/UX-CONTRACT.md new file mode 100644 index 00000000..b5f461e8 --- /dev/null +++ b/gui/UX-CONTRACT.md @@ -0,0 +1,83 @@ +# OCX workspace UX contract + +Scope: the pending workspace navigation, Overview and Settings migration. This +records ownership, not approval of the unfinished visual redesign. Existing +provider/authentication, deletion, billing and persistence policy stays with its +runtime owner; this document does not invent replacement business rules. + +## Product and authority + +- Audience: operators routing model clients through the existing OCX proxy. +- Jobs: inspect real runtime/provider readings, navigate configuration, adjust + language and theme without changing provider credentials. +- Domain: repository `CONTEXT.md`; management API: `src/api.ts` in this GUI; + route catalogue and health state: `src/App.tsx`. +- EN/NL copy and locale persistence: `src/i18n/`; no new markets, timezone policy + or provider-family claims in this migration. +- Visual authority: `DESIGN.md`; runtime CSS remains canonical. The Tailwind + adapter maps existing roles, never a second palette or reset. +- Accessibility target: WCAG 2.2 AA. Automated/component checks and bounded Brave + checks are evidence for specific behaviors, not a conformance certification. + +## Canonical UI Map + +| Capability | Canonical owner | Source of truth | Allowed variants | Verification | +| ---------------------- | ----------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------ | +| Select/Listbox | Shared Select | `src/ui.tsx`, `src/select-position.ts` | Authored, portaled by default; inline inside Settings Sheet | Existing select tests; nested Escape and popup browser check | +| Settings overlay | Shared Sheet / Base UI Dialog | `src/components/primitives/sheet.tsx` | Controlled modal; translated title/close; external return-focus ref | `tests/settings-sheet.test.tsx`, native Tab/Shift+Tab/Escape | +| Theme selection | Shared ToggleGroup | `src/components/primitives/toggle-group.tsx` | Light/dark/system, exactly one active value | Settings behavioral tests | +| Buttons / empty states | Shared Button / Empty | `src/components/primitives/button.tsx`, `empty.tsx` | Existing `.btn` callers remain legacy; touched Overview uses shared primitives | Overview data-state tests and browser | +| Readings | MetricList | `src/components/MetricList.tsx` | Semantic definition list, numbers supplied by caller | Dashboard data-state tests | +| Scrollbar | Application stylesheet | `src/styles/workspace-orbit.css` | Global semantic roles; forced-colors defers to system | Computed styles / browser; platform rendering may differ | +| Navigation | WorkspaceNavigation | `src/components/WorkspaceNavigation.tsx`, `src/route.ts` | Existing hashes; current page via aria-current | Workspace navigation tests | + +There is no new table selection, form submission, date input, toast or CRUD owner +in this slice. Existing workflows retain their owners pending explicit migration. + +## Behavior and resilience + +- Dashboard: pending is not empty; successful zero stays zero; unsuccessful first + load displays unknown readings. Failed refresh preserves fetched readings and + shows the error in the affected section. Polling remains owned by Dashboard. +- Empty-success providers/traffic has a real link to the relevant configuration + or traffic route. A failure never simultaneously claims successful emptiness. +- Settings: title labels the dialog; opening focuses Close, Tab wraps inside, + closing restores the external opener. Escape first dismisses an open Select, + then the Sheet. The locale popup stays inside the dialog and matches its trigger. +- Language/theme selections reuse current persistence. Removed skin choices were + visually identical under the new token override; saved values are not deleted. +- Buttons retain native disabled behavior and visible focus. Icon-only Close has + localized accessible copy. No decorative pressed translation or transition-all. +- Overlay uses existing `--z-modal`; the inline locale popup uses `--z-popover` + inside it. No unrelated body-level popup may escape modal focus containment. + +## Navigation, layout and motion + +- Document title is localized route name plus opencodex; public landing keeps the + product name. Single-destination views do not repeat an Overview subtab. +- Desktop side rail becomes six labelled mobile destinations in two rows. Primary + navigation and Settings controls keep 44px targets; no page-wide overflow. +- Mobile readings become compact label/value rows rather than empty KPI cards. +- Pointer selection has a scoped, interruptible shared-layout spring; keyboard + navigation and reduced motion are instant. No page entrance animation. +- Settings movement is at most 200ms, transform/opacity only. Keyboard opening is + instant; reduced motion suppresses displacement. No ambient animation added. + +## Migration and verification + +- Source owners above precede premium defaults. This is incremental migration, + not permission to replace established controls with screen-local copies. +- Legacy provider textareas remain vertically resizable. A stricter premium + resize-none migration requires usable autosizing/expansion, not lost editing + capacity. The static audit reports this debt; it is not suppressed. +- Run `bun run test` (existing isolated GUI script), `bun run lint`, + `bun run lint:i18n`, `bun run build`; root checks and docs build per AGENTS. +- Behavioral evidence: `tests/dashboard-data-states.test.tsx`, + `tests/settings-sheet.test.tsx`, `tests/workspace-navigation.test.tsx`. +- Browser matrix: desktop 1440, intermediate 1024, mobile 390; EN/NL, light/dark, + reduced motion, focus return and nested popup. Any unobserved combination remains + unverified. Synthetic UI fixtures are explicitly not live runtime evidence. +- Full provider CRUD, screen-reader audit, all detail panels, public landing, + exact-head CI/security approval and deployment acceptance remain separate gates. +- Rollout is through the existing OCX PR/release path. No production cutover is + authorized by a local preview or static audit alone. diff --git a/gui/bun.lock b/gui/bun.lock index d0caf5bb..4bd0195b 100644 --- a/gui/bun.lock +++ b/gui/bun.lock @@ -5,11 +5,14 @@ "": { "name": "gui", "dependencies": { + "@base-ui/react": "1.8.0", "@designcodeio/threeui": "^1.1.0", "@fontsource-variable/jetbrains-mono": "^5.3.0", "@fontsource-variable/manrope": "5.3.0", "@fontsource/instrument-serif": "^5.3.0", "@tanstack/react-virtual": "^3.14.5", + "class-variance-authority": "0.7.1", + "cn": "0.2.6", "motion": "13.2.0", "posthog-js": "^1.422.5", "react": "^19.2.7", @@ -18,6 +21,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@tailwindcss/vite": "4.3.3", "@types/node": "^26.4.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.5", @@ -27,6 +31,7 @@ "eslint-plugin-react-refresh": "^0.5.5", "globals": "^17.11.0", "happy-dom": "20.12.0", + "tailwindcss": "4.3.3", "typescript": "~6.0.2", "typescript-eslint": "^8.69.0", "vite": "^8.1.0", @@ -65,12 +70,18 @@ "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + "@base-ui/react": ["@base-ui/react@1.8.0", "", { "dependencies": { "@babel/runtime": "^7.29.7", "@base-ui/utils": "0.4.0", "@floating-ui/react-dom": "^2.1.9", "@floating-ui/utils": "^0.2.12", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@date-fns/tz": "^1.2.0", "@types/react": "^17 || ^18 || ^19", "date-fns": "^4.0.0", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@date-fns/tz", "@types/react", "date-fns"] }, "sha512-P0/1sxo6SBVZOklKMIedvTWqw2s2IQzi9x5bIVsXu980cuSOD4NeuRSs+/L7LZQfDkZP/uRZyGPyfFl/B1oH+Q=="], + + "@base-ui/utils": ["@base-ui/utils@0.4.0", "", { "dependencies": { "@babel/runtime": "^7.29.7", "@floating-ui/utils": "^0.2.12", "reselect": "^5.2.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-bO9fz25kKtPf+aZVyfQrC0PDmJdmVni31W2hCS5/Owb+inwdIL3XU26pCPRPlt4LSxZrBgLwubXQXQlKaFEZzw=="], + "@designcodeio/threeui": ["@designcodeio/threeui@1.1.0", "", { "dependencies": { "three128": "npm:three@0.128.0", "three165": "npm:three@0.165.0" }, "peerDependencies": { "react": ">=18 <20", "react-dom": ">=18 <20", "three": ">=0.149 <1" } }, "sha512-7VOgCL0dswnQaGy/kkqQe3wMfLSAJ5JlzmrSjT6B7wkM+QHXmtH84CKN3kroT2xA3JdXr1/HZ6y8+VCENOe0ag=="], "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], @@ -95,6 +106,14 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="], + "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.9", "", { "dependencies": { "@floating-ui/dom": "^1.8.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="], + "@fontsource-variable/jetbrains-mono": ["@fontsource-variable/jetbrains-mono@5.3.0", "", {}, "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw=="], "@fontsource-variable/manrope": ["@fontsource-variable/manrope@5.3.0", "", {}, "sha512-6D5dgokHsWDDMtmXHznKa0hK229NN+1a4BLPmUCLqcO1Pw5EEhWY5RFt0AcXnVRAljFFPfRtLkJePQj6LSsV6g=="], @@ -163,6 +182,36 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.5", "", { "dependencies": { "@tanstack/virtual-core": "3.17.3" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4EKRXh7zBLkbKbFmG3AUVkircuHd+7OdT1pocJSepxtfBd3qnrJgJ5rtPkRYyo9fmyVb2+pI2xPy5oYvMLQy6A=="], "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.3", "", {}, "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw=="], @@ -227,6 +276,12 @@ "caniuse-lite": ["caniuse-lite@1.0.30001810", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="], + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "cn": ["cn@0.2.6", "", { "bin": { "cn": "bin/cn.mjs" } }, "sha512-+i4L0zUGgRcEnhsxueVrP7iBGxBx5iD0WOTYg1MwFEu2ZyCmH5Ov2V1cul2Ht5UchRQQgftCf4be/RxspuW6QQ=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "core-js": ["core-js@3.49.0", "", {}, "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg=="], @@ -245,6 +300,8 @@ "electron-to-chromium": ["electron-to-chromium@1.5.420", "", {}, "sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA=="], + "enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -299,6 +356,8 @@ "globals": ["globals@17.11.0", "", {}, "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "happy-dom": ["happy-dom@20.12.0", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-7uMYJu2SEwwL8vVcKp0C0lnt6d2LSGGe+T+oY79PiCJNNSgFpbxW8n5KuzpDQvrU4mt+fYiK1+Jy7Z2v39YR6g=="], "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], @@ -315,6 +374,8 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], @@ -359,6 +420,8 @@ "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "motion": ["motion@13.2.0", "", { "dependencies": { "framer-motion": "^13.2.0", "tslib": "^2.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-4Hrb5vD6HhjFstLUiCmWvtpsw+WTpP4R+QXfSDYZBz7+uxE/LrRg3aV0ReJxHrTRffHhbIE6svEqnotngXvesQ=="], @@ -405,6 +468,8 @@ "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], + "reselect": ["reselect@5.3.0", "", {}, "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg=="], + "rolldown": ["rolldown@1.1.3", "", { "dependencies": { "@oxc-project/types": "=0.137.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.3", "@rolldown/binding-darwin-arm64": "1.1.3", "@rolldown/binding-darwin-x64": "1.1.3", "@rolldown/binding-freebsd-x64": "1.1.3", "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", "@rolldown/binding-linux-arm64-gnu": "1.1.3", "@rolldown/binding-linux-arm64-musl": "1.1.3", "@rolldown/binding-linux-ppc64-gnu": "1.1.3", "@rolldown/binding-linux-s390x-gnu": "1.1.3", "@rolldown/binding-linux-x64-gnu": "1.1.3", "@rolldown/binding-linux-x64-musl": "1.1.3", "@rolldown/binding-openharmony-arm64": "1.1.3", "@rolldown/binding-wasm32-wasi": "1.1.3", "@rolldown/binding-win32-arm64-msvc": "1.1.3", "@rolldown/binding-win32-x64-msvc": "1.1.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], @@ -417,6 +482,10 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], + + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + "three": ["three@0.185.1", "", {}, "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg=="], "three128": ["three@0.128.0", "", {}, "sha512-i0ap/E+OaSfzw7bD1TtYnPo3VEplkl70WX5fZqZnfZsE3k3aSFudqrrC9ldFZfYFkn1zwDmBcdGfiIm/hnbyZA=="], @@ -441,6 +510,8 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "vite": ["vite@8.1.0", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "~1.1.2", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q=="], "web-vitals": ["web-vitals@6.2.1", "", {}, "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw=="], @@ -465,6 +536,18 @@ "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@types/ws/@types/node": ["@types/node@24.13.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA=="], "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], diff --git a/gui/components.json b/gui/components.json new file mode 100644 index 00000000..97fd2b8a --- /dev/null +++ b/gui/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/styles/primitives.css", + "baseColor": "neutral", + "cssVariables": true + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "ui": "@/components/primitives", + "utils": "@/lib/utils", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/gui/package.json b/gui/package.json index 4d5228e2..acd4d48c 100644 --- a/gui/package.json +++ b/gui/package.json @@ -14,11 +14,14 @@ "preview": "vite preview" }, "dependencies": { + "@base-ui/react": "1.8.0", "@designcodeio/threeui": "^1.1.0", "@fontsource-variable/jetbrains-mono": "^5.3.0", "@fontsource-variable/manrope": "5.3.0", "@fontsource/instrument-serif": "^5.3.0", "@tanstack/react-virtual": "^3.14.5", + "class-variance-authority": "0.7.1", + "cn": "0.2.6", "motion": "13.2.0", "posthog-js": "^1.422.5", "react": "^19.2.7", @@ -27,6 +30,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@tailwindcss/vite": "4.3.3", "@types/node": "^26.4.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.5", @@ -36,6 +40,7 @@ "eslint-plugin-react-refresh": "^0.5.5", "globals": "^17.11.0", "happy-dom": "20.12.0", + "tailwindcss": "4.3.3", "typescript": "~6.0.2", "typescript-eslint": "^8.69.0", "vite": "^8.1.0" diff --git a/gui/premium-ui.json b/gui/premium-ui.json new file mode 100644 index 00000000..13508d54 --- /dev/null +++ b/gui/premium-ui.json @@ -0,0 +1,15 @@ +{ + "profile": "product-admin", + "sourceRoots": ["src"], + "canonicalMap": "UX-CONTRACT.md", + "requiredCapabilities": ["Select/Listbox", "Scrollbar"], + "ownership": { "Select/Listbox": "authored" }, + "requiredCommands": ["unit", "lint", "i18n", "build"], + "commands": { + "unit": "bun run test", + "lint": "bun run lint", + "i18n": "bun run lint:i18n", + "build": "bun run build" + }, + "evidence": { "failurePaths": "tests/dashboard-data-states.test.tsx" } +} diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 3ed6c5a8..dbc785c0 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -98,6 +98,16 @@ export default function App() { const { route, navigateTo } = useAppRouteState(); const [theme, setTheme] = useState(readStoredTheme); const t = useT(); + useEffect(() => { + const title = + route.sub === null + ? VIEW_TABS.find((entry) => entry.view === route.view)?.tkey + : SUB_TABS[route.view].find((entry) => entry.sub === route.sub)?.tkey; + document.title = + title && route.view !== "landing" + ? t("app.pageTitle", { page: t(title) }) + : "opencodex"; + }, [route, t]); useEffect(() => { const el = document.documentElement; if (theme === "system") el.removeAttribute("data-theme"); @@ -157,6 +167,8 @@ function DashboardShell({ }) { const t = useT(); const [settingsOpen, setSettingsOpen] = useState(false); + const [settingsInstant, setSettingsInstant] = useState(false); + const settingsTriggerRef = useRef(null); const healthPoll = useKeyedClientResource( `app-healthz:${API_BASE}`, [], @@ -210,9 +222,13 @@ function DashboardShell({ )} - ))} - -
+ {SUB_TABS[route.view].length > 1 && ( + + )} +
- {settingsOpen && ( - setSettingsOpen(false)} - /> - )} + setSettingsOpen(false)} + />
diff --git a/gui/src/components/MetricList.tsx b/gui/src/components/MetricList.tsx new file mode 100644 index 00000000..823a1d82 --- /dev/null +++ b/gui/src/components/MetricList.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from "react"; + +interface Metric { + label: string; + value: ReactNode; +} + +/** Related readings share one definition list, not a collection of action cards. */ +export default function MetricList({ + label, + metrics, +}: { + label: string; + metrics: readonly Metric[]; +}) { + return ( +
+ {metrics.map((metric) => ( +
+
{metric.label}
+
{metric.value}
+
+ ))} +
+ ); +} diff --git a/gui/src/components/SettingsSheet.tsx b/gui/src/components/SettingsSheet.tsx index e8fb6359..2e9f2fbd 100644 --- a/gui/src/components/SettingsSheet.tsx +++ b/gui/src/components/SettingsSheet.tsx @@ -1,20 +1,19 @@ -/* Settings sheet — design-system v2 Motion 5 (pane slide). Config lives here, - not on the dashboard: language, theme, skin. Destructive actions do NOT live - here (those sit in the Systeem danger zone). */ -import { useEffect, useRef, useState } from "react"; +/* Application preferences; destructive actions remain in System. */ +import { useRef, type RefObject } from "react"; import { IconGithub, IconMonitor, IconMoon, IconSun, IconX } from "../icons"; import { useI18n, useT, LOCALES, type Locale, type TKey } from "../i18n/shared"; import { Select } from "../ui"; +import { + Sheet, + SheetClose, + SheetContent, + SheetHeader, + SheetTitle, +} from "./primitives/sheet"; +import { Button } from "./primitives/button"; +import { ToggleGroup, ToggleGroupItem } from "./primitives/toggle-group"; type Theme = "light" | "dark" | "system"; -type Skin = "devin" | "strak"; - -const STYLE_KEY = "ocx-style"; - -function readSkin(): Skin { - return document.documentElement.getAttribute("data-style") === "strak" ? "strak" : "devin"; -} - const THEME_OPTIONS: { id: Theme; tkey: TKey; Icon: typeof IconSun }[] = [ { id: "light", tkey: "theme.light", Icon: IconSun }, { id: "dark", tkey: "theme.dark", Icon: IconMoon }, @@ -22,102 +21,95 @@ const THEME_OPTIONS: { id: Theme; tkey: TKey; Icon: typeof IconSun }[] = [ ]; export default function SettingsSheet({ + open, + returnFocusRef, + instant, theme, onTheme, onClose, }: { + open: boolean; + returnFocusRef: RefObject; + instant: boolean; theme: Theme; onTheme: (next: Theme) => void; onClose: () => void; }) { const t = useT(); const { locale, setLocale } = useI18n(); - const [skin, setSkin] = useState(readSkin); const closeRef = useRef(null); - useEffect(() => { - closeRef.current?.focus(); - const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [onClose]); - - const applySkin = (next: Skin) => { - setSkin(next); - document.documentElement.setAttribute("data-style", next); - try { localStorage.setItem(STYLE_KEY, next); } catch { /* private mode */ } - }; - return ( -
-
-
-
{t("settings.skin")}
-
- - +
+
{t("theme.label")}
+ { + const next = values[0]; + if (next === "light" || next === "dark" || next === "system") + onTheme(next); + }} + > + {THEME_OPTIONS.map(({ id, tkey, Icon }) => ( + + {t(tkey)} + + ))} +
-
- - -
+ + ); } diff --git a/gui/src/components/WorkspaceNavigation.tsx b/gui/src/components/WorkspaceNavigation.tsx index 1a469dca..5a55b95a 100644 --- a/gui/src/components/WorkspaceNavigation.tsx +++ b/gui/src/components/WorkspaceNavigation.tsx @@ -1,5 +1,5 @@ import { LayoutGroup, m, useReducedMotion } from "motion/react"; -import { useState, type ComponentType } from "react"; +import { useId, useState, type ComponentType } from "react"; import type { View } from "../app-routing"; import { useT, type TKey } from "../i18n/shared"; @@ -20,10 +20,11 @@ export default function WorkspaceNavigation({ }) { const t = useT(); const reduceMotion = useReducedMotion(); + const groupId = useId(); const [keyboardNavigation, setKeyboardNavigation] = useState(false); return (
{/* Recent traffic */}
-

{t("nav.usage")}

+

{t("nav.verkeer")}

+ {logsFailed && {t("vk.loadFailed")}} {recentBons.length > 0 ? (
{recentBons.map((entry) => { @@ -369,10 +383,7 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { className="traffic-entry" style={{ borderBottom: "1px solid var(--border-soft)" }} > -
+
{tijd(entry.timestamp, locale)} @@ -386,11 +397,28 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { ); })}
- ) : ( -

{t("vk.empty")}

- )} + ) : !logsFailed ? ( + + + + {t(logsLoaded ? "vk.empty" : "common.loading")} + + + {logsLoaded && ( + + + + )} + + ) : null}
- + ); } diff --git a/gui/src/styles/app-base.css b/gui/src/styles/app-base.css new file mode 100644 index 00000000..ba8055cb --- /dev/null +++ b/gui/src/styles/app-base.css @@ -0,0 +1,2 @@ +@layer theme, base, legacy, components, utilities; +@import "../styles.css" layer(legacy); diff --git a/gui/src/styles/primitives.css b/gui/src/styles/primitives.css new file mode 100644 index 00000000..3e48a23b --- /dev/null +++ b/gui/src/styles/primitives.css @@ -0,0 +1,91 @@ +/* No Tailwind preflight: the existing application reset remains the owner. */ +@layer theme, base, legacy, components, utilities; +@import "tailwindcss/theme.css" layer(theme); +@import "tailwindcss/utilities.css" layer(utilities); + +/* Adapt semantic roles; do not introduce a competing palette. */ +@theme inline { + --color-background: var(--bg); + --color-popover: var(--surface); + --color-popover-foreground: var(--text); + --color-foreground: var(--text); + --color-primary: var(--accent); + --color-primary-foreground: var(--accent-ink); + --color-secondary: var(--raised); + --color-secondary-foreground: var(--text); + --color-muted: var(--rail); + --color-muted-foreground: var(--muted); + --color-accent: var(--hover); + --color-accent-foreground: var(--text); + --color-destructive: var(--red); + --color-border: var(--border-soft); + --color-input: var(--border); + --color-ring: var(--accent-ring); + --font-sans: var(--font-ui); + --text-xs: var(--text-caption); + --text-xs--line-height: var(--leading-ui); + --text-sm: var(--text-control); + --text-sm--line-height: var(--leading-ui); + --text-base: var(--text-body); + --text-base--line-height: var(--leading-body); +} + +[data-slot="button"] { + cursor: pointer; +} +[data-slot="button"]:disabled { + cursor: default; +} +[data-slot="sheet-content"][data-instant] { + transform: none; + translate: none; + transition: none; +} +.settings-sheet-body { + display: flex; + flex-direction: column; + gap: var(--space-6); + overflow-y: auto; + padding: 0 var(--space-6) var(--space-6); + min-height: 0; +} +.settings-sheet-body .sheet-label { + font-size: var(--text-control); + color: var(--text); + text-transform: none; + letter-spacing: normal; +} +.settings-sheet-body .select-trigger { + width: 100%; + min-height: var(--control-lg); + justify-content: space-between; +} +.settings-sheet-body [data-slot="toggle-group"] { + width: 100%; +} +.settings-sheet-body [data-slot="toggle-group-item"] { + flex: 1; + min-width: 0; + min-height: var(--control-lg); +} +.settings-sheet-body [data-slot="toggle-group-item"][aria-pressed="true"] { + background: var(--accent-soft); + color: var(--accent-blue-ink); + border-color: var(--accent); +} +[data-slot="sheet-content"] { + border-color: var(--border-soft); +} +.settings-sheet-body .select-option { + min-height: var(--control-lg); +} + +@media (prefers-reduced-motion: reduce) { + [data-slot="sheet-content"], + [data-slot="sheet-overlay"] { + transform: none; + translate: none; + transition-property: opacity; + transition-duration: 100ms; + } +} diff --git a/gui/src/styles/workspace-orbit.css b/gui/src/styles/workspace-orbit.css index 3a837ee6..d7b8e3fa 100644 --- a/gui/src/styles/workspace-orbit.css +++ b/gui/src/styles/workspace-orbit.css @@ -2,42 +2,75 @@ Existing component contracts inherit these tokens, not a second CSS framework. */ :root, :root[data-style] { - --bg: light-dark(#eef1f6, #0c1018); - --rail: light-dark(#e8edf4, #101620); - --surface: light-dark(#ffffff, #151d29); - --raised: light-dark(#e3eaf2, #1e2939); - --raised-hover: light-dark(#d8e2ee, #29384b); - --text: light-dark(#152232, #edf3fc); - --muted: light-dark(#506078, #adbbcf); - --faint: light-dark(#61718a, #8b9bb3); - --border: light-dark(#bac7d9, #36475e); - --border-soft: light-dark(#d6deea, #263448); - --hover: light-dark(#e5edf6, #223146); - --accent: light-dark(#086454, #73e5cc); - --accent-hover: light-dark(#064e42, #98efdc); - --accent-ink: light-dark(#ffffff, #102a24); - --accent-blue: light-dark(#087361, #73e5cc); - --accent-blue-ink: light-dark(#086454, #98efdc); - --accent-soft: light-dark(#d9f1e9, #163e37); - --accent-ring: light-dark(#087361, #73e5cc); + --bg: light-dark(#ffffff, #141619); + --rail: light-dark(#f1f3f5, #1b1e23); + --surface: light-dark(#ffffff, #1b1e23); + --raised: light-dark(#e8ecf0, #292e36); + --raised-hover: light-dark(#dfe4eb, #333b46); + --text: light-dark(#222831, #eef0f4); + --muted: light-dark(#53606e, #b5beca); + --faint: light-dark(#647080, #96a3b3); + --border: light-dark(#b9c2ce, #48515f); + --border-soft: light-dark(#e1e5eb, #303741); + --hover: light-dark(#e5eaf0, #272d36); + --accent: light-dark(#315b94, #b4caff); + --accent-hover: light-dark(#244777, #cfddff); + --accent-ink: light-dark(#ffffff, #192842); + --accent-blue: light-dark(#315b94, #b4caff); + --accent-blue-ink: light-dark(#244777, #cfddff); + --accent-soft: light-dark(#e7eef9, #29364e); + --accent-ring: light-dark(#315b94, #b4caff); --font-ui: "Manrope Variable", system-ui, sans-serif; --font: var(--font-ui); --text-control: 14px; --text-caption: 12px; --text-label: 13px; - --text-title: 30px; - --text-display: 38px; + --text-title: 28px; + --text-display: 32px; --radius-xs: 8px; --radius-sm: 10px; - --radius: 16px; - --radius-lg: 20px; + --radius: 12px; + --radius-lg: 16px; --control-sm: 32px; --control-md: 38px; --control-lg: 44px; --workspace-rail: 224px; - --workspace-header: 72px; + --workspace-header: 64px; --workspace-measure: 1440px; - --workspace-shadow: 0 10px 36px light-dark(#2035570c, #00000024); + --scrollbar-thumb: var(--border); + --scrollbar-thumb-hover: var(--muted); + --scrollbar-track: var(--bg); + scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); + scrollbar-width: auto; +} + +:root ::-webkit-scrollbar { + width: 12px; + height: 12px; +} +:root ::-webkit-scrollbar-track { + background: var(--scrollbar-track); +} +:root ::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb); + border-radius: var(--radius-xs); +} +:root ::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-hover); +} +:root ::-webkit-scrollbar-thumb:active { + background: var(--text); +} +@media (forced-colors: active) { + :root { + scrollbar-color: auto; + } + :root ::-webkit-scrollbar-track { + background: Canvas; + } + :root ::-webkit-scrollbar-thumb { + background: ButtonText; + } } .ocx-workspace { @@ -56,17 +89,18 @@ border-bottom: 1px solid var(--border-soft); } .ocx-workspace .brand { + display: flex; + align-items: center; gap: var(--space-3); } .ocx-workspace .brand .name { font-weight: 700; font-size: var(--text-subtitle); - letter-spacing: -0.045em; + letter-spacing: -0.025em; } .ocx-workspace .brand .ver { - padding: var(--space-1) var(--space-2); - border: 1px solid var(--border-soft); - border-radius: var(--radius-xs); + padding: 0; + border: 0; color: var(--muted); } .ocx-workspace .offline-banner { @@ -94,6 +128,7 @@ padding: var(--space-3) var(--space-4); border-radius: var(--radius-sm); text-align: left; + white-space: normal; color: var(--muted); transition: color var(--motion-fast), @@ -103,7 +138,8 @@ flex-shrink: 0; } .workspace-navigation .workspace-destination.active { - color: var(--accent-blue-ink); + color: var(--text); + font-weight: 650; } .workspace-navigation .workspace-destination.active::after { content: none; @@ -113,9 +149,7 @@ inset: 0; z-index: -1; border-radius: inherit; - border: 1px solid var(--border-soft); - background: var(--surface); - box-shadow: var(--workspace-shadow); + background: var(--raised); } .workspace-destination:focus-visible { outline: 2px solid var(--accent-ring); @@ -155,7 +189,6 @@ .ocx-workspace .sub-tab.active { background: var(--surface); color: var(--text); - box-shadow: var(--workspace-shadow); } .ocx-workspace .sub-tab.active::after { content: none; @@ -165,11 +198,11 @@ } .ocx-workspace .page-head h2 { font-weight: 650; - letter-spacing: -0.04em; + letter-spacing: -0.025em; } .ocx-workspace .card { border-radius: var(--radius); - box-shadow: var(--workspace-shadow); + box-shadow: none; } .ocx-workspace .btn { min-height: var(--control-md); @@ -186,7 +219,8 @@ } @media (max-width: 1024px) { - :root { + :root, + :root[data-style] { --workspace-rail: 190px; } .workspace-navigation .workspace-destination { @@ -204,15 +238,20 @@ padding: 0 var(--space-4); } .ocx-workspace .workspace-navigation { - flex-direction: row; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); padding: var(--space-2); gap: var(--space-1); border-right: 0; border-bottom: 1px solid var(--border-soft); - overflow-x: auto; + overflow: visible; } .workspace-navigation .workspace-destination { - flex-shrink: 0; + min-width: 0; + padding: var(--space-2); + font-size: var(--text-caption); + white-space: normal; + line-height: var(--leading-tight); } .ocx-workspace .main-inner { padding: var(--space-4); @@ -224,6 +263,141 @@ display: none; } } + +.dashboard-workspace .pws-dashboard-stats { + padding: 0 0 var(--space-4); + margin-bottom: 0; + border: 0; + border-radius: 0; + background: none; +} +.dashboard-workspace .pws-dashboard-stat { + flex-direction: row; + gap: var(--space-2); +} +.dashboard-workspace .pws-dashboard-stat-count { + font-size: var(--text-label); +} +.dashboard-workspace .pws-dashboard-stat-label { + font-size: var(--text-label); + text-transform: none; + letter-spacing: normal; +} +.metric-list { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--space-6) var(--space-8); + margin: 0 0 var(--space-8); + padding: var(--space-6) 0; + border-block: 1px solid var(--border-soft); +} +.metric-reading { + display: flex; + flex-direction: column; + gap: var(--space-2); + min-width: 0; +} +.metric-reading dt { + color: var(--muted); + font-size: var(--text-label); +} +.metric-reading dd { + margin: 0; + font-size: var(--text-title); + font-weight: 600; + font-variant-numeric: tabular-nums; + line-height: var(--leading-tight); +} +.dashboard-workspace .pws-dashboard-columns { + grid-template-columns: minmax(0, 1fr) minmax(0, 1.6fr); + gap: var(--space-8); +} +.dashboard-workspace .pws-dashboard-section-title { + font-size: var(--text-subtitle); + font-weight: 650; + letter-spacing: normal; + text-transform: none; + color: var(--text); + margin-bottom: var(--space-4); +} +.dashboard-workspace .pws-dashboard-empty { + margin: 0; + font-size: var(--text-control); +} +.dashboard-workspace .pws-dashboard-section { + min-height: 0; +} +.dashboard-workspace .pws-dashboard-row { + grid-template-columns: minmax(0, 1fr) auto; + padding: var(--space-3) 0; + cursor: default; +} +.dashboard-workspace .pws-dashboard-row:hover { + background: transparent; +} +.dashboard-workspace .pws-dashboard-row-name { + white-space: normal; + overflow-wrap: anywhere; +} +.dashboard-workspace .dash-bar-track { + grid-column: 1 / -1; +} +.dashboard-workspace .dash-bar-fill { + transition: none; +} +.dashboard-workspace .traffic-entry-head { + grid-template-columns: minmax(0, 1fr) auto auto; + grid-template-areas: "model model status" "principal principal principal" "time tokens duration"; + gap: var(--space-1) var(--space-3); + padding: var(--space-3) 0; + font-size: var(--text-caption); +} +.dashboard-workspace .traffic-col--model { + grid-area: model; + white-space: normal; + overflow-wrap: anywhere; +} +.dashboard-workspace .traffic-col--principal { + grid-area: principal; + white-space: normal; + overflow-wrap: anywhere; +} +.dashboard-workspace .traffic-col--status { + grid-area: status; +} +.dashboard-workspace .traffic-col--time { + grid-area: time; +} +.dashboard-workspace .traffic-col--tokens { + grid-area: tokens; +} +.dashboard-workspace .traffic-col--duration { + grid-area: duration; +} +@media (max-width: 1024px) { + .metric-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .dashboard-workspace .pws-dashboard-columns { + grid-template-columns: minmax(0, 1fr); + } +} +@media (max-width: 760px) { + .metric-list { + display: flex; + flex-direction: column; + gap: var(--space-3); + padding-block: var(--space-4); + } + .metric-reading { + flex-direction: row; + align-items: baseline; + justify-content: space-between; + } + .metric-reading dd { + font-size: var(--text-subtitle); + } +} @media (prefers-reduced-motion: reduce) { .workspace-destination { transition: none; diff --git a/gui/src/ui.tsx b/gui/src/ui.tsx index d10eeeed..25833fbc 100644 --- a/gui/src/ui.tsx +++ b/gui/src/ui.tsx @@ -1,31 +1,82 @@ /* Shared UI primitives built on the design-system classes in styles.css. */ -import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from "react"; +import { + useCallback, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, + type CSSProperties, + type KeyboardEvent, + type ReactNode, +} from "react"; import { createPortal } from "react-dom"; import { IconCheck, IconAlert } from "./icons"; import { IconChevron } from "./icons"; import { computeSelectMenuStyle } from "./select-position"; -export function Switch({ on, onClick, disabled, label }: { on: boolean; onClick: () => void; disabled?: boolean; label?: string }) { +export function Switch({ + on, + onClick, + disabled, + label, +}: { + on: boolean; + onClick: () => void; + disabled?: boolean; + label?: string; +}) { return ( - ); } -export function Notice({ tone, children }: { tone: "ok" | "err"; children: ReactNode }) { +export function Notice({ + tone, + children, +}: { + tone: "ok" | "err"; + children: ReactNode; +}) { return ( -
+
{tone === "ok" ? : } {children}
); } -export interface SelectOption { value: string; label: React.ReactNode } +export interface SelectOption { + value: string; + label: React.ReactNode; +} -export function Select({ value, options, onChange, disabled, label, id, style, align, placement, dropdownStyle, portal = true }: { value: string; +export function Select({ + value, + options, + onChange, + disabled, + label, + id, + style, + align, + placement, + dropdownStyle, + portal = true, +}: { + value: string; options: SelectOption[]; onChange: (value: string) => void; disabled?: boolean; @@ -46,14 +97,24 @@ export function Select({ value, options, onChange, disabled, label, id, style, a const ref = useRef(null); const triggerRef = useRef(null); const menuRef = useRef(null); - const optionId = useCallback((index: number) => `${listboxId}-${index}`, [listboxId]); - const current = options.find(o => o.value === value); - const selectedIndex = options.length === 0 ? 0 : Math.max(0, options.findIndex(o => o.value === value)); + const optionId = useCallback( + (index: number) => `${listboxId}-${index}`, + [listboxId], + ); + const current = options.find((o) => o.value === value); + const selectedIndex = + options.length === 0 + ? 0 + : Math.max( + 0, + options.findIndex((o) => o.value === value), + ); // While open, keyboard/hover highlight wins; while closed, follow the selected value. // Clamp so aria-activedescendant never points at a missing option after shrink/reorder. - const activeIndex = !open || options.length === 0 - ? selectedIndex - : Math.min(highlightIndex ?? selectedIndex, options.length - 1); + const activeIndex = + !open || options.length === 0 + ? selectedIndex + : Math.min(highlightIndex ?? selectedIndex, options.length - 1); const close = useCallback((restoreFocus = false) => { setOpen(false); @@ -61,29 +122,38 @@ export function Select({ value, options, onChange, disabled, label, id, style, a if (restoreFocus) triggerRef.current?.focus(); }, []); - const openAt = useCallback((index: number) => { - if (disabled || options.length === 0) return; - const clamped = Math.max(0, Math.min(options.length - 1, index)); - setHighlightIndex(clamped); - setOpen(true); - }, [disabled, options.length]); + const openAt = useCallback( + (index: number) => { + if (disabled || options.length === 0) return; + const clamped = Math.max(0, Math.min(options.length - 1, index)); + setHighlightIndex(clamped); + setOpen(true); + }, + [disabled, options.length], + ); - const reposition = useCallback((menuHeight?: number) => { - if (!portal) return; - const trigger = triggerRef.current; - if (!trigger) return; - setMenuStyle(computeSelectMenuStyle(trigger.getBoundingClientRect(), { - align, - placement, - menuHeight, - })); - }, [align, placement, portal]); + const reposition = useCallback( + (menuHeight?: number) => { + if (!portal) return; + const trigger = triggerRef.current; + if (!trigger) return; + setMenuStyle( + computeSelectMenuStyle(trigger.getBoundingClientRect(), { + align, + placement, + menuHeight, + }), + ); + }, + [align, placement, portal], + ); useEffect(() => { if (!open) return; const onPointerDown = (e: MouseEvent) => { const target = e.target as Node; - if (ref.current?.contains(target) || menuRef.current?.contains(target)) return; + if (ref.current?.contains(target) || menuRef.current?.contains(target)) + return; close(); }; document.addEventListener("mousedown", onPointerDown); @@ -106,20 +176,30 @@ export function Select({ value, options, onChange, disabled, label, id, style, a if (!open || !portal || !menuRef.current || !triggerRef.current) return; const nextHeight = menuRef.current.offsetHeight; if (!nextHeight) return; - const nextStyle = computeSelectMenuStyle(triggerRef.current.getBoundingClientRect(), { - align, - placement, - menuHeight: nextHeight, - }); - setMenuStyle(prev => { - if (prev?.top === nextStyle.top && prev?.bottom === nextStyle.bottom && prev?.maxHeight === nextStyle.maxHeight) return prev; + const nextStyle = computeSelectMenuStyle( + triggerRef.current.getBoundingClientRect(), + { + align, + placement, + menuHeight: nextHeight, + }, + ); + setMenuStyle((prev) => { + if ( + prev?.top === nextStyle.top && + prev?.bottom === nextStyle.bottom && + prev?.maxHeight === nextStyle.maxHeight + ) + return prev; return nextStyle; }); }, [align, open, options.length, placement, portal]); useLayoutEffect(() => { if (!open || !menuRef.current) return; - const active = menuRef.current.querySelector(`[id="${optionId(activeIndex)}"]`); + const active = menuRef.current.querySelector( + `[id="${optionId(activeIndex)}"]`, + ); active?.scrollIntoView({ block: "nearest" }); }, [activeIndex, open, optionId]); @@ -135,7 +215,9 @@ export function Select({ value, options, onChange, disabled, label, id, style, a switch (event.key) { case "ArrowDown": event.preventDefault(); - openAt(open ? Math.min(options.length - 1, activeIndex + 1) : selectedIndex); + openAt( + open ? Math.min(options.length - 1, activeIndex + 1) : selectedIndex, + ); break; case "ArrowUp": event.preventDefault(); @@ -158,6 +240,7 @@ export function Select({ value, options, onChange, disabled, label, id, style, a case "Escape": if (open) { event.preventDefault(); + event.stopPropagation(); close(true); } break; @@ -174,7 +257,8 @@ export function Select({ value, options, onChange, disabled, label, id, style, a } }; - const activeDescendant = open && options[activeIndex] ? optionId(activeIndex) : undefined; + const activeDescendant = + open && options[activeIndex] ? optionId(activeIndex) : undefined; const dropdown = open ? (
{options.map((o, index) => ( + > + {o.label} + ))}
) : null; return ( -
+
- {portal ? (dropdown && createPortal(dropdown, document.body)) : dropdown} + {portal ? dropdown && createPortal(dropdown, document.body) : dropdown}
); } -export function EmptyState({ icon, title, children, className, style }: { icon?: ReactNode; title: ReactNode; children?: ReactNode; className?: string; style?: CSSProperties }) { +export function EmptyState({ + icon, + title, + children, + className, + style, +}: { + icon?: ReactNode; + title: ReactNode; + children?: ReactNode; + className?: string; + style?: CSSProperties; +}) { return (
{icon} @@ -241,7 +353,12 @@ export function EmptyState({ icon, title, children, className, style }: { icon?: } /* Hover/focus tooltip — styled replacement for the native `title` attribute. */ -export function Tooltip({ content, children, side = "top", maxWidth = 280 }: { +export function Tooltip({ + content, + children, + side = "top", + maxWidth = 280, +}: { content: ReactNode; children: ReactNode; side?: "top" | "bottom" | "left" | "right"; @@ -256,10 +373,18 @@ export function Tooltip({ content, children, side = "top", maxWidth = 280 }: { timer.current = window.setTimeout(() => setOpen(true), 150); }; const hide = () => { - if (timer.current !== null) { window.clearTimeout(timer.current); timer.current = null; } + if (timer.current !== null) { + window.clearTimeout(timer.current); + timer.current = null; + } setOpen(false); }; - useEffect(() => () => { if (timer.current !== null) window.clearTimeout(timer.current); }, []); + useEffect( + () => () => { + if (timer.current !== null) window.clearTimeout(timer.current); + }, + [], + ); return ( @@ -409,7 +411,7 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { diff --git a/tests/install-scripts.test.ts b/tests/install-scripts.test.ts index 06203aad..ce67d929 100644 --- a/tests/install-scripts.test.ts +++ b/tests/install-scripts.test.ts @@ -35,8 +35,12 @@ describe("install scripts", () => { expect(pkg.scripts?.dev).toBe("bun run src/cli/index.ts start"); expect(pkg.scripts?.["dev:proxy"]).toBe("bun run src/cli/index.ts start"); expect(pkg.scripts?.["dev:gui"]).toBe("cd gui && bun run dev"); - expect(pkg.scripts?.["dev:preview"]).toBe("bun scripts/freebuff-preview.ts"); - expect(pkg.scripts?.["prepare:package"]).toBe("bun scripts/prepare-package.ts"); + expect(pkg.scripts?.["dev:preview"]).toBe( + "bun scripts/freebuff-preview.ts", + ); + expect(pkg.scripts?.["prepare:package"]).toBe( + "bun scripts/prepare-package.ts", + ); expect(pkg.scripts?.prepack).toBe("bun run prepare:package"); expect(pkg.files).toContain("assets/banner.png"); expect(pkg.files).toContain("assets/architecture.png"); @@ -52,19 +56,23 @@ describe("install scripts", () => { expect(preview).toContain("OPENCODEX_PROXY_TARGET"); expect(preview).toContain('HOST: "0.0.0.0"'); expect(preview).toContain("process.env.PORT"); - expect(vite).toContain("host: '0.0.0.0'"); + expect(vite).toMatch(/host:\s*(["'])0\.0\.0\.0\1/); expect(vite).toContain("proxyConfig(proxyTarget)"); expect(vite).toContain("guiSessionPlugin(proxyTarget)"); }); test("Node can import the package main without executing the CLI", () => { - const result = spawnSync("node", [ - "-e", - "import('./bin/package-main.mjs').then(m => { if (m.cliCommand !== 'ocx') process.exit(2); })", - ], { - cwd: repoRoot, - encoding: "utf8", - }); + const result = spawnSync( + "node", + [ + "-e", + "import('./bin/package-main.mjs').then(m => { if (m.cliCommand !== 'ocx') process.exit(2); })", + ], + { + cwd: repoRoot, + encoding: "utf8", + }, + ); expect(result.status).toBe(0); }); @@ -111,7 +119,9 @@ describe("install scripts", () => { expect(launcher).toContain('process.argv[2] === "update"'); expect(launcher).toContain('["install", "-g", `${PKG}@${tag}`]'); - expect(launcher).toContain('return String(currentVersion).includes("-preview.") ? "preview" : "latest"'); + expect(launcher).toContain( + 'return String(currentVersion).includes("-preview.") ? "preview" : "latest"', + ); expect(launcher).toContain("!isBunGlobalInstall()"); expect(launcher).toContain("repairCodexShimIfNeeded()"); expect(launcher).toContain("runNpmSelfUpdate()"); From ab7d3520b10fc3a2fe397b61fa48c97e18e7d1d6 Mon Sep 17 00:00:00 2001 From: MisterWanted Date: Sun, 6 Sep 2026 23:18:40 +0200 Subject: [PATCH 05/10] feat(gui): replace model hover cards with a searchable workspace --- .../content/docs/guides/sub-agent-surface.md | 26 +- .../src/content/docs/guides/web-dashboard.md | 101 +- gui/DESIGN.md | 78 +- gui/UX-CONTRACT.md | 21 +- gui/src/i18n/en.ts | 24 + gui/src/i18n/nl.ts | 33 + gui/src/main.tsx | 1 + gui/src/pages/ModelInspector.tsx | 163 + gui/src/pages/Models.tsx | 1669 ++++--- gui/src/pages/models-shared.ts | 29 +- gui/src/styles-models-workspace.css | 122 +- gui/src/styles.css | 4230 +++++++++++++---- gui/src/styles/model-catalog.css | 428 ++ gui/src/styles/workspace-orbit.css | 68 +- .../frontend-component-coverage.test.tsx | 209 +- gui/tests/models-empty-provider.test.tsx | 357 +- gui/tests/models-workspace-selection.test.tsx | 668 +++ 17 files changed, 6447 insertions(+), 1780 deletions(-) create mode 100644 gui/src/pages/ModelInspector.tsx create mode 100644 gui/src/styles/model-catalog.css create mode 100644 gui/tests/models-workspace-selection.test.tsx diff --git a/docs-site/src/content/docs/guides/sub-agent-surface.md b/docs-site/src/content/docs/guides/sub-agent-surface.md index afb53480..f0583436 100644 --- a/docs-site/src/content/docs/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/guides/sub-agent-surface.md @@ -11,11 +11,11 @@ On the v2 surface (`multi_agent_v2`), a spawned sub-agent inherits the parent mo ## Modes -| Mode | Surface | Behavior | -| --- | --- | --- | -| **v1** | `multi_agent_v1` | Classic namespaced agent tools with `send_input` / `close_agent` / `resume_agent`. A `spawn_agent` model override can start a sub-agent on a different model. | -| **base** (default) | Upstream pins | Restores upstream model pins: gpt-5.6-sol and gpt-5.6-terra use v2, gpt-5.6-luna uses v1, and unpinned models follow the Codex `multi_agent_v2` feature flag. Spawn behavior follows the surface that resolves for that model. | -| **v2** | `multi_agent_v2` | Flat `spawn_agent` tools with concurrent sessions and `send_message` / `followup_task` / `wait_agent` / `interrupt_agent`. Children inherit the parent model on full-history forks; `fork_turns: "none"` (or a partial fork) accepts `model` / `reasoning_effort` overrides. If a native→routed child receives only backend-encrypted task content, external routes return `unreadable_encrypted_agent_task`; mixed combos prefer a decrypt-capable native target ([#92](https://github.com/GroepOnline/opencodex/issues/92)). | +| Mode | Surface | Behavior | +| ------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **v1** | `multi_agent_v1` | Classic namespaced agent tools with `send_input` / `close_agent` / `resume_agent`. A `spawn_agent` model override can start a sub-agent on a different model. | +| **base** (default) | Upstream pins | Restores upstream model pins: gpt-5.6-sol and gpt-5.6-terra use v2, gpt-5.6-luna uses v1, and unpinned models follow the Codex `multi_agent_v2` feature flag. Spawn behavior follows the surface that resolves for that model. | +| **v2** | `multi_agent_v2` | Flat `spawn_agent` tools with concurrent sessions and `send_message` / `followup_task` / `wait_agent` / `interrupt_agent`. Children inherit the parent model on full-history forks; `fork_turns: "none"` (or a partial fork) accepts `model` / `reasoning_effort` overrides. If a native→routed child receives only backend-encrypted task content, external routes return `unreadable_encrypted_agent_task`; mixed combos prefer a decrypt-capable native target ([#92](https://github.com/GroepOnline/opencodex/issues/92)). | ### Encrypted v2 task delivery @@ -118,12 +118,12 @@ The optional sub-agent effort setting is stored as `injectionEffort` and is mean `ultra` ranks above `max` in the Codex catalog and adds automatic-delegation semantics, but it never reaches a provider as a literal wire value. Codex converts `ultra` to `max` at the client boundary. opencodex then keeps the provider request valid: -| Model | `max` on wire | `ultra` selection on wire | -| --- | --- | --- | -| gpt-5.5, gpt-5.4, gpt-5.4-mini | xhigh | xhigh (via max, then `nativeEffortClamp`) | -| gpt-5.6-sol, gpt-5.6-terra | max | max | -| gpt-5.6-luna | max | Not advertised by its exact upstream ladder | -| Routed models | Mapped or clamped by the adapter | Converted to max, then mapped or clamped by the adapter | +| Model | `max` on wire | `ultra` selection on wire | +| ------------------------------ | -------------------------------- | ------------------------------------------------------- | +| gpt-5.5, gpt-5.4, gpt-5.4-mini | xhigh | xhigh (via max, then `nativeEffortClamp`) | +| gpt-5.6-sol, gpt-5.6-terra | max | max | +| gpt-5.6-luna | max | Not advertised by its exact upstream ladder | +| Routed models | Mapped or clamped by the adapter | Converted to max, then mapped or clamped by the adapter | Catalog availability is independent of the v1/v2 mode. Reasoning-capable generated entries advertise `max` so direct sub-agent effort overrides validate; current generated routed entries also advertise `ultra`. Exact upstream model ladders are preserved, which is why gpt-5.6-luna stops at `max`. @@ -132,3 +132,7 @@ Catalog availability is independent of the v1/v2 mode. Reasoning-capable generat The global context cap value defaults to 350k and limits the advertised `context_window` only for routed providers whose cap is enabled. Native OpenAI models keep their real context windows. Change the value or the all-provider setting in the Models page, or toggle the cap next to an individual provider group header. + +In the pending catalog redesign, global controls are under **Model settings & +advanced controls**, and each group's cap toggle is under **Provider settings**. +Only their placement changes; the cap and multi-agent behavior above is unchanged. diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 4066199e..054efc26 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -33,24 +33,24 @@ auto-generated `~/.opencodex/admin-api-token` file). ## What you can do -| Area | What it does | -| --- | --- | -| **Dashboard summary** | Multi-agent mode, online state, version, uptime, provider count, 30-day token total, estimated cost, usage coverage, 429/502 ratios, active providers, and available native/routed models. | -| **Sub-agent delegation** | Choose a native or routed model and optional reasoning effort shared by OpenCodex delegation guidance and the separate native-default opt-in. This is not a proxy-side per-spawn router; see below. | -| **Sidecars** | Choose the web-search model and effort plus the vision-description model. Changes apply on the next request. | -| **Maintenance** | Resync the Codex model catalog, inspect project-local config bypass warnings, check the latest or preview release, and run an update with optional proxy restart. | -| **Startup safety** | Show whether injected Codex routing survives a restart, with separate service and launcher-shim health plus exact repair commands. | -| **Windows tray** | Install a per-user login tray for one-click proxy start, stop, restart, dashboard access, and status. The tray is a controller, not a proxy restart service. | -| **Codex autostart** | Allow an already-installed Codex launcher shim to run `ocx ensure`. This toggle does not install a shim or background service. | -| **Providers** | Add, edit, enable/disable, and remove providers; manage OAuth account pools and API-key pools where supported. Provider Settings can disable live model discovery for endpoints with missing, slow, or oversized `/models` catalogs. Claude, Cursor, and Google Antigravity OAuth surfaces each have an experimental account-pool card. Claude and Cursor show per-account usage bars (usage is per credential); a failed probe keeps the last-known bars and marks them unavailable until the next successful refresh. Cursor accepts a second account via browser login or a pasted user API key (JWT) on the same login surface. The pool turns on automatically when a second account is added; you can still turn it off. Cooldown rows have a clear-cooldown control. | -| **Add provider** | Search registry-backed presets for account login, API-key services, local servers, or a custom endpoint. | -| **Codex Auth** | Add ChatGPT/Codex pool accounts, select the next-session account, refresh 5h / weekly / 30d quotas, enable or disable quota auto-switch, set its 1–100% threshold, and configure transient-failure failover. | -| **Subagents** | Feature up to five bare native or namespaced routed models in the `spawn_agent` override list. | -| **Models** | Toggle native GPT and routed models, set provider allowlists and context caps, choose v1/base/v2, and configure the v2 thread limit. Configured providers stay visible as zero-model groups when discovery is off or returns no rows. | -| **Logs** | Auto-refresh recent requests with tokens, requested effort and (when available) effective outbound effort, resolved model, provider, status, request id, duration, and error details. The detail view includes the exact reasoning wire field when the adapter emits one. Filter by opaque conversation/session id (when the client sends one) to total tokens and estimated list-price cost for the currently loaded Logs ring. | -| **Usage / Debug** | Inspect token-usage coverage and trends, or enable opt-in provider transport and usage-extraction diagnostics. | -| **Storage** | Read-only CODEX_HOME disk breakdown (sessions, archives, DBs, attachments). Optional archived cleanup: preview the oldest N%, then quarantine to `CODEX_HOME/.trash` (default) or permanently delete behind an explicit checkbox. **Auto-cleanup policy** is opt-in and **default OFF** (`storageCleanupPolicy.enabled`); configure threshold/target/schedule/mode on the Storage page, or trigger **Run now**. Quarantined entries can be restored from the Storage page (JSONL + threads). Active sessions stay read-only. Cleanup and restore are refused while Codex holds the newest/active `state_*.sqlite` locked. | -| **Stop** | Gracefully stop the proxy and installed background service, restore native Codex, and exit (`POST /api/stop`). | +| Area | What it does | +| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Dashboard summary** | Multi-agent mode, online state, version, uptime, provider count, 30-day token total, estimated cost, usage coverage, 429/502 ratios, active providers, and available native/routed models. | +| **Sub-agent delegation** | Choose a native or routed model and optional reasoning effort shared by OpenCodex delegation guidance and the separate native-default opt-in. This is not a proxy-side per-spawn router; see below. | +| **Sidecars** | Choose the web-search model and effort plus the vision-description model. Changes apply on the next request. | +| **Maintenance** | Resync the Codex model catalog, inspect project-local config bypass warnings, check the latest or preview release, and run an update with optional proxy restart. | +| **Startup safety** | Show whether injected Codex routing survives a restart, with separate service and launcher-shim health plus exact repair commands. | +| **Windows tray** | Install a per-user login tray for one-click proxy start, stop, restart, dashboard access, and status. The tray is a controller, not a proxy restart service. | +| **Codex autostart** | Allow an already-installed Codex launcher shim to run `ocx ensure`. This toggle does not install a shim or background service. | +| **Providers** | Add, edit, enable/disable, and remove providers; manage OAuth account pools and API-key pools where supported. Provider Settings can disable live model discovery for endpoints with missing, slow, or oversized `/models` catalogs. Claude, Cursor, and Google Antigravity OAuth surfaces each have an experimental account-pool card. Claude and Cursor show per-account usage bars (usage is per credential); a failed probe keeps the last-known bars and marks them unavailable until the next successful refresh. Cursor accepts a second account via browser login or a pasted user API key (JWT) on the same login surface. The pool turns on automatically when a second account is added; you can still turn it off. Cooldown rows have a clear-cooldown control. | +| **Add provider** | Search registry-backed presets for account login, API-key services, local servers, or a custom endpoint. | +| **Codex Auth** | Add ChatGPT/Codex pool accounts, select the next-session account, refresh 5h / weekly / 30d quotas, enable or disable quota auto-switch, set its 1–100% threshold, and configure transient-failure failover. | +| **Subagents** | Feature up to five bare native or namespaced routed models in the `spawn_agent` override list. | +| **Models** | Toggle native GPT and routed models, set provider allowlists and context caps, choose v1/base/v2, and configure the v2 thread limit. Configured providers stay visible as zero-model groups when discovery is off or returns no rows. | +| **Logs** | Auto-refresh recent requests with tokens, requested effort and (when available) effective outbound effort, resolved model, provider, status, request id, duration, and error details. The detail view includes the exact reasoning wire field when the adapter emits one. Filter by opaque conversation/session id (when the client sends one) to total tokens and estimated list-price cost for the currently loaded Logs ring. | +| **Usage / Debug** | Inspect token-usage coverage and trends, or enable opt-in provider transport and usage-extraction diagnostics. | +| **Storage** | Read-only CODEX_HOME disk breakdown (sessions, archives, DBs, attachments). Optional archived cleanup: preview the oldest N%, then quarantine to `CODEX_HOME/.trash` (default) or permanently delete behind an explicit checkbox. **Auto-cleanup policy** is opt-in and **default OFF** (`storageCleanupPolicy.enabled`); configure threshold/target/schedule/mode on the Storage page, or trigger **Run now**. Quarantined entries can be restored from the Storage page (JSONL + threads). Active sessions stay read-only. Cleanup and restore are refused while Codex holds the newest/active `state_*.sqlite` locked. | +| **Stop** | Gracefully stop the proxy and installed background service, restore native Codex, and exit (`POST /api/stop`). | ### Linking to a section @@ -70,6 +70,27 @@ may apply instead. ## Model visibility +### Pending catalog workspace redesign + +The redesign branch replaces the provider side rail and model hover cards with a +searchable catalog and persistent model inspector. This interface is pending +release; an installed version may still have the earlier layout. + +- Search matches model IDs, display names and provider names. The provider filter + narrows the same catalog. Search also finds models in collapsed groups. +- Select a model row to inspect its metadata without changing configuration. The + separate visibility switch and **In model picker** control use the same final + visibility rules below. Catalog metadata does not prove live account access or + which model was actually served; missing metadata is shown as **Not reported**. +- On narrow screens, details replace the list. **Back to models** restores the + list and focus. Custom-model edit and delete actions are in the inspector. +- Open **Provider settings** in a group for refresh, custom-model creation, bulk + visibility and its context-cap toggle. Bulk actions affect that entire provider, + not just search results. Empty providers cannot send an empty bulk operation. +- Global caps, shadow-call settings, v1/base/v2 controls and combo summaries remain + under **Model settings & advanced controls**. Existing route bookmarks still work. +- New users see expanded provider groups. Saved collapse preferences are retained. + The **Models** switches show final Codex visibility: a routed model is on only when its provider allowlist includes it (or no allowlist is set) and it is not disabled. Turning a model on reconciles both filters atomically; **All on** clears the provider allowlist so newly discovered models are also on. ## Delegation picker vs spawn routing @@ -124,29 +145,29 @@ The **Codex Auth** page manages the native ChatGPT/Codex route: The GUI is a thin client over the proxy's JSON management API. Useful endpoints include: -| Endpoint | Purpose | -| --- | --- | -| `GET` / `PUT /api/settings` | Read settings or toggle Codex autostart. | -| `GET /api/startup-health` | Read secret-free routing, service, shim, and restart-safety diagnostics. | -| `POST /api/startup-action` | Install the background service or Codex launcher shim through fixed, allowlisted actions. | -| `GET` / `POST /api/windows-tray` | Read or change the Windows tray installation and visible-process state. POST accepts `install`, `start`, `stop`, or `uninstall`. | -| `POST /api/sync` | Rebuild the shared model catalog and stale the Codex model cache. | -| `POST /api/providers/models/refresh?name=...` | Force-fetch one provider's live `/models` list (ignores cache and failure cooldown) and resync the Codex catalog. Dashboard **Fetch models** uses this. | -| `GET /api/update/check` · `POST /api/update/run` · `GET /api/update/status` | Check, run, and monitor self-update jobs. Worker PIDs are persisted so a crashed job recovers automatically; legacy no-PID jobs recover after ten minutes. | -| `GET` / `PUT /api/sidecar-settings` | Read or set search/vision sidecar model settings. | -| `GET` / `PUT /api/injection-model` | Read or set the shared sub-agent model/effort selection and the independent guidance/native-default switches. | -| `GET` / `PUT /api/v2` | Read or set the surface mode, Codex feature flag, and v2 thread limit. | +| Endpoint | Purpose | +| ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` / `PUT /api/settings` | Read settings or toggle Codex autostart. | +| `GET /api/startup-health` | Read secret-free routing, service, shim, and restart-safety diagnostics. | +| `POST /api/startup-action` | Install the background service or Codex launcher shim through fixed, allowlisted actions. | +| `GET` / `POST /api/windows-tray` | Read or change the Windows tray installation and visible-process state. POST accepts `install`, `start`, `stop`, or `uninstall`. | +| `POST /api/sync` | Rebuild the shared model catalog and stale the Codex model cache. | +| `POST /api/providers/models/refresh?name=...` | Force-fetch one provider's live `/models` list (ignores cache and failure cooldown) and resync the Codex catalog. Dashboard **Fetch models** uses this. | +| `GET /api/update/check` · `POST /api/update/run` · `GET /api/update/status` | Check, run, and monitor self-update jobs. Worker PIDs are persisted so a crashed job recovers automatically; legacy no-PID jobs recover after ten minutes. | +| `GET` / `PUT /api/sidecar-settings` | Read or set search/vision sidecar model settings. | +| `GET` / `PUT /api/injection-model` | Read or set the shared sub-agent model/effort selection and the independent guidance/native-default switches. | +| `GET` / `PUT /api/v2` | Read or set the surface mode, Codex feature flag, and v2 thread limit. | | `GET /api/providers` · `POST /api/providers` · `PATCH /api/providers?name=...` · `DELETE /api/providers?name=...` | List, add/replace, partially edit, or remove providers. PATCH accepts `noReasoningModels` as an array of non-empty model strings; `[]` clears the opt-out, while omitted fields and stored credentials are preserved. API keys must use the dedicated key endpoints. Never POST a masked GET response back as provider configuration. | -| `GET /api/models` · `PUT /api/disabled-models` | List native/routed model rows and update the shared disabled-model set. | -| `GET /api/selected-models` · `PUT /api/model-visibility` | Read provider allowlists and atomically change the final visibility of one model or provider group. | -| `GET /api/key-providers` · `GET /api/oauth/providers` | Read the API-key and OAuth provider catalogs. | -| `POST /api/oauth/login` · `GET /api/oauth/status` | Start a provider OAuth flow and poll for completion. | -| `GET /api/codex-auth/accounts?refresh=1` | List main and pool accounts, force quota refresh, and report main-account `hasCredential` / terminal `needsReauth` state. | -| `PUT /api/codex-auth/active` · `PUT /api/codex-auth/auto-switch` · `PUT /api/codex-auth/failover` | Select the account for the next request and configure pool routing. | -| `POST /api/codex-auth/login` · `GET /api/codex-auth/login-status` | Add a pool account through browser login. | -| `GET /api/logs?tail=50&provider=...&status=5xx` | Read recent request metadata with optional tail, provider, and exact/class status filters. | -| `GET` / `PUT /api/subagent-models` | Read or set the five featured `spawn_agent` override models. | -| `POST /api/stop` | Stop the proxy/service, restore native Codex, and exit. | +| `GET /api/models` · `PUT /api/disabled-models` | List native/routed model rows and update the shared disabled-model set. | +| `GET /api/selected-models` · `PUT /api/model-visibility` | Read provider allowlists and atomically change the final visibility of one model or provider group. | +| `GET /api/key-providers` · `GET /api/oauth/providers` | Read the API-key and OAuth provider catalogs. | +| `POST /api/oauth/login` · `GET /api/oauth/status` | Start a provider OAuth flow and poll for completion. | +| `GET /api/codex-auth/accounts?refresh=1` | List main and pool accounts, force quota refresh, and report main-account `hasCredential` / terminal `needsReauth` state. | +| `PUT /api/codex-auth/active` · `PUT /api/codex-auth/auto-switch` · `PUT /api/codex-auth/failover` | Select the account for the next request and configure pool routing. | +| `POST /api/codex-auth/login` · `GET /api/codex-auth/login-status` | Add a pool account through browser login. | +| `GET /api/logs?tail=50&provider=...&status=5xx` | Read recent request metadata with optional tail, provider, and exact/class status filters. | +| `GET` / `PUT /api/subagent-models` | Read or set the five featured `spawn_agent` override models. | +| `POST /api/stop` | Stop the proxy/service, restore native Codex, and exit. | :::tip Adding **Ollama Cloud** or another catalog provider from the dashboard copies its text-versus-vision diff --git a/gui/DESIGN.md b/gui/DESIGN.md index b6344e67..5069356a 100644 --- a/gui/DESIGN.md +++ b/gui/DESIGN.md @@ -11,12 +11,46 @@ ChefGroep-producten en het gedeelde design-system worden hiermee niet gewijzigd. ### Richting en componenten -De eerste inkt/mint-richting (werknaam Orbit) is door Joep afgewezen als generiek. -De huidige correctie is geen goedgekeurde nieuwe merkidentiteit. Het product heet -opencodex; we voegen geen nieuw merk of parallelle app toe. De werkinterface gebruikt -neutrale lichte/grafietoppervlakken, blauw voor acties en Manrope voor interfacecopy. -JetBrains Mono blijft voor machinedata. Geen marketinghero boven dagelijkse data, -decoratieve metriekkaarten of herhaalde navigatie zonder extra bestemming. +Zowel de eerste inkt/mint-richting (werknaam Orbit) als de daaropvolgende blauwe +dashboardcorrectie zijn door Joep afgewezen als generiek. De bestaande runtime is +dus geen visueel geaccepteerd ontwerp. Blauw en paars zijn uitgesloten als merk-, +actie-, selectie- en focusaccent voor het herontwerp; een andere accentkleur op +dezelfde dashboardcompositie is geen oplossing. Het product blijft opencodex. + +De catalogusslice vertrekt vanuit het dagelijkse werk: een model vinden, +de bijbehorende provider begrijpen en de bestaande instellingen aanpassen. Een +doorzoekbare modellenlijst met contextueel detail krijgt voorrang boven een +overzicht van zeven even grote getallen. Bestaande verkeer- en gebruiksschermen +blijven bereikbaar; metrics verdwijnen niet, maar bepalen niet automatisch de +hele werkinterface. Geen verzonnen routinggrafiek of nieuwe backend hiervoor. + +De voorgestelde basis is monochroom: wit `#ffffff`, lichtgrijs `#f1f1f1`, lijn +`#d4d4d4`, secundaire inkt `#656565`, donker oppervlak `#262626`, inkt `#161616`. +Dit zijn de basisrollen van de branch, geen uitgerolde productie-identiteit. Primaire acties +gebruiken tekst/achtergrond-inversie; selectie gebruikt neutraal vlak plus gewicht +of contour. Groen, amber en rood blijven betekenisvolle statussen met tekst, +niet een nieuw merkpalet. Toets focus en tekstcontrast in beide thema's. +Manrope blijft voorlopig voor interfacecopy, JetBrains Mono voor machinedata: +een nieuwe fontdependency is niet de oplossing voor gebrekkige hiërarchie. + +### Onderzoeksbasis en wat we niet kopiëren + +- [Raycast, A fresh look and feel (2022)](https://www.raycast.com/blog/a-fresh-look-and-feel): + zoekveld en contextacties krijgen prioriteit; de actiebalk maakt verborgen + handelingen vindbaar. [Raycast 2.0 (2026)](https://www.raycast.com/blog/the-new-raycast) + behoudt deze actiegerichte opbouw. Geen glaslaag of wallpaper overnemen. +- [Resend, domeinverificatie (2023)](https://resend.com/blog/new-domain-verification-experience): + uitleg en feedback sluiten aan op de gekozen provider en individuele records. + [Productrebranding (2025)](https://resend.com/blog/rebranding-resend) toont + objectlijsten als hoofdinhoud. Niet hun logo, serifkoppen of statuskleuren kopiëren. +- [GitButler 0.15 (2025)](https://blog.gitbutler.com/gitbutler-15-quirky-quinceanera): + werkobjecten en bewerkingen bepalen de ruimte; herstel en selectie zijn onderdeel + van de interface. Geen commit-lanes nabouwen voor modellen zonder zo'n workflow. + +Deze bronnen zijn gedateerde primaire ontwerpvoorbeelden, geen bewijs dat ieder +detail vandaag ongewijzigd is of dat hun vormgeving automatisch bij OCX past. +Ook zwart/neon, crème/terracotta en een krantachtig raster kunnen templates zijn. +Componentlibraries leveren gedrag en consistentie, niet de productcompositie. - `WorkspaceNavigation`: één getypeerde bestemmingencatalogus, iconen, labels, actieve pagina en een gedeelde Motion-selectie. Bestaande hashes blijven werken. @@ -32,18 +66,26 @@ decoratieve metriekkaarten of herhaalde navigatie zonder extra bestemming. ### Geometrie en responsiviteit -Werkvlak maximaal 1440px; navigatie 224px (190px op kleinere desktops); header 64px. -Controls 32/38/44px, primaire touch-acties minimaal 44px; radii 8/10/12/16px. -Typografie 12/13/14/15/28/32px. Mobiel krijgt zes gelabelde bestemmingen in een -3×2-raster in plaats van een horizontaal verborgen rij. Geen -afgesneden werkpanelen, paginabrede horizontale overflow of onbereikbare acties. +De catalogusslice heeft een werkvlak tot 1440px, horizontale globale navigatie en +een desktopheader van 52px. De modellenwerkplek gebruikt een ruime vergelijkbare +lijst en een detailpaneel van 280–340px voor de selectie. Geen drie smalle +zijbalken of een mini-tabel in een grote kaart. Links uitlijnen, model-id's leesbaar +houden en numerieke kolommen uitlijnen. Begin bij 14px interfacecopy, 12px metadata +en 24px paginatitel; toets lange namen en EN/NL vóór verdere verfijning. +Controls mogen compact zijn op desktop, maar primaire touch-acties blijven minimaal +44px. Mobiel toont lijst of detail met een expliciete terugactie en focusherstel, +niet een verkleinde desktop. Bestaande hashes en bestemmingen blijven bereikbaar. +Geen paginabrede horizontale overflow of acties die alleen op hover bestaan. ### Toestanden en motion Echte API-status blijft leidend: laden, leeg, gedeeltelijk, fout en offline mogen -niet worden vervangen door mooie voorbeeldcijfers. Selectie beweegt met een -onderbreekbare spring; toetsenbordnavigatie en reduced-motion slaan die beweging -over. Geen automatische page-reveal of pressed-translate in nieuwe controls. +niet worden vervangen door mooie voorbeeldcijfers. Catalogusvermelding is geen +bewijs van bereikbaarheid, beschikbaar account of daadwerkelijk geserveerd model. +Toon alleen relaties/statussen waarvoor de bestaande API bewijs levert. +Selectiefeedback is direct; een detailovergang mag kort en onderbreekbaar zijn, +zonder de lijst te laten verspringen. Toetsenbordnavigatie en reduced-motion slaan +verplaatsing over. Geen automatische page-reveal of pressed-translate in nieuwe controls. Sheet opent maximaal 200ms via transform/opacity; toetsenbordopening zonder verplaatsing, reduced-motion alleen korte opacity-feedback. Focus, contrast en labels blijven zichtbaar in licht én donker. Onbekende waarden zijn geen nullen. @@ -68,7 +110,13 @@ misleidend. Licht/donker/systeem en EN/NL blijven beschikbaar. ### Implementatiestatus en acceptatie -De eerste slice vervangt de shell, navigatie, typografie en gedeelde tokens. +De eerste dashboardcompositie is visueel afgewezen. De daaropvolgende +modellenwerkplek is geïmplementeerd in de redesignbranch, nog niet door Joep +visueel geaccepteerd of uitgerold. `ModelInspector.tsx` vervangt de hoverkaart; +`styles/model-catalog.css` is eigenaar van lijst/detail-geometrie. Zoekfilter en +providerkeuze staan boven de lijst; geavanceerde bediening blijft in disclosures. +De bestaande API-, locale- en visibilitylogica blijven eigenaar. Verwijderde +selecties worden bij verversen gewist; focus wordt hersteld als die verloren raakt. Detailpanelen, volledige componentmigratie, landing en volledige visuele acceptatie blijven expliciete vervolgstappen. Brave-browserchecks van deze slice zijn geen productie- of brede toegankelijkheidscertificering. Niet als volledig diff --git a/gui/UX-CONTRACT.md b/gui/UX-CONTRACT.md index b5f461e8..68e89b4e 100644 --- a/gui/UX-CONTRACT.md +++ b/gui/UX-CONTRACT.md @@ -1,6 +1,6 @@ # OCX workspace UX contract -Scope: the pending workspace navigation, Overview and Settings migration. This +Scope: the pending workspace navigation, Models catalog, Overview and Settings migration. This records ownership, not approval of the unfinished visual redesign. Existing provider/authentication, deletion, billing and persistence policy stays with its runtime owner; this document does not invent replacement business rules. @@ -31,8 +31,10 @@ runtime owner; this document does not invent replacement business rules. | Scrollbar | Application stylesheet | `src/styles/workspace-orbit.css` | Global semantic roles; forced-colors defers to system | Computed styles / browser; platform rendering may differ | | Navigation | WorkspaceNavigation | `src/components/WorkspaceNavigation.tsx`, `src/route.ts` | Existing hashes; current page via aria-current | Workspace navigation tests | -There is no new table selection, form submission, date input, toast or CRUD owner -in this slice. Existing workflows retain their owners pending explicit migration. +Model selection belongs to `src/pages/Models.tsx`; the persistent detail surface +is `src/pages/ModelInspector.tsx`. Selection uses provider-qualified identity and +does not mutate configuration. The existing visibility and custom-model API +handlers retain ownership of writes. No new auth, billing or CRUD backend. ## Behavior and resilience @@ -55,13 +57,24 @@ in this slice. Existing workflows retain their owners pending explicit migration - Document title is localized route name plus opencodex; public landing keeps the product name. Single-destination views do not repeat an Overview subtab. -- Desktop side rail becomes six labelled mobile destinations in two rows. Primary +- Desktop navigation is a compact horizontal row; six labelled mobile destinations + remain visible in two rows. Primary navigation and Settings controls keep 44px targets; no page-wide overflow. - Mobile readings become compact label/value rows rather than empty KPI cards. - Pointer selection has a scoped, interruptible shared-layout spring; keyboard navigation and reduced motion are instant. No page entrance animation. - Settings movement is at most 200ms, transform/opacity only. Keyboard opening is instant; reduced motion suppresses displacement. No ambient animation added. +- Models uses global search plus provider filtering, preserving empty-provider and + discovery-failure states. Search opens matching groups without overwriting saved + collapse preferences; collapse controls are disabled while search is active. +- Selecting a model focuses its inspector heading. Back restores the originating + row, or search if that row is no longer rendered. On narrow screens, the detail + pane replaces list/header/filter chrome; none of the hidden controls stays tabbable. +- Visibility switches have 44px targets. Missing context/modalities remain unknown; + catalog visibility is explicitly not served-model or live-auth proof. +- Provider bulk actions retain full-provider scope under search. Advanced controls + are disclosed separately, not removed. Existing custom-model confirmation stays. ## Migration and verification diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index d5ec508d..30090905 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -465,6 +465,30 @@ export const en = { "Passthrough models use the Pool or Direct account option selected on Providers. Toggling one off hides it from the Codex picker (the catalog entry is kept, so re-enabling restores it exactly).", "models.active": "{active}/{total} visible", "models.workspace.providers": "Providers", + "models.workspace.description": + "Find a model, check its configuration and choose what appears in your client.", + "models.workspace.manageProviders": "Manage providers", + "models.workspace.providerSettings": "Provider settings", + "models.workspace.search": "Search models or providers…", + "models.workspace.inspect": "Inspect {model}", + "models.workspace.unknown": "Not reported", + "models.workspace.catalog": "Model catalog", + "models.workspace.model": "Model", + "models.workspace.visibility": "In model picker", + "models.workspace.visibilityColumn": "Visible", + "models.workspace.changeVisibility": "Show selected model in picker", + "models.workspace.shown": "Included by your model settings", + "models.workspace.hidden": "Excluded by your model settings", + "models.workspace.noMatches": "No matching models", + "models.workspace.clearSearch": "Clear search", + "models.workspace.advanced": "Model settings & advanced controls", + "models.workspace.back": "Back to models", + "models.workspace.contextLimit": "Configured context limit", + "models.workspace.catalogNotHealth": + "Catalog information, not a live connection test. Model visibility does not verify account access or the model actually served.", + "models.workspace.chooseModel": "Select a model", + "models.workspace.chooseModelHint": + "Compare its context and capabilities, then adjust visibility or edit a custom model.", "models.workspace.allProviders": "All providers", "models.workspace.mainAria": "Model details", "models.combosEmpty": "No combos configured yet", diff --git a/gui/src/i18n/nl.ts b/gui/src/i18n/nl.ts index fd0d01b4..13a5ca74 100644 --- a/gui/src/i18n/nl.ts +++ b/gui/src/i18n/nl.ts @@ -214,6 +214,39 @@ export const nlOverrides: Partial> = { "models.subtitle": "Zet aan welke modellen Codex ziet. Verborgen blijven uit de picker, maar blijven aanroepbaar op exact id.", "models.workspace.providers": "Leveranciers", + "models.active": "{active}/{total} zichtbaar", + "models.collapseAll": "Alles inklappen", + "models.expandAll": "Alles uitklappen", + "models.tipProvider": "Leverancier", + "models.tipContext": "Context", + "models.tipModalities": "Modaliteiten", + "models.customEdit": "Bewerken", + "models.customDelete": "Verwijderen", + "models.customDeleteConfirm": "Model {name} verwijderen?", + "models.workspace.description": + "Zoek een model, bekijk de configuratie en kies wat je client toont.", + "models.workspace.manageProviders": "Leveranciers beheren", + "models.workspace.providerSettings": "Providerinstellingen", + "models.workspace.search": "Zoek modellen of leveranciers…", + "models.workspace.inspect": "Bekijk {model}", + "models.workspace.unknown": "Niet opgegeven", + "models.workspace.catalog": "Modelcatalogus", + "models.workspace.model": "Model", + "models.workspace.visibility": "In modelkiezer", + "models.workspace.visibilityColumn": "Zichtbaar", + "models.workspace.changeVisibility": "Geselecteerd model in kiezer tonen", + "models.workspace.shown": "Opgenomen in je modelinstellingen", + "models.workspace.hidden": "Uitgesloten in je modelinstellingen", + "models.workspace.noMatches": "Geen passende modellen", + "models.workspace.clearSearch": "Zoekopdracht wissen", + "models.workspace.advanced": "Modelinstellingen en geavanceerde opties", + "models.workspace.back": "Terug naar modellen", + "models.workspace.contextLimit": "Ingestelde contextlimiet", + "models.workspace.catalogNotHealth": + "Catalogusinformatie, geen live verbindingstest. Zichtbaarheid bewijst geen accounttoegang of welk model daadwerkelijk is geserveerd.", + "models.workspace.chooseModel": "Selecteer een model", + "models.workspace.chooseModelHint": + "Vergelijk context en mogelijkheden, pas de zichtbaarheid aan of bewerk een eigen model.", "models.workspace.allProviders": "Alle leveranciers", "models.combosEmpty": "Nog geen combos", "models.combosSetup": "Instellen", diff --git a/gui/src/main.tsx b/gui/src/main.tsx index 4ca89633..bc7c63a6 100644 --- a/gui/src/main.tsx +++ b/gui/src/main.tsx @@ -12,6 +12,7 @@ import "./styles/app-base.css"; import "./landing/landing.css"; import "@fontsource-variable/manrope"; import "./styles/workspace-orbit.css"; +import "./styles/model-catalog.css"; import "./styles/primitives.css"; initPostHog(); diff --git a/gui/src/pages/ModelInspector.tsx b/gui/src/pages/ModelInspector.tsx new file mode 100644 index 00000000..d0d8a3bb --- /dev/null +++ b/gui/src/pages/ModelInspector.tsx @@ -0,0 +1,163 @@ +import { useEffect, useRef } from "react"; +import { useT } from "../i18n/shared"; +import { IconBoxes, IconChevron } from "../icons"; +import { Switch } from "../ui"; +import { modelLabel } from "../model-display"; +import type { ProviderModelGroup } from "../models-groups"; +import { discoveryFailureLabel, fmtK, type ModelRow } from "./models-shared"; + +export default function ModelInspector({ + model, + group, + visible, + busy, + onClose, + onToggle, + onEdit, + onDelete, +}: { + model: ModelRow | null; + group?: ProviderModelGroup; + visible: boolean; + busy: boolean; + onClose: () => void; + onToggle: () => void; + onEdit: () => void; + onDelete: () => void; +}) { + const t = useT(); + const headingRef = useRef(null); + useEffect(() => { + headingRef.current?.focus({ preventScroll: true }); + }, [model?.namespaced]); + + return ( + + ); +} diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 4b470a7b..3c25c380 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -7,7 +7,10 @@ import type { TFn, TKey } from "../i18n/shared"; import { modelLabel } from "../model-display"; import { type ComboItem, parseComboList } from "../combo-workspace-data"; import { readJsonIfOk, readJsonOrThrow } from "../fetch-json"; -import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; +import { + readSessionListCache, + writeSessionListCache, +} from "../session-list-cache"; import { buildProviderModelGroups, type ConfiguredProviderSummary, @@ -43,6 +46,7 @@ import { type V2Status, } from "./models-shared"; import { EmptyProviderHint } from "./models-provider-hints"; +import ModelInspector from "./ModelInspector"; import { refreshProviderModels } from "../provider-workspace/refresh-models"; type CachedModelsPage = { @@ -60,18 +64,29 @@ export default function Models({ apiBase }: { apiBase: string }) { const cached = readSessionListCache(cacheKey); const hasCacheRef = useRef(Boolean(cached)); const [models, setModels] = useState(() => cached?.models ?? []); - const [providers, setProviders] = useState(() => cached?.providers ?? []); - const [disabled, setDisabled] = useState>(() => new Set(cached?.disabled ?? [])); - const [selectedModels, setSelectedModels] = useState(() => cached?.selectedModels ?? null); - const [search, setSearch] = useState>({}); + const [providers, setProviders] = useState( + () => cached?.providers ?? [], + ); + const [disabled, setDisabled] = useState>( + () => new Set(cached?.disabled ?? []), + ); + const [selectedModels, setSelectedModels] = useState( + () => cached?.selectedModels ?? null, + ); + const [catalogQuery, setCatalogQuery] = useState(""); const [limit, setLimit] = useState>({}); - const [contextCaps, setContextCaps] = useState>(() => cached?.contextCaps ?? {}); - const [contextCapValue, setContextCapValue] = useState(() => cached?.contextCapValue ?? 350_000); + const [contextCaps, setContextCaps] = useState>( + () => cached?.contextCaps ?? {}, + ); + const [contextCapValue, setContextCapValue] = useState( + () => cached?.contextCapValue ?? 350_000, + ); const [customCap, setCustomCap] = useState(""); const [showCustom, setShowCustom] = useState(false); const initialCollapsed = readCollapsedProviders(); - const [collapsed, setCollapsed] = useState>(() => initialCollapsed ?? new Set()); - const needsDefaultCollapseRef = useRef(initialCollapsed === null); + const [collapsed, setCollapsed] = useState>( + () => initialCollapsed ?? new Set(), + ); const [status, setStatus] = useState(""); const [ok, setOk] = useState(false); const [loading, setLoading] = useState(() => !cached); @@ -96,11 +111,21 @@ export default function Models({ apiBase }: { apiBase: string }) { const [customFormDisplayName, setCustomFormDisplayName] = useState(""); const [customFormContextWindow, setCustomFormContextWindow] = useState(""); const [customFormShowCustomCtx, setCustomFormShowCustomCtx] = useState(false); - const [customFormModalities, setCustomFormModalities] = useState(["text"]); + const [customFormModalities, setCustomFormModalities] = useState([ + "text", + ]); const [customSaving, setCustomSaving] = useState(false); const [customError, setCustomError] = useState(""); - const [hoveredModel, setHoveredModel] = useState<{ namespaced: string; rect: DOMRect } | null>(null); - const hoverTimerRef = useRef | null>(null); + const [selectedModelName, updateSelectedModelName] = useState( + null, + ); + const selectedModelNameRef = useRef(null); + const setSelectedModelName = useCallback((name: string | null) => { + selectedModelNameRef.current = name; + updateSelectedModelName(name); + }, []); + const selectedRowRef = useRef(null); + const catalogSearchRef = useRef(null); const [shadowCall, setShadowCall] = useState(null); const [shadowCallSaving, setShadowCallSaving] = useState(false); // Combo summary section. null = loading or failed (section hidden on failure — @@ -134,13 +159,11 @@ export default function Models({ apiBase }: { apiBase: string }) { } } })(); - return () => { cancelled = true; }; + return () => { + cancelled = true; + }; }, [apiBase]); - useEffect(() => () => { - if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current); - }, []); - const shadowModelOptions = useMemo( () => activeModelOptions(models, disabled, selectedModels ?? {}), [models, disabled, selectedModels], @@ -151,7 +174,9 @@ export default function Models({ apiBase }: { apiBase: string }) { const r = await fetch(`${apiBase}/api/shadow-call-settings`); const data = await readJsonIfOk(r); if (data) setShadowCall(data); - } catch { /* old server / network: keep the section disabled */ } + } catch { + /* old server / network: keep the section disabled */ + } }, [apiBase]); const loadV2 = useCallback(async () => { @@ -159,82 +184,128 @@ export default function Models({ apiBase }: { apiBase: string }) { if (v2BusyRef.current) return; try { const r = await fetch(`${apiBase}/api/v2`); - if (!(r.headers.get("content-type") ?? "").includes("application/json")) { setV2(null); return; } + if (!(r.headers.get("content-type") ?? "").includes("application/json")) { + setV2(null); + return; + } const data = await readJsonIfOk(r); - if (!data || typeof data.enabled !== "boolean") { setV2(null); return; } + if (!data || typeof data.enabled !== "boolean") { + setV2(null); + return; + } setV2({ enabled: data.enabled, agentsMaxThreadsConflict: data.agentsMaxThreadsConflict === true, - maxConcurrentThreadsPerSession: typeof data.maxConcurrentThreadsPerSession === "number" ? data.maxConcurrentThreadsPerSession : null, - multiAgentMode: data.multiAgentMode === "v1" || data.multiAgentMode === "v2" ? data.multiAgentMode : "default", + maxConcurrentThreadsPerSession: + typeof data.maxConcurrentThreadsPerSession === "number" + ? data.maxConcurrentThreadsPerSession + : null, + multiAgentMode: + data.multiAgentMode === "v1" || data.multiAgentMode === "v2" + ? data.multiAgentMode + : "default", }); } catch { setV2(null); // old server / network: hide the section instead of guessing } }, [apiBase]); - const load = useCallback(async (force = false): Promise => { - if (loadPendingRef.current && !force) return false; - loadPendingRef.current = true; - const generation = ++loadGenerationRef.current; - // Soft refresh: keep last-good catalog painted while revalidating. - if (!hasCacheRef.current) setLoading(true); - try { - const [modelsRes, capsRes, providersRes, selectionData] = await Promise.all([ - fetch(`${apiBase}/api/models`), - fetch(`${apiBase}/api/provider-context-caps`), - fetch(`${apiBase}/api/providers`), - fetchSelectedModels(apiBase), - ]); - const [data, capsData, providerData] = await Promise.all([ - readJsonOrThrow(modelsRes), - readJsonOrThrow(capsRes), - readJsonOrThrow(providersRes), - ]); - if (data === undefined || capsData === undefined || providerData === undefined) { - throw new Error("models payload missing"); - } - if (!shouldApplyLoadGeneration(generation, loadGenerationRef.current)) return false; - const nextGroups = buildProviderModelGroups(data, providerData); - setSelectedProvider(prev => ( - prev !== null && !nextGroups.some(group => group.provider === prev) - ? null - : prev - )); - const nextDisabled = collectDisabledNamespaced(data); - const value = typeof capsData.value === "number" && Number.isFinite(capsData.value) && capsData.value > 0 - ? capsData.value - : (typeof capsData.cap === "number" && Number.isFinite(capsData.cap) && capsData.cap > 0 ? capsData.cap : undefined); - const nextCapValue = value !== undefined ? value : 350_000; - const nextCaps = capsData.caps ?? {}; - setModels(data); - setProviders(providerData); - setDisabled(nextDisabled); - setSelectedModels(selectionData); - if (value !== undefined) setContextCapValue(value); - setContextCaps(nextCaps); - hasCacheRef.current = true; - writeSessionListCache(cacheKey, { - models: data, - providers: providerData, - selectedModels: selectionData, - disabled: [...nextDisabled], - contextCaps: nextCaps, - contextCapValue: nextCapValue, - } satisfies CachedModelsPage); - return true; - } catch { - if (shouldApplyLoadGeneration(generation, loadGenerationRef.current) && !hasCacheRef.current) { - setOk(false); setStatus(t("models.loadFail")); - } - return false; - } finally { - if (shouldApplyLoadGeneration(generation, loadGenerationRef.current)) { - loadPendingRef.current = false; - setLoading(false); + const load = useCallback( + async (force = false): Promise => { + if (loadPendingRef.current && !force) return false; + loadPendingRef.current = true; + const generation = ++loadGenerationRef.current; + // Soft refresh: keep last-good catalog painted while revalidating. + if (!hasCacheRef.current) setLoading(true); + try { + const [modelsRes, capsRes, providersRes, selectionData] = + await Promise.all([ + fetch(`${apiBase}/api/models`), + fetch(`${apiBase}/api/provider-context-caps`), + fetch(`${apiBase}/api/providers`), + fetchSelectedModels(apiBase), + ]); + const [data, capsData, providerData] = await Promise.all([ + readJsonOrThrow(modelsRes), + readJsonOrThrow(capsRes), + readJsonOrThrow(providersRes), + ]); + if ( + data === undefined || + capsData === undefined || + providerData === undefined + ) { + throw new Error("models payload missing"); + } + if (!shouldApplyLoadGeneration(generation, loadGenerationRef.current)) + return false; + const nextGroups = buildProviderModelGroups(data, providerData); + const selectedName = selectedModelNameRef.current; + if ( + selectedName && + !nextGroups.some((group) => + group.rows.some((model) => model.namespaced === selectedName), + ) + ) { + const restoreFocus = Boolean( + document.activeElement?.closest("#model-inspector"), + ); + setSelectedModelName(null); + if (restoreFocus) + requestAnimationFrame(() => catalogSearchRef.current?.focus()); + } + setSelectedProvider((prev) => + prev !== null && !nextGroups.some((group) => group.provider === prev) + ? null + : prev, + ); + const nextDisabled = collectDisabledNamespaced(data); + const value = + typeof capsData.value === "number" && + Number.isFinite(capsData.value) && + capsData.value > 0 + ? capsData.value + : typeof capsData.cap === "number" && + Number.isFinite(capsData.cap) && + capsData.cap > 0 + ? capsData.cap + : undefined; + const nextCapValue = value !== undefined ? value : 350_000; + const nextCaps = capsData.caps ?? {}; + setModels(data); + setProviders(providerData); + setDisabled(nextDisabled); + setSelectedModels(selectionData); + if (value !== undefined) setContextCapValue(value); + setContextCaps(nextCaps); + hasCacheRef.current = true; + writeSessionListCache(cacheKey, { + models: data, + providers: providerData, + selectedModels: selectionData, + disabled: [...nextDisabled], + contextCaps: nextCaps, + contextCapValue: nextCapValue, + } satisfies CachedModelsPage); + return true; + } catch { + if ( + shouldApplyLoadGeneration(generation, loadGenerationRef.current) && + !hasCacheRef.current + ) { + setOk(false); + setStatus(t("models.loadFail")); + } + return false; + } finally { + if (shouldApplyLoadGeneration(generation, loadGenerationRef.current)) { + loadPendingRef.current = false; + setLoading(false); + } } - } - }, [apiBase, cacheKey, t]); + }, + [apiBase, cacheKey, setSelectedModelName, t], + ); // Shadow/v2 controls must not wait on the models catalog (live discovery can be slow). useEffect(() => { @@ -274,29 +345,17 @@ export default function Models({ apiBase }: { apiBase: string }) { [models, providers], ); - // One-shot default collapse. It stays an effect on `groups` so CACHED groups collapse - // immediately on first paint, even when revalidation is slow or fails; moving it into - // the load() success path would render cached providers expanded and leave them - // expanded whenever the refresh errors. - useEffect(() => { - if (!needsDefaultCollapseRef.current) return; - if (groups.length === 0) return; - needsDefaultCollapseRef.current = false; - const all = new Set(groups.map(group => group.provider)); - // eslint-disable-next-line react-hooks/set-state-in-effect - setCollapsed(all); - writeCollapsedProviders(all); - }, [groups]); - const effectiveVisibleCount = useMemo(() => { if (!selectedModels) return 0; - return models.filter(model => modelVisible( - selectedModels, - model.provider, - model.id, - model.native === true, - disabled.has(model.namespaced), - )).length; + return models.filter((model) => + modelVisible( + selectedModels, + model.provider, + model.id, + model.native === true, + disabled.has(model.namespaced), + ), + ).length; }, [disabled, models, selectedModels]); const applyVisibility = async ( @@ -311,7 +370,13 @@ export default function Models({ apiBase }: { apiBase: string }) { setStatus(""); let errorKey: "models.saveFailed" | "models.networkError" | null = null; try { - const response = await putModelVisibility(apiBase, scope, provider, targets, enabled); + const response = await putModelVisibility( + apiBase, + scope, + provider, + targets, + enabled, + ); if (!response.ok) errorKey = "models.saveFailed"; } catch { errorKey = "models.networkError"; @@ -341,7 +406,10 @@ export default function Models({ apiBase }: { apiBase: string }) { body: JSON.stringify({ provider, enabled }), }); try { - const data = await readJsonOrThrow(r, t("models.capSaveFailed")); + const data = await readJsonOrThrow( + r, + t("models.capSaveFailed"), + ); setContextCaps(data?.caps ?? {}); setOk(true); setStatus(t("models.capApplied")); @@ -351,23 +419,27 @@ export default function Models({ apiBase }: { apiBase: string }) { setStatus(e instanceof Error ? e.message : t("models.capSaveFailed")); } } catch { - setOk(false); setStatus(t("models.networkError")); + setOk(false); + setStatus(t("models.networkError")); } finally { setBusy(false); busyRef.current = false; } }; const toggleCollapse = (p: string) => { - setCollapsed(prev => { + setCollapsed((prev) => { const n = new Set(prev); - if (n.has(p)) n.delete(p); else n.add(p); + if (n.has(p)) n.delete(p); + else n.add(p); writeCollapsedProviders(n); return n; }); }; const setAllCollapsed = (collapse: boolean) => { setCollapsed(() => { - const n = collapse ? new Set(groups.map(group => group.provider)) : new Set(); + const n = collapse + ? new Set(groups.map((group) => group.provider)) + : new Set(); writeCollapsedProviders(n); return n; }); @@ -384,8 +456,16 @@ export default function Models({ apiBase }: { apiBase: string }) { body: JSON.stringify(body), }); try { - const data = await readJsonOrThrow(r, t("models.capSaveFailed")); - if (typeof data?.value === "number" && Number.isFinite(data.value) && data.value > 0) setContextCapValue(data.value); + const data = await readJsonOrThrow( + r, + t("models.capSaveFailed"), + ); + if ( + typeof data?.value === "number" && + Number.isFinite(data.value) && + data.value > 0 + ) + setContextCapValue(data.value); setContextCaps(data?.caps ?? {}); setOk(true); setStatus(t("models.capApplied")); @@ -395,7 +475,8 @@ export default function Models({ apiBase }: { apiBase: string }) { setStatus(e instanceof Error ? e.message : t("models.capSaveFailed")); } } catch { - setOk(false); setStatus(t("models.networkError")); + setOk(false); + setStatus(t("models.networkError")); } finally { setBusy(false); busyRef.current = false; @@ -408,28 +489,41 @@ export default function Models({ apiBase }: { apiBase: string }) { }; const onSelectCap = (raw: string) => { - if (raw === CUSTOM_OPTION) { setShowCustom(true); setCustomCap(String(contextCapValue)); return; } + if (raw === CUSTOM_OPTION) { + setShowCustom(true); + setCustomCap(String(contextCapValue)); + return; + } setShowCustom(false); const value = Number(raw); - if (Number.isFinite(value) && value > 0 && value !== contextCapValue) setGlobalCap(value); + if (Number.isFinite(value) && value > 0 && value !== contextCapValue) + setGlobalCap(value); }; const applyCustomCap = () => { const value = Number(customCap.replace(/[_,\s]/g, "")); - if (!Number.isFinite(value) || value <= 0) { setOk(false); setStatus(t("models.capSaveFailed")); return; } + if (!Number.isFinite(value) || value <= 0) { + setOk(false); + setStatus(t("models.capSaveFailed")); + return; + } setShowCustom(false); setGlobalCap(value); }; - const allCapped = useMemo( - () => { - // Cap aggregate counts routed providers only; the single native group has no cap switch. - const routed = groups.filter(group => !group.native && group.rows.length > 0); - return routed.length > 0 && routed.every(group => contextCaps[group.provider] === contextCapValue); - }, - [groups, contextCaps, contextCapValue], - ); - const setAll = () => { void putCap({ setAll: !allCapped }); }; + const allCapped = useMemo(() => { + // Cap aggregate counts routed providers only; the single native group has no cap switch. + const routed = groups.filter( + (group) => !group.native && group.rows.length > 0, + ); + return ( + routed.length > 0 && + routed.every((group) => contextCaps[group.provider] === contextCapValue) + ); + }, [groups, contextCaps, contextCapValue]); + const setAll = () => { + void putCap({ setAll: !allCapped }); + }; const fetchGroupModels = async (provider: string) => { if (fetchingProvider || busy) return; @@ -492,7 +586,10 @@ export default function Models({ apiBase }: { apiBase: string }) { body: JSON.stringify({ multiAgentMode: mode }), }); try { - const data = await readJsonOrThrow(r, t("models.saveFailed")); + const data = await readJsonOrThrow( + r, + t("models.saveFailed"), + ); void loadV2(); setOk(true); setStatus(t("models.v2Applied")); @@ -502,7 +599,8 @@ export default function Models({ apiBase }: { apiBase: string }) { setStatus(e instanceof Error ? e.message : t("models.saveFailed")); } } catch { - setOk(false); setStatus(t("models.networkError")); + setOk(false); + setStatus(t("models.networkError")); } finally { setV2Busy(false); v2BusyRef.current = false; @@ -514,7 +612,11 @@ export default function Models({ apiBase }: { apiBase: string }) { // (setMaxConcurrentThreads no-ops on equal value), so a re-selected current // value or a double click can never double-write config.toml. if (!v2 || v2BusyRef.current) return; - if (!Number.isInteger(value) || value < 1) { setOk(false); setStatus(t("models.v2ThreadsInvalid")); return; } + if (!Number.isInteger(value) || value < 1) { + setOk(false); + setStatus(t("models.v2ThreadsInvalid")); + return; + } if (v2.maxConcurrentThreadsPerSession === value) return; setV2Busy(true); v2BusyRef.current = true; @@ -527,7 +629,10 @@ export default function Models({ apiBase }: { apiBase: string }) { body: JSON.stringify({ maxConcurrentThreadsPerSession: value }), }); try { - const data = await readJsonOrThrow(r, t("models.saveFailed")); + const data = await readJsonOrThrow( + r, + t("models.saveFailed"), + ); if (!data || typeof data.enabled !== "boolean") { setOk(false); setStatus(t("models.saveFailed")); @@ -536,8 +641,14 @@ export default function Models({ apiBase }: { apiBase: string }) { setV2({ enabled: data.enabled, agentsMaxThreadsConflict: data.agentsMaxThreadsConflict === true, - maxConcurrentThreadsPerSession: typeof data.maxConcurrentThreadsPerSession === "number" ? data.maxConcurrentThreadsPerSession : null, - multiAgentMode: data.multiAgentMode === "v1" || data.multiAgentMode === "v2" ? data.multiAgentMode : "default", + maxConcurrentThreadsPerSession: + typeof data.maxConcurrentThreadsPerSession === "number" + ? data.maxConcurrentThreadsPerSession + : null, + multiAgentMode: + data.multiAgentMode === "v1" || data.multiAgentMode === "v2" + ? data.multiAgentMode + : "default", }); setOk(true); setStatus(t("models.v2ThreadsApplied")); @@ -547,7 +658,8 @@ export default function Models({ apiBase }: { apiBase: string }) { setStatus(e instanceof Error ? e.message : t("models.saveFailed")); } } catch { - setOk(false); setStatus(t("models.networkError")); + setOk(false); + setStatus(t("models.networkError")); } finally { setV2Busy(false); v2BusyRef.current = false; @@ -555,32 +667,15 @@ export default function Models({ apiBase }: { apiBase: string }) { }; const onSelectThreads = (raw: string) => { - if (raw === CUSTOM_OPTION) { setShowThreadsCustom(true); setThreadsCustom(String(v2?.maxConcurrentThreadsPerSession ?? "")); return; } + if (raw === CUSTOM_OPTION) { + setShowThreadsCustom(true); + setThreadsCustom(String(v2?.maxConcurrentThreadsPerSession ?? "")); + return; + } setShowThreadsCustom(false); void putV2Threads(Number(raw)); }; - const onRowEnter = (namespaced: string, el: HTMLElement) => { - if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current); - hoverTimerRef.current = setTimeout(() => { - setHoveredModel({ namespaced, rect: el.getBoundingClientRect() }); - }, 300); - }; - - const onRowFocus = (namespaced: string, el: HTMLElement) => { - if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current); - setHoveredModel({ namespaced, rect: el.getBoundingClientRect() }); - }; - - const onRowLeave = () => { - if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current); - hoverTimerRef.current = setTimeout(() => setHoveredModel(null), 120); - }; - - const keepRowTipOpen = () => { - if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current); - }; - const addCustomModel = async ( provider: string, modelId: string, @@ -594,7 +689,13 @@ export default function Models({ apiBase }: { apiBase: string }) { const r = await fetch(`${apiBase}/api/custom-models`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ provider, modelId, displayName, contextWindow, inputModalities }), + body: JSON.stringify({ + provider, + modelId, + displayName, + contextWindow, + inputModalities, + }), }); try { await readJsonOrThrow(r, t("models.customSaveFailed")); @@ -603,7 +704,9 @@ export default function Models({ apiBase }: { apiBase: string }) { setStatus(t("models.customAdded")); await load(true); } catch (e) { - setCustomError(e instanceof Error ? e.message : t("models.customSaveFailed")); + setCustomError( + e instanceof Error ? e.message : t("models.customSaveFailed"), + ); } } catch { setCustomError(t("models.networkError")); @@ -612,15 +715,21 @@ export default function Models({ apiBase }: { apiBase: string }) { } }; - const updateCustomModel = async (id: string, patch: Record) => { + const updateCustomModel = async ( + id: string, + patch: Record, + ) => { setCustomSaving(true); setCustomError(""); try { - const r = await fetch(`${apiBase}/api/custom-models/${encodeURIComponent(id)}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(patch), - }); + const r = await fetch( + `${apiBase}/api/custom-models/${encodeURIComponent(id)}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }, + ); try { await readJsonOrThrow(r, t("models.customSaveFailed")); setCustomModalOpen(false); @@ -628,7 +737,9 @@ export default function Models({ apiBase }: { apiBase: string }) { setStatus(t("models.customUpdated")); await load(true); } catch (e) { - setCustomError(e instanceof Error ? e.message : t("models.customSaveFailed")); + setCustomError( + e instanceof Error ? e.message : t("models.customSaveFailed"), + ); } } catch { setCustomError(t("models.networkError")); @@ -639,7 +750,10 @@ export default function Models({ apiBase }: { apiBase: string }) { const deleteCustomModel = async (id: string) => { try { - const r = await fetch(`${apiBase}/api/custom-models/${encodeURIComponent(id)}`, { method: "DELETE" }); + const r = await fetch( + `${apiBase}/api/custom-models/${encodeURIComponent(id)}`, + { method: "DELETE" }, + ); if (r.ok) { setOk(true); setStatus(t("models.customDeleted")); @@ -659,16 +773,62 @@ export default function Models({ apiBase }: { apiBase: string }) { return ( <>
-
- {t("models.shadowCallIntercept")} - {t("models.shadowCallOriginal")} - void saveShadowCall({ enabled: !shadowCall?.enabled })} disabled={!shadowCall || shadowCallSaving} label={t("models.shadowCallIntercept")} /> +
+ + {t("models.shadowCallIntercept")}{" "} + + + ⓘ + + + + + {t("models.shadowCallOriginal")} + + + void saveShadowCall({ enabled: !shadowCall?.enabled }) + } + disabled={!shadowCall || shadowCallSaving} + label={t("models.shadowCallIntercept")} + />
- { + setShadowCall((c) => (c ? { ...c, model: v } : c)); + void saveShadowCall({ model: v }); + }} + disabled={ + !shadowCall || shadowCallSaving || !shadowCall.enabled + } + label={t("models.shadowCallIntercept")} + />
-
{t("models.loading")}
+
+ {" "} + {t("models.loading")} +
); } @@ -676,287 +836,366 @@ export default function Models({ apiBase }: { apiBase: string }) { return {t("models.loadFail")}; } - const renderGroup = (group: ProviderModelGroup) => { - const { provider, rows, native, liveModels, discovery, clientHideReason, clientHideReasonLabel, clientHidden } = group; - const isCollapsed = collapsed.has(provider); + const { + provider, + rows, + native, + liveModels, + discovery, + clientHideReason, + clientHideReasonLabel, + clientHidden, + } = group; + const isCollapsed = !catalogQuery.trim() && collapsed.has(provider); // Final visibility, not just the disable flag: a model is visible to Codex only when the // provider allowlist admits it AND it is not disabled. Reading `disabled` alone made the // switches disagree with what the picker actually offers. - const isVisible = (model: ModelRow) => modelVisible( - selectedModels, - provider, - model.id, - model.native === true, - disabled.has(model.namespaced), - ); + const isVisible = (model: ModelRow) => + modelVisible( + selectedModels, + provider, + model.id, + model.native === true, + disabled.has(model.namespaced), + ); const activeCount = rows.filter(isVisible).length; const capOn = contextCaps[provider] === contextCapValue; const isNative = native; - const configured = providers.find(row => row.name === provider); - const canFetchModels = !isNative && configured?.authMode !== "forward" && configured?.disabled !== true; - const discoveryFailure = liveModels && discovery?.status === "failed" ? discovery : undefined; - const q = (search[provider] ?? "").trim().toLowerCase(); - const filtered = q ? rows.filter(m => m.id.toLowerCase().includes(q)) : rows; + const configured = providers.find((row) => row.name === provider); + const canFetchModels = + !isNative && + configured?.authMode !== "forward" && + configured?.disabled !== true; + const discoveryFailure = + liveModels && discovery?.status === "failed" ? discovery : undefined; + const q = catalogQuery.trim().toLowerCase(); + const filtered = q + ? rows.filter((m) => + `${m.id} ${m.displayName ?? ""} ${provider}` + .toLowerCase() + .includes(q), + ) + : rows; + if (q && filtered.length === 0) return null; // Display-only: enabled models float to the top of each provider group so they // stay findable in long lists. The sort is stable, so the server order is kept // inside each partition, and this does not affect the picker order above // (visibility toggles still only filter). - const sorted = filtered.toSorted((a, b) => Number(!isVisible(a)) - Number(!isVisible(b))); + const sorted = filtered.toSorted( + (a, b) => Number(!isVisible(a)) - Number(!isVisible(b)), + ); const shown = limit[provider] ?? PAGE; const visible = sorted.slice(0, shown); const remaining = filtered.length - visible.length; - // An empty provider has nothing to send: keep both bulk buttons inert so we never PUT an - // empty target list (the management API rejects it with 400). - const hasRows = rows.length > 0; - const allOn = !hasRows || rows.every(isVisible); - const allOff = !hasRows || rows.every(m => !isVisible(m)); - const bulkToggle = (enable: boolean) => { - if (!hasRows) return; - void applyVisibility( - "provider", - provider, - rows.map(m => ({ id: m.id, native: m.native === true })), - enable, - ); - }; + // An empty provider has nothing to send: keep both bulk buttons inert so we never PUT an + // empty target list (the management API rejects it with 400). + const hasRows = rows.length > 0; + const allOn = !hasRows || rows.every(isVisible); + const allOff = !hasRows || rows.every((m) => !isVisible(m)); + const bulkToggle = (enable: boolean) => { + if (!hasRows) return; + void applyVisibility( + "provider", + provider, + rows.map((m) => ({ id: m.id, native: m.native === true })), + enable, + ); + }; return ( -
-
+
+
-
- {canFetchModels && ( - - )} - {!isNative && ( - - )} - - - {!isNative && <> - toggleProviderCap(provider)} disabled={busy} label={t("models.capValue", { value: fmtK(contextCapValue) })} /> - {t("models.capValue", { value: fmtK(contextCapValue) })} - } -
+
+ {t("models.workspace.providerSettings")} +
+ {canFetchModels && ( + + )} + {!isNative && ( + + )} + + + {!isNative && ( + <> + toggleProviderCap(provider)} + disabled={busy} + label={t("models.capValue", { + value: fmtK(contextCapValue), + })} + /> + + {t("models.capValue", { value: fmtK(contextCapValue) })} + + + )} +
+
{!isCollapsed && (
- {isNative &&

{t("models.nativeHint")}

} - {rows.length === 0 && ( - + {isNative && ( +

+ {t("models.nativeHint")} +

)} - {rows.length > PAGE / 2 && ( - setSearch(prev => ({ ...prev, [provider]: e.target.value }))} - aria-label={t("models.search")} + {rows.length === 0 && ( + )} - {visible.map(m => { - // The row reflects the same final-visibility answer as the count and the picker. - const off = !isVisible(m); - return ( -
onRowEnter(m.namespaced, e.currentTarget)} - onMouseLeave={onRowLeave} - onFocus={(e) => onRowFocus(m.namespaced, e.currentTarget)} - onBlur={(e) => { - if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setHoveredModel(null); - }} - > -
- void applyVisibility("models", provider, [{ id: m.id, native: m.native === true }], off)} disabled={busy} label={m.native ? m.id : m.namespaced} /> - {m.native ? modelLabel(m.id) : m.namespaced} - {m.custom && ( - - {t("models.customBadge")} - - )} - {m.contextCapped && {t("models.contextCappedValue", { value: fmtK(m.contextCap ?? contextCapValue) })}} -
- {hoveredModel?.namespaced === m.namespaced && (() => { - const r = hoveredModel.rect; - const tipTop = r.bottom + 4; - const flipUp = tipTop + 360 > window.innerHeight; - return ( -
-
{m.native ? m.id : m.namespaced}
- {m.displayName &&
{m.displayName}
} - {m.custom && ( - - {t("models.customBadge")} - - )} -
- {t("models.tipProvider")} - {m.provider} - {(m.contextWindow || m.contextCap) && ( - <> - {t("models.tipContext")} - {fmtK(m.contextWindow ?? m.contextCap ?? 0)} - - )} - {m.inputModalities && m.inputModalities.length > 0 && ( - <> - {t("models.tipModalities")} - {m.inputModalities.join(", ")} - - )} - {t("models.tipStatus")} - {off ? t("models.tipDisabled") : t("models.tipActive")} -
- {m.custom && m.customId && ( -
- - -
- )} -
- ); - })()} -
- ); - })} - {remaining > 0 && ( - - )} -
- )} -
- ); + {visible.map((m) => { + const off = !isVisible(m); + return ( +
+ + + void applyVisibility( + "models", + provider, + [{ id: m.id, native: m.native === true }], + off, + ) + } + disabled={busy} + label={m.native ? m.id : m.namespaced} + /> +
+ ); + })} + {remaining > 0 && ( + + )} +
+ )} +
+ ); }; const visibleGroups = selectedProvider - ? groups.filter(group => group.provider === selectedProvider) + ? groups.filter((group) => group.provider === selectedProvider) : groups; const controlsBlock = ( <>
-
- {t("models.shadowCallIntercept")} - {t("models.shadowCallOriginal")} - void saveShadowCall({ enabled: !shadowCall?.enabled })} disabled={!shadowCall || shadowCallSaving} label={t("models.shadowCallIntercept")} /> +
+ + {t("models.shadowCallIntercept")}{" "} + + + ⓘ + + + + + {t("models.shadowCallOriginal")} + + + void saveShadowCall({ enabled: !shadowCall?.enabled }) + } + disabled={!shadowCall || shadowCallSaving} + label={t("models.shadowCallIntercept")} + />
- { + setShadowCall((c) => (c ? { ...c, model: v } : c)); + void saveShadowCall({ model: v }); + }} + disabled={!shadowCall || shadowCallSaving || !shadowCall.enabled} + label={t("models.shadowCallIntercept")} + />
{v2 && (
{t("models.v2Label")} -
- {(["v1", "default", "v2"] as const).map(mode => ( +
+ {(["v1", "default", "v2"] as const).map((mode) => ( + )} - - {t("models.setAllHint", { value: fmtK(contextCapValue) })} + + + {t("models.setAllHint", { value: fmtK(contextCapValue) })} +
{(() => { - const customCount = models.filter(m => m.custom).length; + const customCount = models.filter((m) => m.custom).length; if (customCount === 0) return null; return (
@@ -1084,60 +1396,122 @@ export default function Models({ apiBase }: { apiBase: string }) { const combosBlock = ( <> - {combos !== null && !combosError && combos.length === 0 && ( - - )} - {combos !== null && !combosError && combos.length > 0 && ( -
- - {combosOpen && ( -
- {combos.map(c => ( -
- {c.model} - {c.strategy} · {c.targets.length} -
- ))} - - + {t("models.combosAdd")} - -
- )} -
- )} + {combos !== null && !combosError && combos.length === 0 && ( +
+
+
+
+ + {t("models.combosSetup")} + +
+
+ )} + {combos !== null && !combosError && combos.length > 0 && ( +
+
+ + + {t("models.combosSetup")} + +
+ {combosOpen && ( +
+ {combos.map((c) => ( +
+ {c.model} + + {c.strategy} · {c.targets.length} + +
+ ))} + + + {t("models.combosAdd")} + +
+ )} +
+ )} ); const collapseControls = (
- -
); @@ -1155,22 +1529,53 @@ export default function Models({ apiBase }: { apiBase: string }) { const modalsBlock = ( <> {v2HelpOpen && ( -
setV2HelpOpen(false)} onKeyDown={e => { if (e.key === "Escape") setV2HelpOpen(false); }}> -
e.stopPropagation()}> +
setV2HelpOpen(false)} + onKeyDown={(e) => { + if (e.key === "Escape") setV2HelpOpen(false); + }} + > +
e.stopPropagation()}>

{t("models.v2Label")}

- +
-
+
{t("models.v2Help")}
- +
@@ -1182,17 +1587,23 @@ export default function Models({ apiBase }: { apiBase: string }) { role="dialog" aria-modal="true" aria-label={t("models.customAdd")} - onClick={() => { if (!customSaving) setCustomModalOpen(false); }} + onClick={() => { + if (!customSaving) setCustomModalOpen(false); + }} onKeyDown={(e) => { if (e.key === "Escape" && !customSaving) setCustomModalOpen(false); }} > -
e.stopPropagation()}> +
e.stopPropagation()}>

{customModalMode === "add" - ? t("models.customAddTitle", { provider: customModalProvider }) - : t("models.customEditTitle", { provider: customModalProvider })} + ? t("models.customAddTitle", { + provider: customModalProvider, + }) + : t("models.customEditTitle", { + provider: customModalProvider, + })}

+ > + × +
{customError && {customError}} @@ -1211,7 +1624,7 @@ export default function Models({ apiBase }: { apiBase: string }) { setCustomFormModelId(e.target.value)} + onChange={(e) => setCustomFormModelId(e.target.value)} disabled={customSaving} placeholder={t("models.customFieldModelIdPlaceholder")} autoFocus @@ -1223,7 +1636,7 @@ export default function Models({ apiBase }: { apiBase: string }) { setCustomFormDisplayName(e.target.value)} + onChange={(e) => setCustomFormDisplayName(e.target.value)} disabled={customSaving} placeholder={t("models.customFieldDisplayNamePlaceholder")} /> @@ -1233,7 +1646,11 @@ export default function Models({ apiBase }: { apiBase: string }) { {t("models.customFieldContext")}
{ - setCustomFormModalities(prev => ( - e.target.checked ? [...prev, mod] : prev.filter(m => m !== mod) - )); + onChange={(e) => { + setCustomFormModalities((prev) => + e.target.checked + ? [...prev, mod] + : prev.filter((m) => m !== mod), + ); }} disabled={customSaving} /> @@ -1294,7 +1715,12 @@ export default function Models({ apiBase }: { apiBase: string }) {
-
@@ -1335,72 +1768,190 @@ export default function Models({ apiBase }: { apiBase: string }) { ); + const selectedModel = visibleGroups + .flatMap((group) => group.rows) + .find((model) => model.namespaced === selectedModelName); + const selectedGroup = selectedModel + ? groups.find((group) => group.provider === selectedModel.provider) + : undefined; + const query = catalogQuery.trim().toLowerCase(); + const hasMatches = visibleGroups.some((group) => + group.rows.some((model) => + `${model.id} ${model.displayName ?? ""} ${group.provider}` + .toLowerCase() + .includes(query), + ), + ); + const closeInspector = () => { + setSelectedModelName(null); + requestAnimationFrame(() => { + if ( + selectedRowRef.current?.isConnected && + selectedRowRef.current.getClientRects().length + ) + selectedRowRef.current.focus(); + else catalogSearchRef.current?.focus(); + }); + }; + return (
-

{t("nav.models")}

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

{t("nav.models")}

+

+ {t("models.workspace.description")} +

+ + {t("models.workspace.manageProviders")} +
-

{t("models.subtitle")}

{status && {status}} -
-