diff --git a/devlog/_plan/260807_compatibility_lab/001_pr_stack_status.md b/devlog/_plan/260807_compatibility_lab/001_pr_stack_status.md index 2fde00f84..cdd56426b 100644 --- a/devlog/_plan/260807_compatibility_lab/001_pr_stack_status.md +++ b/devlog/_plan/260807_compatibility_lab/001_pr_stack_status.md @@ -23,7 +23,8 @@ independent review, blockers, and whether a later phase is authorized. | CL-01 | `feat/cl-01-conformance-harness` | `c2113ca47b8a05c5a5f90679e4eaa640ca2c6a66` | `22d608c82d82e2746c0cef9cd761db19a8e465ee` | [#1320](https://github.com/lidge-jun/opencodex/pull/1320) | MERGED TO `dev` at `4bb249b756abd468c675d2d92fffe4da95ad3e2a` | | CL-02 | `feat/cl-02-evidence-ledger` | `4bb249b756abd468c675d2d92fffe4da95ad3e2a` | NOT RECORDED | [#1333](https://github.com/lidge-jun/opencodex/pull/1333) | MERGED TO `dev` at `025c37916225dd685d9217e5b40190600f06d278`; POST-MERGE HARDENING [#1343](https://github.com/lidge-jun/opencodex/pull/1343) MERGED at `eee2dab4d1bbacefce56057adad51d734f346702`; FINAL CLOSURE GATE [#1348](https://github.com/lidge-jun/opencodex/pull/1348) | | CL-03 | `feat/cl-03-live-route-probes` | `4f746d13799888ea0a8c7a111aa2ad61c2126ea0` | `003f7402f49bfe8dd710a7beba52f717051bfadf` | [#1352](https://github.com/lidge-jun/opencodex/pull/1352) | MERGED TO `dev` at `68c71a4e9cdf882d812f09fd94783a28749db629`; ACCEPTED/CLOSED | -| CL-04 | `feat/cl-04-lab-read-surfaces` | `68c71a4e9cdf882d812f09fd94783a28749db629` | NOT RECORDED | DRAFT PENDING | IMPLEMENTATION IN PROGRESS (not accepted) | +| CL-04 | `feat/cl-04-lab-read-surfaces` | `68c71a4e9cdf882d812f09fd94783a28749db629` | NOT RECORDED | [#1378](https://github.com/lidge-jun/opencodex/pull/1378) | MERGED TO `dev` at `d517161aeaa3a974ad3c0360ff0c97b03b4c4520` | +| CL-05 | `feat/cl-05-compatibility-matrix-ui` | `d517161aeaa3a974ad3c0360ff0c97b03b4c4520` | `2a159b8b7` (Models tab placement) | [#1384](https://github.com/lidge-jun/opencodex/pull/1384) | IMPLEMENTATION IN PROGRESS (not accepted) | The CL-01 starting SHA is the exact CL-00 tip recorded when CL-01 began. Its moving base-ref name is not a substitute for that historical SHA. @@ -158,7 +159,8 @@ Claims cannot produce `PROBED`/`VERIFIED`. - CL-01: **MERGED** via #1320 at `4bb249b756abd468c675d2d92fffe4da95ad3e2a`. - CL-02: **MERGED** via #1333 at `025c37916225dd685d9217e5b40190600f06d278`; post-merge hardening #1343 is also **MERGED** at `eee2dab4d1bbacefce56057adad51d734f346702`; final closure is tracked in #1348. - CL-03: **ACCEPTED/CLOSED** via [#1352](https://github.com/lidge-jun/opencodex/pull/1352), merged to `dev` at `68c71a4e9cdf882d812f09fd94783a28749db629`. -- CL-04: **IMPLEMENTATION IN PROGRESS**, authorized from accepted CL-03 merge `68c71a4e9cdf882d812f09fd94783a28749db629`. +- CL-04: **MERGED** via #1378 at `d517161aeaa3a974ad3c0360ff0c97b03b4c4520`. +- CL-05: **IMPLEMENTATION IN PROGRESS**, authorized from CL-04 merge `d517161aeaa3a974ad3c0360ff0c97b03b4c4520`. ## CL-03 implementation log (2026-08-09) diff --git a/devlog/_plan/260807_compatibility_lab/005_cl05_compatibility_matrix_ui.md b/devlog/_plan/260807_compatibility_lab/005_cl05_compatibility_matrix_ui.md new file mode 100644 index 000000000..128b1c700 --- /dev/null +++ b/devlog/_plan/260807_compatibility_lab/005_cl05_compatibility_matrix_ui.md @@ -0,0 +1,71 @@ +# CL-05 implementation record - Compatibility Matrix UI + +## Programme position + +- **Phase:** CL-05 (read-only GUI) +- **Starting `upstream/dev` SHA:** `d517161aeaa3a974ad3c0360ff0c97b03b4c4520` (merge #1378 / CL-04) +- **Branch:** `feat/cl-05-compatibility-matrix-ui` +- **PR:** DRAFT → `lidge-jun/opencodex:dev` ([#1384](https://github.com/lidge-jun/opencodex/pull/1384)) +- **CL-06:** not started + +## Scope delivered + +### GUI (`gui/src/pages/`) + +- **Models → Compatibility tab** at `#models/compatibility` (not a standalone sidebar page) +- Legacy `#lab` hash redirects to `#models/compatibility` +- `CompatibilityMatrix.tsx` - read-only verdict matrix over CL-04 management APIs +- `compatibility-matrix-api.ts` - bounded paginated fetch helpers for `/api/lab/status`, `/api/lab/verdicts`, `/api/lab/subjects`, and detail reads +- `compatibility-matrix-shared.ts` - fail-closed DTO parsing, matrix grouping, and filters +- `styles-compatibility-matrix.css` - scrollable matrix + detail pane +- Compatibility Lab copy localized for all seven locales (`en`, `de`, `ko`, `zh`, `ru`, `ja`, `tr`) +- Component/layout tests in `gui/tests/compatibility-lab.test.tsx` and `gui/tests/compatibility-matrix-layout.test.ts` + +### Behaviour + +- Projection status cards (subjects, verdicts, observations, events, built-at) +- Subject × evidence-layer matrix with per-suite verdict badges +- Server-side verdict filters for evidence layer, verdict, and an exact Subject-ID picker, with paginated "Load more" +- Verdict detail pane (subject, all paginated observations, bounded evidence events, artifact metadata only) +- Verdict selection is presentation state only; it never changes matrix filters or pagination identity +- Empty/unavailable/incompatible projection states via existing data-surface contract; malformed successful API payloads fail closed as load errors +- Lazy mount with `active` / hidden-panel gating inside Models workspace +- No probe execution, projection rebuild, or evidence mutation + +## Review remediation + +The follow-up review pass addresses all validated CodeRabbit findings plus the independent lifecycle findings found while reviewing #1384: + +- legacy `#lab` cold-load page alignment and delimiter-aware regression coverage +- repeated-cursor contract guards plus explicit truncation when advancing pagination reaches the browser-side safety cap +- strict verdict/detail DTO validation and malformed-200 handling +- bounded, partial-failure-tolerant event/artifact enrichment +- detail-selection and load-more stale-response protection +- first-page refresh/poll invalidation of appended cursor pages +- exact Subject-ID picker semantics instead of misleading free-text search +- localized closed-value labels and neutral evidence-event wording +- real button semantics for verdict-detail selection +- inactive-panel, selection/filter, pagination, localization, and routing regression coverage +- focused `compatibility-pagination-cap.test.ts` coverage proving a large advancing dataset is truncated rather than mislabeled as a broken contract +- Models-tab layout test updated from the removed standalone App/sidebar architecture + +## Validation recorded before review remediation + +- `bun x tsc --noEmit` - passed +- `bun test tests/lab-read-surfaces.test.ts tests/models-workspace-tabs.test.ts` - 33/33 passed +- `cd gui && bun test tests/compatibility-lab.test.tsx tests/models-workspace-panels.test.tsx` - 29/29 passed +- `cd gui && bun test tests/compatibility-matrix-layout.test.ts` - 2/2 passed in the full GUI gate +- `bun run lint:gui && bun run doctor:gui && bun run build:gui && bun run privacy:scan` - passed + +The latest pagination-cap remediation adds `gui/tests/compatibility-pagination-cap.test.ts` and requires a fresh CI pass before acceptance; the earlier results above are retained only as baseline evidence. + +## Acceptance blockers + +- Fresh cross-platform CI on the remediation head must be green +- Independent acceptance review not performed + +## Out of scope (confirmed) + +- CL-06 routing profile compatibility fields, CL-07 fabric, CL-08 shadow/automatic/public publish +- New management APIs or CLI changes (CL-04 read surfaces are sufficient) +- Probe execution, projection rebuild triggers, raw artifact download in GUI diff --git a/devlog/_plan/260807_compatibility_lab/screenshots/cl-05/01-matrix-populated-dark.png b/devlog/_plan/260807_compatibility_lab/screenshots/cl-05/01-matrix-populated-dark.png new file mode 100644 index 000000000..20e8b7c6e Binary files /dev/null and b/devlog/_plan/260807_compatibility_lab/screenshots/cl-05/01-matrix-populated-dark.png differ diff --git a/devlog/_plan/260807_compatibility_lab/screenshots/cl-05/02-projection-unavailable-dark.png b/devlog/_plan/260807_compatibility_lab/screenshots/cl-05/02-projection-unavailable-dark.png new file mode 100644 index 000000000..91deca33e Binary files /dev/null and b/devlog/_plan/260807_compatibility_lab/screenshots/cl-05/02-projection-unavailable-dark.png differ diff --git a/devlog/_plan/260807_compatibility_lab/screenshots/cl-05/03-projection-empty-dark.png b/devlog/_plan/260807_compatibility_lab/screenshots/cl-05/03-projection-empty-dark.png new file mode 100644 index 000000000..55053aa4d Binary files /dev/null and b/devlog/_plan/260807_compatibility_lab/screenshots/cl-05/03-projection-empty-dark.png differ diff --git a/devlog/_plan/260807_compatibility_lab/screenshots/cl-05/04-verdict-detail-dark.png b/devlog/_plan/260807_compatibility_lab/screenshots/cl-05/04-verdict-detail-dark.png new file mode 100644 index 000000000..660a7575d Binary files /dev/null and b/devlog/_plan/260807_compatibility_lab/screenshots/cl-05/04-verdict-detail-dark.png differ diff --git a/devlog/_plan/260807_compatibility_lab/screenshots/cl-05/05-matrix-narrow-dark.png b/devlog/_plan/260807_compatibility_lab/screenshots/cl-05/05-matrix-narrow-dark.png new file mode 100644 index 000000000..5a1a9c2df Binary files /dev/null and b/devlog/_plan/260807_compatibility_lab/screenshots/cl-05/05-matrix-narrow-dark.png differ diff --git a/gui/src/app-routing.ts b/gui/src/app-routing.ts index 55b59af85..306a7c79f 100644 --- a/gui/src/app-routing.ts +++ b/gui/src/app-routing.ts @@ -35,8 +35,11 @@ export function readPageFromHash(hash?: string): Page { const pageId = raw.split("/")[0] as Page; // Legacy: Debug used to be a standalone page; it now lives as a tab on Logs. if (pageId === ("debug" as Page)) return "logs"; - // Legacy: Combos and Routing used to be standalone pages; both are Models tabs now. - if (pageId === ("combos" as Page) || pageId === ("routing" as Page)) return "models"; + // Legacy: Combos, Routing, and Lab are Models tabs now. Resolve the destination page + // immediately so a cold legacy hash never flashes Dashboard before replacement. + if (pageId === ("combos" as Page) + || pageId === ("routing" as Page) + || pageId === ("lab" as Page)) return "models"; // Legacy integration pages now live below one Integrations route. Returning // the destination page here keeps the initial hook state aligned until the // resolver replaces the hash with the exact nested destination. @@ -54,11 +57,11 @@ export function readPageFromHash(hash?: string): Page { export const DASHBOARD_TAB_HASHES = ["dashboard/providers", "dashboard/models"] as const; /** - * Models owns three tabs: the catalog, Combos, and Routing. The catalog is the bare - * `#models`, so it has no suffix entry here — same convention Dashboard uses for - * Overview and Logs uses for the log list. + * Models owns four tabs: the catalog, Combos, Routing, and Compatibility. The catalog + * is the bare `#models`, so it has no suffix entry here - same convention Dashboard + * uses for Overview and Logs uses for the log list. */ -export const MODELS_TAB_HASHES = ["models/combos", "models/routing"] as const; +export const MODELS_TAB_HASHES = ["models/combos", "models/routing", "models/compatibility"] as const; /** * `#dashboard/update` is an action deep link, not a tab: the sidebar update button uses @@ -98,7 +101,6 @@ export function hashBelongsToPage(rawHash: string, page: Page): boolean { && (INTEGRATION_TAB_HASHES as readonly string[]).includes(rawHash)); } - /** Result of resolving an incoming hash. */ export type AppHashChangeAction = { page: Page; @@ -119,48 +121,26 @@ export function resolveAppHashChange(rawHash: string): AppHashChangeAction { return { page: "logs", replaceTo: "logs/debug" }; } - /* - * Legacy: Combos and Routing used to be standalone pages, now Models tabs. - * - * The `startsWith` arm is not decoration. Without it the generic normalization below - * would rewrite `#routing/anything` to the bare page and drop the destination — the - * same bug the `#api` comment below documents. - */ + /* Legacy Models pages. Delimiter-aware prefix arms preserve nested legacy bookmarks. */ if (rawHash === "combos" || rawHash.startsWith("combos/")) { return { page: "models", replaceTo: "models/combos" }; } if (rawHash === "routing" || rawHash.startsWith("routing/")) { return { page: "models", replaceTo: "models/routing" }; } - - /* - * Legacy top-level integration pages. - * - * `readPageFromHash("api")` already answers `integrations`, so without these - * branches the generic normalization below would rewrite the hash to the - * bare page and silently drop the nested destination — an old `#api` - * bookmark would land on Overview instead of API Keys. `replaceTo` is - * applied with replaceState, so the correction adds no history entry. - */ - if (rawHash === "api") { - return { page: "integrations", replaceTo: "integrations/keys" }; - } - if (rawHash === "claude") { - return { page: "integrations", replaceTo: "integrations/claude" }; - } - if (rawHash === "grok") { - return { page: "integrations", replaceTo: "integrations/grok" }; + if (rawHash === "lab" || rawHash.startsWith("lab/")) { + return { page: "models", replaceTo: "models/compatibility" }; } + /* Legacy top-level integration pages. */ + if (rawHash === "api") return { page: "integrations", replaceTo: "integrations/keys" }; + if (rawHash === "claude") return { page: "integrations", replaceTo: "integrations/claude" }; + if (rawHash === "grok") return { page: "integrations", replaceTo: "integrations/grok" }; + // Legacy deep link from the removed dual-layout era. - if (rawHash === "providers/workspace") { - return { page: "providers", replaceTo: "providers" }; - } + if (rawHash === "providers/workspace") return { page: "providers", replaceTo: "providers" }; // An unrecognised sub-hash is normalised away rather than left in the URL. - if (!hashBelongsToPage(rawHash, nextPage)) { - return { page: nextPage, replaceTo: nextPage }; - } - + if (!hashBelongsToPage(rawHash, nextPage)) return { page: nextPage, replaceTo: nextPage }; return { page: nextPage, replaceTo: null }; } diff --git a/gui/src/i18n/catalogs.ts b/gui/src/i18n/catalogs.ts index 6d756ef8d..262593ab9 100644 --- a/gui/src/i18n/catalogs.ts +++ b/gui/src/i18n/catalogs.ts @@ -5,11 +5,29 @@ import { zh } from "./zh"; import { ru } from "./ru"; import { ja } from "./ja"; import { tr } from "./tr"; +import { LAB_CATALOG_OVERRIDES, type LabLocale } from "./lab-translations"; /** React-free locale catalog registry for formatters and other shared helpers. */ -export type Locale = "en" | "de" | "ko" | "zh" | "ru" | "ja" | "tr"; +export type Locale = LabLocale; -export const DICTS: Record> = { en, de, ko, zh, ru, ja, tr }; +function withLabTranslations(locale: Locale, catalog: Record): Record { + return { ...catalog, ...LAB_CATALOG_OVERRIDES[locale] }; +} + +/** + * CL-05 translations are overlaid centrally so the compatibility surface cannot regress to + * copied English values in a locale catalog. The locale parity test still validates the base + * catalogs; this overlay is deliberately limited to the closed `lab.*` namespace. + */ +export const DICTS: Record> = { + en: withLabTranslations("en", en), + de: withLabTranslations("de", de), + ko: withLabTranslations("ko", ko), + zh: withLabTranslations("zh", zh), + ru: withLabTranslations("ru", ru), + ja: withLabTranslations("ja", ja), + tr: withLabTranslations("tr", tr), +}; /** Native language names shown by the language picker, kept inside i18n rather than UI metadata. */ export function localeDisplayName(locale: Locale): string { diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index ad073d31a..a53882bef 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -434,9 +434,11 @@ export const de: Record = { "prov.openaiApiSetup": "API-Schlüssel einrichten", "models.tab.catalog": "Modelle", "models.tab.combos": "Combos", + "models.tab.compatibility": "Kompatibilität", "models.tab.routing": "Routing (beta)", "models.tabsLabel": "Modell-Oberflächen", "models.subtitle.combos": "Geordnete Modellgruppen, die unter einer id antworten. Failover probiert Ziele der Reihe nach, Round-Robin verteilt die Last.", + "models.subtitle.compatibility": "Schreibgeschützte Kompatibilitätsmatrix aus der Lab-Projektion.", "models.subtitle.routing": "Policy-Profile, Dry-Run-Auswertung und quellenbasierte Routing-Analysen.", "models.subtitle": "Steuere, welche Modelle Codex sieht — natives GPT-Passthrough und geroutete Anbieter, nach Anbieter gruppiert (Kopfzeile zum Einklappen anklicken). Ausgeblendete Modelle fehlen in Katalog und Auswahl, bleiben aber per genauer ID aufrufbar. Änderungen gelten bei der nächsten Codex-Runde — opencodex invalidiert Codex 5-Minuten-Modell-Cache, kein Neustart nötig.", "models.nativeGroupLabel": "OpenAI nativ", @@ -1908,4 +1910,51 @@ export const de: Record = { "sub.delegation.modelHint": "Das Modell, zu dem Codex zuerst greift, wenn es Arbeit übergibt. Oben steht, wen es überhaupt aufrufen darf; hier wählst du den Ersten davon.", "dash.syncModelsHint": "Schreibt Codex' Modellkatalog anhand deiner verbundenen Provider neu.", "dash.syncRun": "Jetzt synchronisieren", + "lab.title": "Kompatibilitäts-Labor", + "lab.subtitle": "Schreibgeschützte Kompatibilitätsmatrix aus der Lab-Projektion.", + "lab.loadFailed": "Kompatibilitäts-Lab-Daten konnten nicht geladen werden", + "lab.projectionUnavailable": "Lab-Projektion ist nicht verfügbar. Führen Sie zuerst Konformitäts- oder Live-Probes aus.", + "lab.projectionIncompatible": "Lab-Projektionsschema ist inkompatibel. Projektion neu aufbauen.", + "lab.statusTitle": "Projektionsstatus", + "lab.matrixTitle": "Kompatibilitätsmatrix", + "lab.verdictsTitle": "Urteilsdatensätze", + "lab.filter.layer": "Evidenzschicht", + "lab.filter.verdict": "Urteil", + "lab.filter.subject": "Subject-ID", + "lab.filter.all": "Alle", + "lab.col.subject": "Subject", + "lab.col.layer": "Schicht", + "lab.col.suite": "Suite", + "lab.col.verdict": "Urteil", + "lab.col.asOf": "Stand", + "lab.col.protocol": "Protokollkonformität", + "lab.col.live": "Live-Route-Kompatibilität", + "lab.col.task": "Aufgabenwirksamkeit", + "lab.empty": "Noch keine Kompatibilitätsurteile in der Projektion.", + "lab.subjectKind": "Art", + "lab.observationCount": "Beobachtungen", + "lab.eventCount": "Ereignisse", + "lab.verdictCount": "Urteile", + "lab.subjectCount": "Subjects", + "lab.builtAt": "Erstellt", + "lab.loading": "Kompatibilitätsevidenz wird geladen…", + "lab.loadMore": "Load more", + "lab.detailTitle": "Verdict detail", + "lab.detailClose": "Close", + "lab.detailSubject": "Subject", + "lab.detailObservations": "Observations", + "lab.detailEvents": "Contributing events", + "lab.detailArtifacts": "Artifact metadata", + "lab.detailLoadFailed": "Could not load verdict detail", + "lab.refresh": "Aktualisieren", + "lab.verdict.UNKNOWN": "Unbekannt", + "lab.verdict.CLAIMED": "Behauptet", + "lab.verdict.PROBED": "Geprüft", + "lab.verdict.VERIFIED": "Verifiziert", + "lab.verdict.DEGRADED": "Eingeschränkt", + "lab.verdict.BLOCKED": "Blockiert", + "lab.verdict.UNSUPPORTED": "Nicht unterstützt", + "lab.layer.protocol_conformance": "Protokollkonformität", + "lab.layer.live_route_compatibility": "Live-Route-Kompatibilität", + "lab.layer.task_effectiveness": "Aufgabenwirksamkeit", }; diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 1b8c25bf3..6d4347316 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -453,9 +453,11 @@ export const en = { // models "models.tab.catalog": "Models", "models.tab.combos": "Combos", + "models.tab.compatibility": "Compatibility", "models.tab.routing": "Routing (beta)", "models.tabsLabel": "Model surfaces", "models.subtitle.combos": "Ordered groups of models that answer as one id. Failover tries targets in order; round-robin spreads the load.", + "models.subtitle.compatibility": "Read-only compatibility verdict matrix from lab projection evidence.", "models.subtitle.routing": "Policy profiles, dry-run evaluation, and source-backed routing analytics.", "models.subtitle": "Toggle which models Codex sees — native GPT passthrough and routed providers, grouped by provider (click a header to collapse). Hidden models stay off the catalog + model picker but remain directly callable by exact id. Changes apply on the next Codex turn — opencodex invalidates Codex's 5-min model cache so no restart is needed.", "models.nativeGroupLabel": "OpenAI native", @@ -1936,6 +1938,55 @@ export const en = { "claudeDesktop.effort.supported": "effort", "claudeDesktop.effort.displayOnly": "effort (display only)", + // compatibility lab (CL-05) + "lab.title": "Compatibility Lab", + "lab.subtitle": "Read-only compatibility verdict matrix from lab projection evidence.", + "lab.loadFailed": "Could not load compatibility lab data", + "lab.projectionUnavailable": "Lab projection is not available. Run conformance or live probes first.", + "lab.projectionIncompatible": "Lab projection schema is incompatible. Rebuild the projection.", + "lab.statusTitle": "Projection status", + "lab.matrixTitle": "Compatibility matrix", + "lab.verdictsTitle": "Verdict records", + "lab.filter.layer": "Evidence layer", + "lab.filter.verdict": "Verdict", + "lab.filter.subject": "Subject ID", + "lab.filter.all": "All", + "lab.col.subject": "Subject", + "lab.col.layer": "Layer", + "lab.col.suite": "Suite", + "lab.col.verdict": "Verdict", + "lab.col.asOf": "As of", + "lab.col.protocol": "Protocol conformance", + "lab.col.live": "Live route compatibility", + "lab.col.task": "Task effectiveness", + "lab.empty": "No compatibility verdicts in the projection yet.", + "lab.subjectKind": "Kind", + "lab.observationCount": "Observations", + "lab.eventCount": "Events", + "lab.verdictCount": "Verdicts", + "lab.subjectCount": "Subjects", + "lab.builtAt": "Built", + "lab.loading": "Loading compatibility evidence…", + "lab.loadMore": "Load more", + "lab.detailTitle": "Verdict detail", + "lab.detailClose": "Close", + "lab.detailSubject": "Subject", + "lab.detailObservations": "Observations", + "lab.detailEvents": "Contributing events", + "lab.detailArtifacts": "Artifact metadata", + "lab.detailLoadFailed": "Could not load verdict detail", + "lab.refresh": "Refresh", + "lab.verdict.UNKNOWN": "Unknown", + "lab.verdict.CLAIMED": "Claimed", + "lab.verdict.PROBED": "Probed", + "lab.verdict.VERIFIED": "Verified", + "lab.verdict.DEGRADED": "Degraded", + "lab.verdict.BLOCKED": "Blocked", + "lab.verdict.UNSUPPORTED": "Unsupported", + "lab.layer.protocol_conformance": "Protocol conformance", + "lab.layer.live_route_compatibility": "Live route compatibility", + "lab.layer.task_effectiveness": "Task effectiveness", + } as const; export type TKey = keyof typeof en; diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 6a163d161..3770c0722 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -442,9 +442,11 @@ export const ja: Record = { // models "models.tab.catalog": "モデル", "models.tab.combos": "コンボ", + "models.tab.compatibility": "互換性", "models.tab.routing": "ルーティング (beta)", "models.tabsLabel": "モデルサーフェス", "models.subtitle.combos": "複数のモデルを 1 つの id にまとめ、順に応答させます。failover は順番に試し、round-robin は負荷を分散します。", + "models.subtitle.compatibility": "ラボ投影証拠の読み取り専用互換性判定マトリクス。", "models.subtitle.routing": "ポリシープロファイル、dry-run 評価、そして根拠の残るルーティング分析です。", "models.subtitle": "Codex に表示するモデルを切り替えます — ネイティブ GPT パススルーとルーティングプロバイダー、プロバイダー別(ヘッダーをクリックで折りたたみ)。非表示モデルはカタログとピッカーから外れますが、正確な id での直接呼び出しは可能です。変更は次回の Codex ターンで適用 — opencodex は Codex の 5 分間モデルキャッシュを無効化するので再起動は不要です。", "models.nativeGroupLabel": "OpenAI ネイティブ", @@ -1929,4 +1931,51 @@ export const ja: Record = { "sub.delegation.modelHint": "Codex が作業を任せるとき、最初に呼ぶモデルです。上のおすすめが呼べる候補で、ここで選んだものがその中の第一候補になります。", "dash.syncModelsHint": "接続済みのプロバイダーをもとに Codex のモデルカタログを書き直します。", "dash.syncRun": "今すぐ同期", + "lab.title": "Compatibility Lab", + "lab.subtitle": "Read-only compatibility verdict matrix from lab projection evidence.", + "lab.loadFailed": "Could not load compatibility lab data", + "lab.projectionUnavailable": "Lab projection is not available. Run conformance or live probes first.", + "lab.projectionIncompatible": "Lab projection schema is incompatible. Rebuild the projection.", + "lab.statusTitle": "Projection status", + "lab.matrixTitle": "Compatibility matrix", + "lab.verdictsTitle": "Verdict records", + "lab.filter.layer": "Evidence layer", + "lab.filter.verdict": "Verdict", + "lab.filter.subject": "Subject ID", + "lab.filter.all": "All", + "lab.col.subject": "Subject", + "lab.col.layer": "Layer", + "lab.col.suite": "Suite", + "lab.col.verdict": "Verdict", + "lab.col.asOf": "As of", + "lab.col.protocol": "Protocol conformance", + "lab.col.live": "Live route compatibility", + "lab.col.task": "Task effectiveness", + "lab.empty": "No compatibility verdicts in the projection yet.", + "lab.subjectKind": "Kind", + "lab.observationCount": "Observations", + "lab.eventCount": "Events", + "lab.verdictCount": "Verdicts", + "lab.subjectCount": "Subjects", + "lab.builtAt": "Built", + "lab.loading": "Loading compatibility evidence…", + "lab.loadMore": "Load more", + "lab.detailTitle": "Verdict detail", + "lab.detailClose": "Close", + "lab.detailSubject": "Subject", + "lab.detailObservations": "Observations", + "lab.detailEvents": "Contributing events", + "lab.detailArtifacts": "Artifact metadata", + "lab.detailLoadFailed": "Could not load verdict detail", + "lab.refresh": "Refresh", + "lab.verdict.UNKNOWN": "Unknown", + "lab.verdict.CLAIMED": "Claimed", + "lab.verdict.PROBED": "Probed", + "lab.verdict.VERIFIED": "Verified", + "lab.verdict.DEGRADED": "Degraded", + "lab.verdict.BLOCKED": "Blocked", + "lab.verdict.UNSUPPORTED": "Unsupported", + "lab.layer.protocol_conformance": "Protocol conformance", + "lab.layer.live_route_compatibility": "Live route compatibility", + "lab.layer.task_effectiveness": "Task effectiveness", }; diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 30097ac45..102128891 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -445,9 +445,11 @@ export const ko: Record = { // models "models.tab.catalog": "모델", "models.tab.combos": "콤보", + "models.tab.compatibility": "호환성", "models.tab.routing": "라우팅 (beta)", "models.tabsLabel": "모델 표면", "models.subtitle.combos": "여러 모델을 하나의 id로 묶어 순서대로 응답하게 합니다. failover는 순서대로 시도하고, round-robin은 부하를 나눕니다.", + "models.subtitle.compatibility": "랩 프로젝션 증거의 읽기 전용 호환성 판정 행렬.", "models.subtitle.routing": "정책 프로필, dry-run 평가, 그리고 근거가 남는 라우팅 분석입니다.", "models.subtitle": "Codex가 보는 모델을 켜고 끕니다 — 네이티브 GPT passthrough와 라우팅된 모델을 프로바이더별로 묶어 보여줍니다(헤더를 클릭하면 접힘). 숨긴 모델은 카탈로그와 선택기에서 빠지지만 정확한 id로 직접 호출할 수 있습니다. 변경 사항은 다음 Codex 턴에 적용됩니다 — opencodex가 Codex의 5분 모델 캐시를 무효화하므로 재시작이 필요 없습니다.", "models.nativeGroupLabel": "OpenAI 네이티브", @@ -1929,4 +1931,52 @@ export const ko: Record = { "claudeDesktop.effort.supported": "effort", "claudeDesktop.effort.displayOnly": "effort (표시만)", + "lab.title": "Compatibility Lab", + "lab.subtitle": "Read-only compatibility verdict matrix from lab projection evidence.", + "lab.loadFailed": "Could not load compatibility lab data", + "lab.projectionUnavailable": "Lab projection is not available. Run conformance or live probes first.", + "lab.projectionIncompatible": "Lab projection schema is incompatible. Rebuild the projection.", + "lab.statusTitle": "Projection status", + "lab.matrixTitle": "Compatibility matrix", + "lab.verdictsTitle": "Verdict records", + "lab.filter.layer": "Evidence layer", + "lab.filter.verdict": "Verdict", + "lab.filter.subject": "Subject ID", + "lab.filter.all": "All", + "lab.col.subject": "Subject", + "lab.col.layer": "Layer", + "lab.col.suite": "Suite", + "lab.col.verdict": "Verdict", + "lab.col.asOf": "As of", + "lab.col.protocol": "Protocol conformance", + "lab.col.live": "Live route compatibility", + "lab.col.task": "Task effectiveness", + "lab.empty": "No compatibility verdicts in the projection yet.", + "lab.subjectKind": "Kind", + "lab.observationCount": "Observations", + "lab.eventCount": "Events", + "lab.verdictCount": "Verdicts", + "lab.subjectCount": "Subjects", + "lab.builtAt": "Built", + "lab.loading": "Loading compatibility evidence…", + "lab.loadMore": "Load more", + "lab.detailTitle": "Verdict detail", + "lab.detailClose": "Close", + "lab.detailSubject": "Subject", + "lab.detailObservations": "Observations", + "lab.detailEvents": "Contributing events", + "lab.detailArtifacts": "Artifact metadata", + "lab.detailLoadFailed": "Could not load verdict detail", + "lab.refresh": "Refresh", + "lab.verdict.UNKNOWN": "Unknown", + "lab.verdict.CLAIMED": "Claimed", + "lab.verdict.PROBED": "Probed", + "lab.verdict.VERIFIED": "Verified", + "lab.verdict.DEGRADED": "Degraded", + "lab.verdict.BLOCKED": "Blocked", + "lab.verdict.UNSUPPORTED": "Unsupported", + "lab.layer.protocol_conformance": "Protocol conformance", + "lab.layer.live_route_compatibility": "Live route compatibility", + "lab.layer.task_effectiveness": "Task effectiveness", + }; diff --git a/gui/src/i18n/lab-translations.ts b/gui/src/i18n/lab-translations.ts new file mode 100644 index 000000000..f75959d05 --- /dev/null +++ b/gui/src/i18n/lab-translations.ts @@ -0,0 +1,436 @@ +import type { TKey } from "./en"; + +export type LabLocale = "en" | "de" | "ko" | "zh" | "ru" | "ja" | "tr"; +export type LabCatalogKey = Extract; +export type LabSupplementKey = + | "subjectKindUnknown" + | "artifact.present" + | "artifact.corrupt" + | "artifact.purged_unavailable" + | "selectVerdict"; + +const en: Record = { + "lab.title": "Compatibility Lab", + "lab.subtitle": "Read-only compatibility verdict matrix from lab projection evidence.", + "lab.loadFailed": "Could not load compatibility lab data", + "lab.projectionUnavailable": "Lab projection is not available. Run conformance or live probes first.", + "lab.projectionIncompatible": "Lab projection schema is incompatible. Rebuild the projection.", + "lab.statusTitle": "Projection status", + "lab.matrixTitle": "Compatibility matrix", + "lab.verdictsTitle": "Verdict records", + "lab.filter.layer": "Evidence layer", + "lab.filter.verdict": "Verdict", + "lab.filter.subject": "Subject ID", + "lab.filter.all": "All", + "lab.col.subject": "Subject", + "lab.col.layer": "Layer", + "lab.col.suite": "Suite", + "lab.col.verdict": "Verdict", + "lab.col.asOf": "As of", + "lab.col.protocol": "Protocol conformance", + "lab.col.live": "Live route compatibility", + "lab.col.task": "Task effectiveness", + "lab.empty": "No compatibility verdicts in the projection yet.", + "lab.subjectKind": "Kind", + "lab.observationCount": "Observations", + "lab.eventCount": "Events", + "lab.verdictCount": "Verdicts", + "lab.subjectCount": "Subjects", + "lab.builtAt": "Built", + "lab.loading": "Loading compatibility evidence…", + "lab.loadMore": "Load more", + "lab.detailTitle": "Verdict detail", + "lab.detailClose": "Close", + "lab.detailSubject": "Subject", + "lab.detailObservations": "Observations", + "lab.detailEvents": "Evidence events", + "lab.detailArtifacts": "Artifact metadata", + "lab.detailLoadFailed": "Could not load verdict detail", + "lab.refresh": "Refresh", + "lab.verdict.UNKNOWN": "Unknown", + "lab.verdict.CLAIMED": "Claimed", + "lab.verdict.PROBED": "Probed", + "lab.verdict.VERIFIED": "Verified", + "lab.verdict.DEGRADED": "Degraded", + "lab.verdict.BLOCKED": "Blocked", + "lab.verdict.UNSUPPORTED": "Unsupported", + "lab.layer.protocol_conformance": "Protocol conformance", + "lab.layer.live_route_compatibility": "Live route compatibility", + "lab.layer.task_effectiveness": "Task effectiveness", +}; + +const de: Record = { + "lab.title": "Kompatibilitäts-Labor", + "lab.subtitle": "Schreibgeschützte Kompatibilitätsmatrix aus den Evidenzen der Lab-Projektion.", + "lab.loadFailed": "Kompatibilitätsdaten konnten nicht geladen werden", + "lab.projectionUnavailable": "Die Lab-Projektion ist nicht verfügbar. Führe zuerst Konformitäts- oder Live-Probes aus.", + "lab.projectionIncompatible": "Das Schema der Lab-Projektion ist inkompatibel. Baue die Projektion neu auf.", + "lab.statusTitle": "Projektionsstatus", + "lab.matrixTitle": "Kompatibilitätsmatrix", + "lab.verdictsTitle": "Urteilsdatensätze", + "lab.filter.layer": "Evidenzschicht", + "lab.filter.verdict": "Urteil", + "lab.filter.subject": "Subjekt-ID", + "lab.filter.all": "Alle", + "lab.col.subject": "Subjekt", + "lab.col.layer": "Schicht", + "lab.col.suite": "Suite", + "lab.col.verdict": "Urteil", + "lab.col.asOf": "Stand", + "lab.col.protocol": "Protokollkonformität", + "lab.col.live": "Live-Route-Kompatibilität", + "lab.col.task": "Aufgabenwirksamkeit", + "lab.empty": "In der Projektion gibt es noch keine Kompatibilitätsurteile.", + "lab.subjectKind": "Art", + "lab.observationCount": "Beobachtungen", + "lab.eventCount": "Ereignisse", + "lab.verdictCount": "Urteile", + "lab.subjectCount": "Subjekte", + "lab.builtAt": "Erstellt", + "lab.loading": "Kompatibilitätsevidenz wird geladen…", + "lab.loadMore": "Mehr laden", + "lab.detailTitle": "Urteilsdetails", + "lab.detailClose": "Schließen", + "lab.detailSubject": "Subjekt", + "lab.detailObservations": "Beobachtungen", + "lab.detailEvents": "Evidenzereignisse", + "lab.detailArtifacts": "Artefakt-Metadaten", + "lab.detailLoadFailed": "Urteilsdetails konnten nicht geladen werden", + "lab.refresh": "Aktualisieren", + "lab.verdict.UNKNOWN": "Unbekannt", + "lab.verdict.CLAIMED": "Behauptet", + "lab.verdict.PROBED": "Geprüft", + "lab.verdict.VERIFIED": "Verifiziert", + "lab.verdict.DEGRADED": "Eingeschränkt", + "lab.verdict.BLOCKED": "Blockiert", + "lab.verdict.UNSUPPORTED": "Nicht unterstützt", + "lab.layer.protocol_conformance": "Protokollkonformität", + "lab.layer.live_route_compatibility": "Live-Route-Kompatibilität", + "lab.layer.task_effectiveness": "Aufgabenwirksamkeit", +}; + +const ja: Record = { + "lab.title": "互換性ラボ", + "lab.subtitle": "ラボ投影の証拠に基づく読み取り専用の互換性判定マトリクスです。", + "lab.loadFailed": "互換性ラボのデータを読み込めませんでした", + "lab.projectionUnavailable": "ラボ投影を利用できません。先に適合性プローブまたはライブプローブを実行してください。", + "lab.projectionIncompatible": "ラボ投影のスキーマに互換性がありません。投影を再構築してください。", + "lab.statusTitle": "投影ステータス", + "lab.matrixTitle": "互換性マトリクス", + "lab.verdictsTitle": "判定レコード", + "lab.filter.layer": "証拠レイヤー", + "lab.filter.verdict": "判定", + "lab.filter.subject": "サブジェクト ID", + "lab.filter.all": "すべて", + "lab.col.subject": "サブジェクト", + "lab.col.layer": "レイヤー", + "lab.col.suite": "スイート", + "lab.col.verdict": "判定", + "lab.col.asOf": "時点", + "lab.col.protocol": "プロトコル適合性", + "lab.col.live": "ライブ経路互換性", + "lab.col.task": "タスク有効性", + "lab.empty": "投影にはまだ互換性判定がありません。", + "lab.subjectKind": "種類", + "lab.observationCount": "観測", + "lab.eventCount": "イベント", + "lab.verdictCount": "判定", + "lab.subjectCount": "サブジェクト", + "lab.builtAt": "構築日時", + "lab.loading": "互換性の証拠を読み込み中…", + "lab.loadMore": "さらに読み込む", + "lab.detailTitle": "判定の詳細", + "lab.detailClose": "閉じる", + "lab.detailSubject": "サブジェクト", + "lab.detailObservations": "観測", + "lab.detailEvents": "証拠イベント", + "lab.detailArtifacts": "アーティファクトのメタデータ", + "lab.detailLoadFailed": "判定の詳細を読み込めませんでした", + "lab.refresh": "更新", + "lab.verdict.UNKNOWN": "不明", + "lab.verdict.CLAIMED": "申告済み", + "lab.verdict.PROBED": "プローブ済み", + "lab.verdict.VERIFIED": "検証済み", + "lab.verdict.DEGRADED": "低下", + "lab.verdict.BLOCKED": "ブロック", + "lab.verdict.UNSUPPORTED": "未対応", + "lab.layer.protocol_conformance": "プロトコル適合性", + "lab.layer.live_route_compatibility": "ライブ経路互換性", + "lab.layer.task_effectiveness": "タスク有効性", +}; + +const ko: Record = { + "lab.title": "호환성 랩", + "lab.subtitle": "랩 프로젝션 증거를 기반으로 한 읽기 전용 호환성 판정 매트릭스입니다.", + "lab.loadFailed": "호환성 랩 데이터를 불러오지 못했습니다", + "lab.projectionUnavailable": "랩 프로젝션을 사용할 수 없습니다. 먼저 적합성 또는 라이브 프로브를 실행하세요.", + "lab.projectionIncompatible": "랩 프로젝션 스키마가 호환되지 않습니다. 프로젝션을 다시 빌드하세요.", + "lab.statusTitle": "프로젝션 상태", + "lab.matrixTitle": "호환성 매트릭스", + "lab.verdictsTitle": "판정 레코드", + "lab.filter.layer": "증거 레이어", + "lab.filter.verdict": "판정", + "lab.filter.subject": "대상 ID", + "lab.filter.all": "전체", + "lab.col.subject": "대상", + "lab.col.layer": "레이어", + "lab.col.suite": "스위트", + "lab.col.verdict": "판정", + "lab.col.asOf": "기준 시각", + "lab.col.protocol": "프로토콜 적합성", + "lab.col.live": "라이브 경로 호환성", + "lab.col.task": "작업 효과성", + "lab.empty": "프로젝션에 아직 호환성 판정이 없습니다.", + "lab.subjectKind": "종류", + "lab.observationCount": "관측", + "lab.eventCount": "이벤트", + "lab.verdictCount": "판정", + "lab.subjectCount": "대상", + "lab.builtAt": "빌드 시각", + "lab.loading": "호환성 증거를 불러오는 중…", + "lab.loadMore": "더 불러오기", + "lab.detailTitle": "판정 상세", + "lab.detailClose": "닫기", + "lab.detailSubject": "대상", + "lab.detailObservations": "관측", + "lab.detailEvents": "증거 이벤트", + "lab.detailArtifacts": "아티팩트 메타데이터", + "lab.detailLoadFailed": "판정 상세를 불러오지 못했습니다", + "lab.refresh": "새로고침", + "lab.verdict.UNKNOWN": "알 수 없음", + "lab.verdict.CLAIMED": "주장됨", + "lab.verdict.PROBED": "프로브됨", + "lab.verdict.VERIFIED": "검증됨", + "lab.verdict.DEGRADED": "저하됨", + "lab.verdict.BLOCKED": "차단됨", + "lab.verdict.UNSUPPORTED": "지원되지 않음", + "lab.layer.protocol_conformance": "프로토콜 적합성", + "lab.layer.live_route_compatibility": "라이브 경로 호환성", + "lab.layer.task_effectiveness": "작업 효과성", +}; + +const ru: Record = { + "lab.title": "Лаборатория совместимости", + "lab.subtitle": "Матрица вердиктов совместимости только для чтения на основе данных проекции лаборатории.", + "lab.loadFailed": "Не удалось загрузить данные лаборатории совместимости", + "lab.projectionUnavailable": "Проекция лаборатории недоступна. Сначала выполните проверки соответствия или live-проверки.", + "lab.projectionIncompatible": "Схема проекции лаборатории несовместима. Перестройте проекцию.", + "lab.statusTitle": "Состояние проекции", + "lab.matrixTitle": "Матрица совместимости", + "lab.verdictsTitle": "Записи вердиктов", + "lab.filter.layer": "Слой доказательств", + "lab.filter.verdict": "Вердикт", + "lab.filter.subject": "ID субъекта", + "lab.filter.all": "Все", + "lab.col.subject": "Субъект", + "lab.col.layer": "Слой", + "lab.col.suite": "Набор", + "lab.col.verdict": "Вердикт", + "lab.col.asOf": "По состоянию на", + "lab.col.protocol": "Соответствие протоколу", + "lab.col.live": "Совместимость live-маршрута", + "lab.col.task": "Эффективность задач", + "lab.empty": "В проекции пока нет вердиктов совместимости.", + "lab.subjectKind": "Тип", + "lab.observationCount": "Наблюдения", + "lab.eventCount": "События", + "lab.verdictCount": "Вердикты", + "lab.subjectCount": "Субъекты", + "lab.builtAt": "Собрано", + "lab.loading": "Загрузка доказательств совместимости…", + "lab.loadMore": "Загрузить ещё", + "lab.detailTitle": "Детали вердикта", + "lab.detailClose": "Закрыть", + "lab.detailSubject": "Субъект", + "lab.detailObservations": "Наблюдения", + "lab.detailEvents": "События доказательств", + "lab.detailArtifacts": "Метаданные артефактов", + "lab.detailLoadFailed": "Не удалось загрузить детали вердикта", + "lab.refresh": "Обновить", + "lab.verdict.UNKNOWN": "Неизвестно", + "lab.verdict.CLAIMED": "Заявлено", + "lab.verdict.PROBED": "Проверено пробой", + "lab.verdict.VERIFIED": "Подтверждено", + "lab.verdict.DEGRADED": "Ограничено", + "lab.verdict.BLOCKED": "Заблокировано", + "lab.verdict.UNSUPPORTED": "Не поддерживается", + "lab.layer.protocol_conformance": "Соответствие протоколу", + "lab.layer.live_route_compatibility": "Совместимость live-маршрута", + "lab.layer.task_effectiveness": "Эффективность задач", +}; + +const tr: Record = { + "lab.title": "Uyumluluk Laboratuvarı", + "lab.subtitle": "Laboratuvar projeksiyonu kanıtlarından oluşturulan salt okunur uyumluluk karar matrisi.", + "lab.loadFailed": "Uyumluluk laboratuvarı verileri yüklenemedi", + "lab.projectionUnavailable": "Laboratuvar projeksiyonu kullanılamıyor. Önce uygunluk veya canlı probları çalıştırın.", + "lab.projectionIncompatible": "Laboratuvar projeksiyonu şeması uyumsuz. Projeksiyonu yeniden oluşturun.", + "lab.statusTitle": "Projeksiyon durumu", + "lab.matrixTitle": "Uyumluluk matrisi", + "lab.verdictsTitle": "Karar kayıtları", + "lab.filter.layer": "Kanıt katmanı", + "lab.filter.verdict": "Karar", + "lab.filter.subject": "Özne kimliği", + "lab.filter.all": "Tümü", + "lab.col.subject": "Özne", + "lab.col.layer": "Katman", + "lab.col.suite": "Paket", + "lab.col.verdict": "Karar", + "lab.col.asOf": "Tarih", + "lab.col.protocol": "Protokol uygunluğu", + "lab.col.live": "Canlı rota uyumluluğu", + "lab.col.task": "Görev etkinliği", + "lab.empty": "Projeksiyonda henüz uyumluluk kararı yok.", + "lab.subjectKind": "Tür", + "lab.observationCount": "Gözlemler", + "lab.eventCount": "Olaylar", + "lab.verdictCount": "Kararlar", + "lab.subjectCount": "Özneler", + "lab.builtAt": "Oluşturulma", + "lab.loading": "Uyumluluk kanıtları yükleniyor…", + "lab.loadMore": "Daha fazla yükle", + "lab.detailTitle": "Karar ayrıntısı", + "lab.detailClose": "Kapat", + "lab.detailSubject": "Özne", + "lab.detailObservations": "Gözlemler", + "lab.detailEvents": "Kanıt olayları", + "lab.detailArtifacts": "Artefakt meta verileri", + "lab.detailLoadFailed": "Karar ayrıntısı yüklenemedi", + "lab.refresh": "Yenile", + "lab.verdict.UNKNOWN": "Bilinmiyor", + "lab.verdict.CLAIMED": "İddia edildi", + "lab.verdict.PROBED": "Problandı", + "lab.verdict.VERIFIED": "Doğrulandı", + "lab.verdict.DEGRADED": "Kısıtlı", + "lab.verdict.BLOCKED": "Engellendi", + "lab.verdict.UNSUPPORTED": "Desteklenmiyor", + "lab.layer.protocol_conformance": "Protokol uygunluğu", + "lab.layer.live_route_compatibility": "Canlı rota uyumluluğu", + "lab.layer.task_effectiveness": "Görev etkinliği", +}; + +const zh: Record = { + "lab.title": "兼容性实验室", + "lab.subtitle": "基于实验室投影证据的只读兼容性判定矩阵。", + "lab.loadFailed": "无法加载兼容性实验室数据", + "lab.projectionUnavailable": "实验室投影不可用。请先运行一致性探测或实时探测。", + "lab.projectionIncompatible": "实验室投影架构不兼容。请重新构建投影。", + "lab.statusTitle": "投影状态", + "lab.matrixTitle": "兼容性矩阵", + "lab.verdictsTitle": "判定记录", + "lab.filter.layer": "证据层", + "lab.filter.verdict": "判定", + "lab.filter.subject": "主体 ID", + "lab.filter.all": "全部", + "lab.col.subject": "主体", + "lab.col.layer": "层", + "lab.col.suite": "测试套件", + "lab.col.verdict": "判定", + "lab.col.asOf": "截至", + "lab.col.protocol": "协议一致性", + "lab.col.live": "实时路由兼容性", + "lab.col.task": "任务有效性", + "lab.empty": "投影中还没有兼容性判定。", + "lab.subjectKind": "类型", + "lab.observationCount": "观测", + "lab.eventCount": "事件", + "lab.verdictCount": "判定", + "lab.subjectCount": "主体", + "lab.builtAt": "构建时间", + "lab.loading": "正在加载兼容性证据…", + "lab.loadMore": "加载更多", + "lab.detailTitle": "判定详情", + "lab.detailClose": "关闭", + "lab.detailSubject": "主体", + "lab.detailObservations": "观测", + "lab.detailEvents": "证据事件", + "lab.detailArtifacts": "制品元数据", + "lab.detailLoadFailed": "无法加载判定详情", + "lab.refresh": "刷新", + "lab.verdict.UNKNOWN": "未知", + "lab.verdict.CLAIMED": "已声明", + "lab.verdict.PROBED": "已探测", + "lab.verdict.VERIFIED": "已验证", + "lab.verdict.DEGRADED": "降级", + "lab.verdict.BLOCKED": "已阻止", + "lab.verdict.UNSUPPORTED": "不支持", + "lab.layer.protocol_conformance": "协议一致性", + "lab.layer.live_route_compatibility": "实时路由兼容性", + "lab.layer.task_effectiveness": "任务有效性", +}; + +export const LAB_CATALOG_OVERRIDES: Record> = { + en, + de, + ko, + zh, + ru, + ja, + tr, +}; + +const supplements: Record> = { + en: { + subjectKindUnknown: "Unknown", + "artifact.present": "Present", + "artifact.corrupt": "Corrupt", + "artifact.purged_unavailable": "Purged / unavailable", + selectVerdict: "View verdict for {subject}", + }, + de: { + subjectKindUnknown: "Unbekannt", + "artifact.present": "Vorhanden", + "artifact.corrupt": "Beschädigt", + "artifact.purged_unavailable": "Gelöscht / nicht verfügbar", + selectVerdict: "Urteil für {subject} anzeigen", + }, + ko: { + subjectKindUnknown: "알 수 없음", + "artifact.present": "있음", + "artifact.corrupt": "손상됨", + "artifact.purged_unavailable": "삭제됨 / 사용할 수 없음", + selectVerdict: "{subject}의 판정 보기", + }, + zh: { + subjectKindUnknown: "未知", + "artifact.present": "存在", + "artifact.corrupt": "已损坏", + "artifact.purged_unavailable": "已清除 / 不可用", + selectVerdict: "查看 {subject} 的判定", + }, + ru: { + subjectKindUnknown: "Неизвестно", + "artifact.present": "Доступен", + "artifact.corrupt": "Повреждён", + "artifact.purged_unavailable": "Удалён / недоступен", + selectVerdict: "Открыть вердикт для {subject}", + }, + ja: { + subjectKindUnknown: "不明", + "artifact.present": "存在", + "artifact.corrupt": "破損", + "artifact.purged_unavailable": "削除済み / 利用不可", + selectVerdict: "{subject} の判定を表示", + }, + tr: { + subjectKindUnknown: "Bilinmiyor", + "artifact.present": "Mevcut", + "artifact.corrupt": "Bozuk", + "artifact.purged_unavailable": "Temizlenmiş / kullanılamıyor", + selectVerdict: "{subject} için kararı görüntüle", + }, +}; + +export function labSupplement( + locale: LabLocale, + key: LabSupplementKey, + vars?: Record, +): string { + let value = supplements[locale][key]; + if (vars) { + for (const [name, replacement] of Object.entries(vars)) { + value = value.split(`{${name}}`).join(String(replacement)); + } + } + return value; +} diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index d462a81ad..2f9fc958d 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -447,9 +447,11 @@ export const ru: Record = { // models "models.tab.catalog": "Модели", "models.tab.combos": "Комбо", + "models.tab.compatibility": "Совместимость", "models.tab.routing": "Маршрутизация (beta)", "models.tabsLabel": "Поверхности моделей", "models.subtitle.combos": "Упорядоченные группы моделей, отвечающие под одним id. Failover пробует цели по порядку, round-robin распределяет нагрузку.", + "models.subtitle.compatibility": "Матрица совместимости только для чтения из проекции лаборатории.", "models.subtitle.routing": "Профили политик, оценка в режиме dry-run и аналитика маршрутизации с подтверждением источников.", "models.subtitle": "Управляйте тем, какие модели видит Codex — нативные GPT (сквозной проброс) и модели маршрутизируемых провайдеров, сгруппированные по провайдеру (нажмите на заголовок, чтобы свернуть группу). Скрытые модели исчезают из каталога и селектора, но остаются вызываемыми по точному id. Изменения применяются на следующем ходе Codex — opencodex сбрасывает 5-минутный кэш моделей Codex, поэтому перезапуск не требуется.", "models.nativeGroupLabel": "Нативные OpenAI", @@ -1931,4 +1933,51 @@ export const ru: Record = { "sub.delegation.modelHint": "Модель, к которой Codex обращается первой, когда передаёт работу. Список выше — кого он вообще может вызвать, а здесь выбирается первый в очереди.", "dash.syncModelsHint": "Перезаписывает каталог моделей Codex по подключённым провайдерам.", "dash.syncRun": "Синхронизировать", + "lab.title": "Compatibility Lab", + "lab.subtitle": "Read-only compatibility verdict matrix from lab projection evidence.", + "lab.loadFailed": "Could not load compatibility lab data", + "lab.projectionUnavailable": "Lab projection is not available. Run conformance or live probes first.", + "lab.projectionIncompatible": "Lab projection schema is incompatible. Rebuild the projection.", + "lab.statusTitle": "Projection status", + "lab.matrixTitle": "Compatibility matrix", + "lab.verdictsTitle": "Verdict records", + "lab.filter.layer": "Evidence layer", + "lab.filter.verdict": "Verdict", + "lab.filter.subject": "Subject ID", + "lab.filter.all": "All", + "lab.col.subject": "Subject", + "lab.col.layer": "Layer", + "lab.col.suite": "Suite", + "lab.col.verdict": "Verdict", + "lab.col.asOf": "As of", + "lab.col.protocol": "Protocol conformance", + "lab.col.live": "Live route compatibility", + "lab.col.task": "Task effectiveness", + "lab.empty": "No compatibility verdicts in the projection yet.", + "lab.subjectKind": "Kind", + "lab.observationCount": "Observations", + "lab.eventCount": "Events", + "lab.verdictCount": "Verdicts", + "lab.subjectCount": "Subjects", + "lab.builtAt": "Built", + "lab.loading": "Loading compatibility evidence…", + "lab.loadMore": "Load more", + "lab.detailTitle": "Verdict detail", + "lab.detailClose": "Close", + "lab.detailSubject": "Subject", + "lab.detailObservations": "Observations", + "lab.detailEvents": "Contributing events", + "lab.detailArtifacts": "Artifact metadata", + "lab.detailLoadFailed": "Could not load verdict detail", + "lab.refresh": "Refresh", + "lab.verdict.UNKNOWN": "Unknown", + "lab.verdict.CLAIMED": "Claimed", + "lab.verdict.PROBED": "Probed", + "lab.verdict.VERIFIED": "Verified", + "lab.verdict.DEGRADED": "Degraded", + "lab.verdict.BLOCKED": "Blocked", + "lab.verdict.UNSUPPORTED": "Unsupported", + "lab.layer.protocol_conformance": "Protocol conformance", + "lab.layer.live_route_compatibility": "Live route compatibility", + "lab.layer.task_effectiveness": "Task effectiveness", }; diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 32e97e2e4..6fd435e53 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -450,9 +450,11 @@ export const tr: Record = { // models "models.tab.catalog": "Modeller", "models.tab.combos": "Kombolar", + "models.tab.compatibility": "Uyumluluk", "models.tab.routing": "Yönlendirme (beta)", "models.tabsLabel": "Model yüzeyleri", "models.subtitle.combos": "Tek bir kimlik olarak yanıt veren sıralı model grupları.", + "models.subtitle.compatibility": "Lab projeksiyon kanıtından salt okunur uyumluluk matrisi.", "models.subtitle.routing": "Politika profilleri ve simülasyon değerlendirmesi.", "models.subtitle": "Codex'in göreceği modelleri açıp kapatın.", "models.nativeGroupLabel": "Yerel OpenAI", @@ -1931,4 +1933,51 @@ export const tr: Record = { "claudeDesktop.health.stats": "{count} istek / {errors} hata", "claudeDesktop.effort.supported": "çaba", "claudeDesktop.effort.displayOnly": "çaba (yalnızca ekran)", + "lab.title": "Compatibility Lab", + "lab.subtitle": "Read-only compatibility verdict matrix from lab projection evidence.", + "lab.loadFailed": "Could not load compatibility lab data", + "lab.projectionUnavailable": "Lab projection is not available. Run conformance or live probes first.", + "lab.projectionIncompatible": "Lab projection schema is incompatible. Rebuild the projection.", + "lab.statusTitle": "Projection status", + "lab.matrixTitle": "Compatibility matrix", + "lab.verdictsTitle": "Verdict records", + "lab.filter.layer": "Evidence layer", + "lab.filter.verdict": "Verdict", + "lab.filter.subject": "Subject ID", + "lab.filter.all": "All", + "lab.col.subject": "Subject", + "lab.col.layer": "Layer", + "lab.col.suite": "Suite", + "lab.col.verdict": "Verdict", + "lab.col.asOf": "As of", + "lab.col.protocol": "Protocol conformance", + "lab.col.live": "Live route compatibility", + "lab.col.task": "Task effectiveness", + "lab.empty": "No compatibility verdicts in the projection yet.", + "lab.subjectKind": "Kind", + "lab.observationCount": "Observations", + "lab.eventCount": "Events", + "lab.verdictCount": "Verdicts", + "lab.subjectCount": "Subjects", + "lab.builtAt": "Built", + "lab.loading": "Loading compatibility evidence…", + "lab.loadMore": "Load more", + "lab.detailTitle": "Verdict detail", + "lab.detailClose": "Close", + "lab.detailSubject": "Subject", + "lab.detailObservations": "Observations", + "lab.detailEvents": "Contributing events", + "lab.detailArtifacts": "Artifact metadata", + "lab.detailLoadFailed": "Could not load verdict detail", + "lab.refresh": "Refresh", + "lab.verdict.UNKNOWN": "Unknown", + "lab.verdict.CLAIMED": "Claimed", + "lab.verdict.PROBED": "Probed", + "lab.verdict.VERIFIED": "Verified", + "lab.verdict.DEGRADED": "Degraded", + "lab.verdict.BLOCKED": "Blocked", + "lab.verdict.UNSUPPORTED": "Unsupported", + "lab.layer.protocol_conformance": "Protocol conformance", + "lab.layer.live_route_compatibility": "Live route compatibility", + "lab.layer.task_effectiveness": "Task effectiveness", }; diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index c62f4477c..2ae8b6ac2 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -442,9 +442,11 @@ export const zh: Record = { // models "models.tab.catalog": "模型", "models.tab.combos": "组合", + "models.tab.compatibility": "兼容性", "models.tab.routing": "路由 (beta)", "models.tabsLabel": "模型界面", "models.subtitle.combos": "把多个模型合成一个 id 依次应答。failover 按顺序尝试,round-robin 分摊负载。", + "models.subtitle.compatibility": "来自实验室投影证据的只读兼容性判定矩阵。", "models.subtitle.routing": "策略配置、dry-run 评估,以及有据可查的路由分析。", "models.subtitle": "开关 Codex 可见的模型 — 原生 GPT passthrough 与已路由模型按提供方分组(点击标题可折叠)。隐藏的模型不会出现在目录和模型选择器中,但仍可按精确 id 直接调用。更改在下一个 Codex 回合生效 — opencodex 会使 Codex 的 5 分钟模型缓存失效,因此无需重启。", "models.nativeGroupLabel": "OpenAI 原生", @@ -1929,4 +1931,51 @@ export const zh: Record = { "sub.delegation.modelHint": "Codex 分派工作时最先调用的模型。上面的推荐是可调用的名单,这里选的是其中第一顺位。", "dash.syncModelsHint": "按已连接的提供商重写 Codex 的模型目录。", "dash.syncRun": "立即同步", + "lab.title": "Compatibility Lab", + "lab.subtitle": "Read-only compatibility verdict matrix from lab projection evidence.", + "lab.loadFailed": "Could not load compatibility lab data", + "lab.projectionUnavailable": "Lab projection is not available. Run conformance or live probes first.", + "lab.projectionIncompatible": "Lab projection schema is incompatible. Rebuild the projection.", + "lab.statusTitle": "Projection status", + "lab.matrixTitle": "Compatibility matrix", + "lab.verdictsTitle": "Verdict records", + "lab.filter.layer": "Evidence layer", + "lab.filter.verdict": "Verdict", + "lab.filter.subject": "Subject ID", + "lab.filter.all": "All", + "lab.col.subject": "Subject", + "lab.col.layer": "Layer", + "lab.col.suite": "Suite", + "lab.col.verdict": "Verdict", + "lab.col.asOf": "As of", + "lab.col.protocol": "Protocol conformance", + "lab.col.live": "Live route compatibility", + "lab.col.task": "Task effectiveness", + "lab.empty": "No compatibility verdicts in the projection yet.", + "lab.subjectKind": "Kind", + "lab.observationCount": "Observations", + "lab.eventCount": "Events", + "lab.verdictCount": "Verdicts", + "lab.subjectCount": "Subjects", + "lab.builtAt": "Built", + "lab.loading": "Loading compatibility evidence…", + "lab.loadMore": "Load more", + "lab.detailTitle": "Verdict detail", + "lab.detailClose": "Close", + "lab.detailSubject": "Subject", + "lab.detailObservations": "Observations", + "lab.detailEvents": "Contributing events", + "lab.detailArtifacts": "Artifact metadata", + "lab.detailLoadFailed": "Could not load verdict detail", + "lab.refresh": "Refresh", + "lab.verdict.UNKNOWN": "Unknown", + "lab.verdict.CLAIMED": "Claimed", + "lab.verdict.PROBED": "Probed", + "lab.verdict.VERIFIED": "Verified", + "lab.verdict.DEGRADED": "Degraded", + "lab.verdict.BLOCKED": "Blocked", + "lab.verdict.UNSUPPORTED": "Unsupported", + "lab.layer.protocol_conformance": "Protocol conformance", + "lab.layer.live_route_compatibility": "Live route compatibility", + "lab.layer.task_effectiveness": "Task effectiveness", }; diff --git a/gui/src/pages/CompatibilityMatrix.tsx b/gui/src/pages/CompatibilityMatrix.tsx new file mode 100644 index 000000000..f5efe6ea7 --- /dev/null +++ b/gui/src/pages/CompatibilityMatrix.tsx @@ -0,0 +1,597 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { IconRefresh } from "../icons"; +import { useI18n, type TKey } from "../i18n/shared"; +import { labSupplement, type LabSupplementKey } from "../i18n/lab-translations"; +import { EmptyState, Notice, Select } from "../ui"; +import { useDataSurface } from "../data-surface"; +import { DataSurfaceSkeleton, DataSurfaceStatus } from "../components/data-surface"; +import { + fetchLabPageData, + fetchMoreVerdicts, + fetchVerdictDetail, + type LabPageData, + type VerdictDetailData, +} from "./compatibility-matrix-api"; +import { + COMPATIBILITY_VERDICTS, + EVIDENCE_LAYERS, + buildMatrixRows, + formatAsOf, + shortSubjectId, + verdictQueryFromFilters, + type ArtifactStatus, + type CompatibilityVerdict, + type EvidenceLayer, + type VerdictDto, + type VerdictFilters, +} from "./compatibility-matrix-shared"; + +const LAYER_LABEL: Record = { + protocol_conformance: "lab.layer.protocol_conformance", + live_route_compatibility: "lab.layer.live_route_compatibility", + task_effectiveness: "lab.layer.task_effectiveness", +}; + +const LAYER_COLUMN: Record = { + protocol_conformance: "lab.col.protocol", + live_route_compatibility: "lab.col.live", + task_effectiveness: "lab.col.task", +}; + +const VERDICT_LABEL: Record = { + UNKNOWN: "lab.verdict.UNKNOWN", + CLAIMED: "lab.verdict.CLAIMED", + PROBED: "lab.verdict.PROBED", + VERIFIED: "lab.verdict.VERIFIED", + DEGRADED: "lab.verdict.DEGRADED", + BLOCKED: "lab.verdict.BLOCKED", + UNSUPPORTED: "lab.verdict.UNSUPPORTED", +}; + +const ARTIFACT_STATUS_LABEL: Record = { + present: "artifact.present", + corrupt: "artifact.corrupt", + purged_unavailable: "artifact.purged_unavailable", +}; + +type ExtraVerdictPage = { + baseData: LabPageData; + queryKey: string; + verdicts: VerdictDto[]; + nextCursor?: string; + hasMore: boolean; +}; + +function localizedFetchError(e: unknown, fallback: string): string { + if (!(e instanceof Error)) return fallback; + const msg = e.message; + if (msg === "Failed to fetch" || msg.includes("NetworkError") || msg.includes("network error")) { + return fallback; + } + return msg || fallback; +} + +function VerdictBadge({ + verdict, + caption, + label, + selected, + onSelect, +}: { + verdict: CompatibilityVerdict; + caption: string; + label: string; + selected?: boolean; + onSelect?: () => void; +}) { + const className = `lab-verdict-badge${selected ? " lab-verdict-badge--selected" : ""}`; + if (onSelect) { + return ( + + ); + } + return ( + + {label} + {caption} + + ); +} + +function VerdictCell({ + rows, + t, + selectedKey, + onSelect, +}: { + rows: VerdictDto[]; + t: (key: TKey) => string; + selectedKey: string | null; + onSelect: (verdict: VerdictDto) => void; +}) { + if (rows.length === 0) return -; + return ( +
+ {rows.map(row => ( + onSelect(row)} + /> + ))} +
+ ); +} + +function StatusCards({ data, t, locale }: { + data: LabPageData; + t: (key: TKey, vars?: Record) => string; + locale: string; +}) { + const { status } = data; + const cards: Array<{ label: string; value: string }> = [ + { label: t("lab.subjectCount"), value: String(status.subjectCount ?? 0) }, + { label: t("lab.verdictCount"), value: String(status.verdictCount ?? 0) }, + { label: t("lab.observationCount"), value: String(status.observationCount ?? 0) }, + { label: t("lab.eventCount"), value: String(status.eventCount ?? 0) }, + ]; + if (status.builtAtMs) cards.push({ label: t("lab.builtAt"), value: formatAsOf(status.builtAtMs, locale) }); + return ( +
+ {cards.map(card => ( +
+ {card.label} + {card.value} +
+ ))} +
+ ); +} + +function DetailPane({ + verdict, + detail, + loading, + error, + t, + locale, + onClose, +}: { + verdict: VerdictDto; + detail: VerdictDetailData | null; + loading: boolean; + error: string | null; + t: (key: TKey, vars?: Record) => string; + locale: Parameters[0]; + onClose: () => void; +}) { + return ( + + ); +} + +export default function CompatibilityMatrix({ + apiBase, + active = true, + onCountChange, +}: { + apiBase: string; + active?: boolean; + onCountChange?: (count: number | null) => void; +}) { + const { t, locale } = useI18n(); + const [filters, setFilters] = useState({ layer: "", verdict: "", subjectQuery: "", suiteId: "" }); + const [extraPage, setExtraPage] = useState(null); + const [loadingMore, setLoadingMore] = useState(false); + const [selectedVerdict, setSelectedVerdict] = useState(null); + const [detail, setDetail] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const [detailError, setDetailError] = useState(null); + + const loadMoreRef = useRef(null); + const detailRequestRef = useRef(null); + const selectedKeyRef = useRef(null); + + const queryFilters = useMemo(() => verdictQueryFromFilters(filters), [filters]); + const queryKey = JSON.stringify(queryFilters); + + const fetchPage = useCallback( + (signal: AbortSignal) => fetchLabPageData(apiBase, queryFilters, signal), + [apiBase, queryFilters], + ); + const resourceKey = `lab-matrix:${apiBase}:${queryKey}`; + const surface = useDataSurface(resourceKey, [apiBase, queryKey], fetchPage, { + isEmpty: (data: LabPageData) => data.verdicts.length === 0, + pollMs: 60_000, + enabled: active, + pauseWhenHidden: true, + }); + + const resetPagination = useCallback(() => { + loadMoreRef.current?.abort(); + loadMoreRef.current = null; + setExtraPage(null); + setLoadingMore(false); + }, []); + + const clearDetail = useCallback(() => { + selectedKeyRef.current = null; + detailRequestRef.current?.abort(); + detailRequestRef.current = null; + setSelectedVerdict(null); + setDetail(null); + setDetailError(null); + setDetailLoading(false); + }, []); + + useEffect(() => { + // A refreshed first page makes any in-flight cursor request stale. The associated + // appended-page state is identity-bound below, so it becomes invisible immediately + // without synchronously cascading state from this effect. + loadMoreRef.current?.abort(); + }, [surface.data]); + + useEffect(() => () => { + loadMoreRef.current?.abort(); + detailRequestRef.current?.abort(); + }, []); + + const updateFilters = useCallback((updater: (current: VerdictFilters) => VerdictFilters) => { + clearDetail(); + resetPagination(); + setFilters(updater); + }, [clearDetail, resetPagination]); + + const validExtraPage = extraPage !== null + && extraPage.baseData === surface.data + && extraPage.queryKey === queryKey + ? extraPage + : null; + + const reportedCount = useMemo(() => { + if (!active || !surface.data?.status.projectionAvailable) return null; + const total = surface.data.status.verdictCount; + return typeof total === "number" + ? total + : surface.data.verdicts.length + (validExtraPage?.verdicts.length ?? 0); + }, [active, surface.data, validExtraPage]); + + useEffect(() => { onCountChange?.(reportedCount); }, [onCountChange, reportedCount]); + + const allVerdicts = useMemo( + () => surface.data ? [...surface.data.verdicts, ...(validExtraPage?.verdicts ?? [])] : [], + [surface.data, validExtraPage], + ); + const matrixRows = useMemo( + () => surface.data ? buildMatrixRows(allVerdicts, surface.data.subjects) : [], + [allVerdicts, surface.data], + ); + + const loadMore = useCallback(async () => { + const cursor = validExtraPage?.nextCursor ?? surface.data?.nextCursor; + const baseData = surface.data; + if (!cursor || !baseData || loadingMore) return; + loadMoreRef.current?.abort(); + const controller = new AbortController(); + loadMoreRef.current = controller; + const startedKey = queryKey; + setLoadingMore(true); + try { + const page = await fetchMoreVerdicts(apiBase, queryFilters, cursor, controller.signal); + if (controller.signal.aborted) return; + setExtraPage(current => { + const existing = current?.baseData === baseData && current.queryKey === startedKey + ? current.verdicts + : []; + return { + baseData, + queryKey: startedKey, + verdicts: [...existing, ...page.verdicts], + nextCursor: page.nextCursor, + hasMore: page.hasMore, + }; + }); + } catch { + // Keep the current rows. The normal refresh action retries from a consistent first page. + } finally { + if (loadMoreRef.current === controller) { + loadMoreRef.current = null; + setLoadingMore(false); + } + } + }, [apiBase, loadingMore, queryFilters, queryKey, surface.data, validExtraPage]); + + const selectVerdict = useCallback(async (verdict: VerdictDto) => { + if (!active) return; + detailRequestRef.current?.abort(); + const controller = new AbortController(); + detailRequestRef.current = controller; + selectedKeyRef.current = verdict.projectionKey; + setSelectedVerdict(verdict); + setDetail(null); + setDetailError(null); + setDetailLoading(true); + try { + const loaded = await fetchVerdictDetail(apiBase, verdict, controller.signal); + if (!controller.signal.aborted && selectedKeyRef.current === verdict.projectionKey) setDetail(loaded); + } catch (e) { + if (!controller.signal.aborted && selectedKeyRef.current === verdict.projectionKey) { + setDetailError(localizedFetchError(e, t("lab.detailLoadFailed"))); + } + } finally { + if (detailRequestRef.current === controller) { + detailRequestRef.current = null; + if (!controller.signal.aborted && selectedKeyRef.current === verdict.projectionKey) setDetailLoading(false); + } + } + }, [active, apiBase, t]); + + const refreshSurface = surface.refresh; + const refreshMatrix = useCallback(() => { + clearDetail(); + resetPagination(); + refreshSurface({ forceLoading: true }); + }, [clearDetail, refreshSurface, resetPagination]); + + const visibleSelection = active ? selectedVerdict : null; + const pageHasMore = validExtraPage ? validExtraPage.hasMore : (surface.data?.hasMore ?? false); + const layerOptions = [ + { value: "", label: t("lab.filter.all") }, + ...EVIDENCE_LAYERS.map(layer => ({ value: layer, label: t(LAYER_LABEL[layer]) })), + ]; + const verdictOptions = [ + { value: "", label: t("lab.filter.all") }, + ...COMPATIBILITY_VERDICTS.map(verdict => ({ value: verdict, label: t(VERDICT_LABEL[verdict]) })), + ]; + const subjectOptions = [ + { value: "", label: t("lab.filter.all") }, + ...(surface.data?.subjects ?? []).map(subject => ({ + value: subject.subjectId, + label: `${shortSubjectId(subject.subjectId)} · ${subject.subjectKind}`, + })), + ]; + + if (surface.state.showSkeleton) return ; + + const loadError = surface.state.showError ? localizedFetchError(surface.error, t("lab.loadFailed")) : null; + const status = surface.data?.status; + const projectionUnavailable = status && !status.projectionAvailable; + const projectionIncompatible = status?.projectionIncompatible === true; + + return ( +
+
+ +
+ + {surface.refreshing && !surface.state.showSkeleton && ( + {t("common.loading")} + )} + {loadError && {loadError}} + {projectionIncompatible && {t("lab.projectionIncompatible")}} + {projectionUnavailable && !projectionIncompatible && } + + {surface.data && status?.projectionAvailable && !projectionIncompatible && ( +
+
+ + +
+
+ + updateFilters(current => ({ ...current, verdict: value as CompatibilityVerdict | "" }))} + label={t("lab.filter.verdict")} + portal={false} + /> +
+
+ +