From 0cc26f828efff424a19bc0196317d6bdc362e0a8 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:08:59 +0200 Subject: [PATCH 1/6] feat(gui): add API Keys workspace view (rail + main, classic toggle) Adds a workspace layout for the API Keys tab mirroring the Providers workspace DNA: a left rail listing active keys (name + prefix + created date), and a main pane that shows either the overview (endpoint, generate form, usage example) or a per-key detail view with a two-step delete. The classic view is preserved and toggled via a button in the page head, with the preference persisted to localStorage (ocx-apikeys-view), matching the existing Providers workspace/classic pattern. - New ApiKeysWorkspace component + isolated stylesheet (awi- namespace) - i18n keys added across en/de/ko/zh/ru/ja (compile-checked against en) - handleCreate now accepts an optional name so both views share it - No behavior change to the underlying /api/keys flow --- .../apikeys-workspace/ApiKeysWorkspace.tsx | 202 +++++++++++ gui/src/i18n/de.ts | 7 + gui/src/i18n/en.ts | 7 + gui/src/i18n/ja.ts | 7 + gui/src/i18n/ko.ts | 7 + gui/src/i18n/ru.ts | 7 + gui/src/i18n/zh.ts | 7 + gui/src/pages/ApiKeys.tsx | 53 ++- gui/src/styles-apikeys-workspace.css | 320 ++++++++++++++++++ gui/src/styles.css | 1 + 10 files changed, 615 insertions(+), 3 deletions(-) create mode 100644 gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx create mode 100644 gui/src/styles-apikeys-workspace.css diff --git a/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx b/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx new file mode 100644 index 000000000..77745a446 --- /dev/null +++ b/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx @@ -0,0 +1,202 @@ +/** + * ApiKeysWorkspace — rail + main workspace for the API Keys tab, mirroring the + * Providers workspace DNA. Left rail lists active keys; the main pane shows either + * the overview (endpoint, generate form, usage example) or a per-key detail view. + */ +import { useState } from "react"; +import { IconCheck, IconPlus, IconTrash, IconX } from "../../icons"; +import { useT } from "../../i18n"; + +export interface ApiKeyEntry { + id: string; + name: string; + prefix: string; + createdAt: string; +} + +export interface ApiKeysWorkspaceProps { + keys: ApiKeyEntry[]; + endpoint: string; + localeTag?: string; + creating: boolean; + newKey: string | null; + copied: boolean; + onCreate: (name: string) => void; + onDismissNewKey: () => void; + onCopyNewKey: () => void; + onDelete: (id: string) => void; +} + +function formatCreatedDate(iso: string, localeTag?: string): string { + return new Date(iso).toLocaleDateString(localeTag); +} + +export default function ApiKeysWorkspace({ + keys, + endpoint, + localeTag, + creating, + newKey, + copied, + onCreate, + onDismissNewKey, + onCopyNewKey, + onDelete, +}: ApiKeysWorkspaceProps) { + const t = useT(); + const [selectedId, setSelectedId] = useState(null); + const [newName, setNewName] = useState(""); + const [confirmDelete, setConfirmDelete] = useState(false); + + const selected = keys.find(k => k.id === selectedId) ?? null; + + const handleCreate = () => { + onCreate(newName); + setNewName(""); + }; + + const handleDeleteClick = () => { + if (!selected) return; + if (!confirmDelete) { + setConfirmDelete(true); + return; + } + onDelete(selected.id); + setConfirmDelete(false); + setSelectedId(null); + }; + + return ( +
+
+ + +
+ {selected ? ( +
+
+

{selected.name}

+ + {confirmDelete ? ( + <> + + + + ) : ( + + )} + +
+ {confirmDelete && ( +

{t("api.workspace.deleteConfirm")}

+ )} +
+

{t("api.workspace.keyDetails")}

+
+
+
{t("api.colName")}
+
{selected.name}
+
+
+
{t("api.workspace.keyPrefix")}
+
{selected.prefix}
+
+
+
{t("api.colCreated")}
+
{formatCreatedDate(selected.createdAt, localeTag)}
+
+
+
+
+ ) : ( + <> + {keys.length > 0 && ( +
{t("api.workspace.selectKeyHint")}
+ )} + + {newKey && ( +
+

{t("api.newKeyTitle")}

+

{t("api.newKeyNote")}

+
+ {newKey} + + +
+
+ )} + +
+

{t("api.endpoint")}

+ {endpoint} +

{t("api.endpointNote")}

+
+ +
+

{t("api.generateTitle")}

+
+ setNewName(e.target.value)} + onKeyDown={e => { if (e.key === "Enter") handleCreate(); }} + /> + +
+
+ +
+

{t("api.usageTitle")}

+
{`curl ${endpoint} \\
+  -H "Authorization: Bearer ocx_YOUR_KEY_HERE" \\
+  -H "Content-Type: application/json" \\
+  -d '{
+    "model": "gpt-5.4",
+    "input": "Hello, world!"
+  }'`}
+
+ + )} +
+
+
+ ); +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 23b7e6355..3ff34870e 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -715,6 +715,13 @@ export const de = { "api.confirm": "Bestätigen", "api.deleteAria": "API-Schlüssel löschen", "api.usageTitle": "Nutzungsbeispiel", + "api.workspace.overview": "Übersicht", + "api.workspace.keyDetails": "Schlüsseldetails", + "api.workspace.keyPrefix": "Schlüssel-Präfix", + "api.workspace.deleteKey": "Schlüssel löschen", + "api.workspace.deleteConfirm": "Möchten Sie diesen Schlüssel wirklich löschen? Dies kann nicht rückgängig gemacht werden.", + "api.workspace.noKeysHint": "Noch keine API-Schlüssel. Erstellen Sie einen, um zu beginnen.", + "api.workspace.selectKeyHint": "Wählen Sie einen Schlüssel aus der Liste, um seine Details anzuzeigen.", // Claude Code inbound "nav.claude": "Claude", "claude.subtitle": "GPT, Gemini und andere Modelle in Claude Code verwenden.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 772cc84d3..bf7773435 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1022,6 +1022,13 @@ export const en = { "api.confirm": "Confirm", "api.deleteAria": "Delete API key", "api.usageTitle": "Usage example", + "api.workspace.overview": "Overview", + "api.workspace.keyDetails": "Key details", + "api.workspace.keyPrefix": "Key prefix", + "api.workspace.deleteKey": "Delete key", + "api.workspace.deleteConfirm": "Are you sure you want to delete this key? This cannot be undone.", + "api.workspace.noKeysHint": "No API keys yet. Generate one to get started.", + "api.workspace.selectKeyHint": "Select a key from the list to view its details.", // Claude Code inbound "nav.claude": "Claude", "claude.subtitle": "Use GPT, Gemini, and other models inside Claude Code.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 2adafa1de..f241382f1 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -977,6 +977,13 @@ export const ja: Record = { "api.confirm": "確認", "api.deleteAria": "API キーを削除", "api.usageTitle": "使用例", + "api.workspace.overview": "概要", + "api.workspace.keyDetails": "キーの詳細", + "api.workspace.keyPrefix": "キーのプレフィックス", + "api.workspace.deleteKey": "キーを削除", + "api.workspace.deleteConfirm": "このキーを削除してもよろしいですか?この操作は元に戻せません。", + "api.workspace.noKeysHint": "API キーがまだありません。作成して始めましょう。", + "api.workspace.selectKeyHint": "一覧からキーを選択すると、詳細が表示されます。", // Claude Code inbound "nav.claude": "Claude", "claude.subtitle": "Claude Code 内で GPT、Gemini などのモデルを使用します。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 777ed16c9..5760e3cc7 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -737,6 +737,13 @@ export const ko: Record = { "api.confirm": "확인", "api.deleteAria": "API 키 삭제", "api.usageTitle": "사용 예시", + "api.workspace.overview": "개요", + "api.workspace.keyDetails": "키 세부 정보", + "api.workspace.keyPrefix": "키 접두사", + "api.workspace.deleteKey": "키 삭제", + "api.workspace.deleteConfirm": "이 키를 삭제하시겠습니까? 되돌릴 수 없습니다.", + "api.workspace.noKeysHint": "아직 API 키가 없습니다. 키를 생성하여 시작하세요.", + "api.workspace.selectKeyHint": "목록에서 키를 선택하여 세부 정보를 확인하세요.", // Claude Code inbound "nav.claude": "Claude", "claude.subtitle": "Claude Code에서 GPT, Gemini 등 다른 모델도 쓸 수 있게 해줍니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index e3efe039a..b7cf27c28 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1022,6 +1022,13 @@ export const ru: Record = { "api.confirm": "Подтвердить", "api.deleteAria": "Удалить API-ключ", "api.usageTitle": "Пример использования", + "api.workspace.overview": "Обзор", + "api.workspace.keyDetails": "Сведения о ключе", + "api.workspace.keyPrefix": "Префикс ключа", + "api.workspace.deleteKey": "Удалить ключ", + "api.workspace.deleteConfirm": "Вы уверены, что хотите удалить этот ключ? Это действие нельзя отменить.", + "api.workspace.noKeysHint": "API-ключей пока нет. Создайте один, чтобы начать.", + "api.workspace.selectKeyHint": "Выберите ключ из списка, чтобы просмотреть его сведения.", // Claude Code inbound "nav.claude": "Claude", "claude.subtitle": "Используйте GPT, Gemini и другие модели внутри Claude Code.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 9b2e162d0..b36a09482 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -737,6 +737,13 @@ export const zh: Record = { "api.confirm": "确认", "api.deleteAria": "删除 API 密钥", "api.usageTitle": "使用示例", + "api.workspace.overview": "概览", + "api.workspace.keyDetails": "密钥详情", + "api.workspace.keyPrefix": "密钥前缀", + "api.workspace.deleteKey": "删除密钥", + "api.workspace.deleteConfirm": "确定要删除此密钥吗?此操作无法撤销。", + "api.workspace.noKeysHint": "还没有 API 密钥。生成一个以开始使用。", + "api.workspace.selectKeyHint": "从列表中选择一个密钥以查看其详情。", // Claude Code inbound "nav.claude": "Claude", "claude.subtitle": "在 Claude Code 中使用 GPT、Gemini 等其他模型。", diff --git a/gui/src/pages/ApiKeys.tsx b/gui/src/pages/ApiKeys.tsx index 5046643c4..bec92bd7f 100644 --- a/gui/src/pages/ApiKeys.tsx +++ b/gui/src/pages/ApiKeys.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import { IconPlus, IconX, IconCheck } from "../icons"; import { useI18n, LOCALES } from "../i18n/shared"; +import ApiKeysWorkspace from "../components/apikeys-workspace/ApiKeysWorkspace"; interface ApiKeyEntry { id: string; @@ -23,6 +24,23 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { const [newKey, setNewKey] = useState(null); const [copied, setCopied] = useState(false); const [confirmDelete, setConfirmDelete] = useState(null); + // Workspace vs Classic: localStorage is the source of truth (same pattern as Providers). + const [workspaceView, setWorkspaceView] = useState(() => { + try { + return localStorage.getItem("ocx-apikeys-view") === "workspace"; + } catch { + return false; + } + }); + const toggleWorkspace = () => { + const next = !workspaceView; + try { + localStorage.setItem("ocx-apikeys-view", next ? "workspace" : "classic"); + } catch { + /* ignore */ + } + setWorkspaceView(next); + }; const fetchKeys = useCallback(async () => { try { @@ -44,13 +62,14 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { const responseEndpoint = endpoint || "http://127.0.0.1:10100/v1/responses"; - const handleCreate = async () => { + const handleCreate = async (name?: string) => { + const effectiveName = name ?? newName; setCreating(true); try { const res = await fetch(`${apiBase}/api/keys`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: newName || "default" }), + body: JSON.stringify({ name: effectiveName || "default" }), }); if (res.ok) { const data = await res.json(); @@ -84,10 +103,38 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { // Subtitle carries two inline chips; split the localized string on both tokens. const subtitleParts = t("api.subtitle").split(/\{authHeader\}|\{altHeader\}/); + if (workspaceView) { + return ( +
+
+

{t("api.title")}

+
+ +
+
+ { void handleCreate(name); }} + onDismissNewKey={() => setNewKey(null)} + onCopyNewKey={copyKey} + onDelete={id => { void handleDelete(id); }} + /> +
+ ); + } + return (

{t("api.title")}

+
+ +

{subtitleParts[0]} @@ -131,7 +178,7 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { onChange={e => setNewName(e.target.value)} className="input" /> - diff --git a/gui/src/styles-apikeys-workspace.css b/gui/src/styles-apikeys-workspace.css new file mode 100644 index 000000000..9c4360c9d --- /dev/null +++ b/gui/src/styles-apikeys-workspace.css @@ -0,0 +1,320 @@ +/* ============================================================================ + apikeys-workspace — isolated stylesheet (mirrors providers-workspace layout DNA) + Namespace: apikeys-workspace- | widgets: awi- + Uses only design tokens from styles.css. No gradients. + ============================================================================ */ + +.main-inner:has(.apikeys-workspace-shell) { + max-width: 1200px; +} + +.apikeys-workspace-shell { + width: 100%; + min-width: 0; +} + +.apikeys-workspace-root { + display: grid; + grid-template-columns: minmax(280px, 320px) minmax(0, 1fr); + gap: var(--space-4); + width: 100%; + max-width: 100%; + min-height: 480px; +} + +/* ── Rail ─────────────────────────────────────────────── */ + +.apikeys-workspace-rail { + display: flex; + flex-direction: column; + gap: var(--space-3); + border-right: 1px solid var(--border); + padding-right: var(--space-3); + min-width: 0; +} + +.apikeys-workspace-rail-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} + +.apikeys-workspace-rail-title { + font-size: var(--text-body); + font-weight: var(--weight-semibold); + color: var(--text); +} + +.apikeys-workspace-rail-count { + font-family: var(--mono); + font-size: 11px; + color: var(--faint); + font-variant-numeric: tabular-nums; +} + +.apikeys-workspace-rail-list { + display: flex; + flex-direction: column; + gap: 2px; + overflow-y: auto; + max-height: 560px; + min-height: 0; +} + +.apikeys-workspace-rail-row { + display: flex; + flex-direction: column; + gap: var(--space-0-5); + width: 100%; + min-height: 46px; + appearance: none; + background: none; + border: none; + border-radius: var(--radius-sm); + padding: var(--space-1-5) var(--space-2); + cursor: pointer; + text-align: left; + color: inherit; + font: inherit; + overflow: hidden; + transition: background var(--motion-fast), box-shadow var(--motion-fast); +} + +.apikeys-workspace-rail-row:hover { + background: var(--surface); +} + +.apikeys-workspace-rail-row--selected { + background: var(--accent-soft); + box-shadow: inset 0 0 0 1px var(--border); +} + +.apikeys-workspace-rail-row:focus-visible { + outline: 2px solid var(--accent-ring); + outline-offset: -2px; +} + +.apikeys-workspace-rail-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--text-body); + font-weight: var(--weight-medium); + line-height: var(--leading-ui); +} + +.apikeys-workspace-rail-meta { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--text-caption); + line-height: var(--leading-ui); + color: var(--muted); + font-family: var(--mono); +} + +.apikeys-workspace-rail-empty { + font-size: 12px; + padding: 8px 4px; + color: var(--muted); +} + +/* ── Main ─────────────────────────────────────────────── */ + +.apikeys-workspace-main { + min-width: 0; + max-width: 100%; + overflow: hidden; + padding: 0 var(--space-2); +} + +.awi-section { + margin-bottom: 20px; +} + +.awi-section-title { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted); + margin: 0 0 8px 0; +} + +.awi-endpoint { + display: block; + font-family: var(--mono); + font-size: 0.82rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 10px 12px; + word-break: break-all; + color: var(--text); +} + +.awi-endpoint-note { + font-size: 12px; + color: var(--muted); + margin-top: 6px; +} + +.awi-generate-row { + display: flex; + align-items: center; + gap: 8px; +} + +.awi-generate-row .input { + flex: 1 1 auto; + min-width: 0; +} + +.awi-newkey { + border: 1px solid var(--accent); + background: var(--accent-soft); + border-radius: var(--radius-sm); + padding: 12px; + margin-bottom: 20px; +} + +.awi-newkey-title { + font-weight: var(--weight-semibold); + margin: 0 0 4px 0; + font-size: var(--text-body); +} + +.awi-newkey-note { + font-size: 12px; + color: var(--muted); + margin: 0 0 10px 0; +} + +.awi-newkey-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.awi-newkey-code { + flex: 1 1 auto; + min-width: 0; + font-family: var(--mono); + font-size: 0.8rem; + word-break: break-all; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 8px 10px; +} + +.awi-kv { + display: flex; + flex-direction: column; + margin: 0; +} + +.awi-kv-row { + display: flex; + gap: 12px; + padding: 5px 0; + font-size: 0.84rem; + line-height: 1.4; +} + +.awi-kv-row dt { + flex-shrink: 0; + width: 130px; + color: var(--muted); + font-weight: 400; +} + +.awi-kv-row dd { + margin: 0; + flex: 1; + min-width: 0; + word-break: break-word; +} + +.awi-kv-row dd code { + font-size: 0.8rem; +} + +.awi-detail-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; +} + +.awi-detail-title { + font-size: 1.15rem; + font-weight: 600; + margin: 0; + min-width: 0; + word-break: break-all; +} + +.awi-detail-actions { + display: inline-flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.awi-usage-pre { + font-family: var(--mono); + font-size: 0.78rem; + line-height: 1.5; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 12px; + overflow-x: auto; + color: var(--text); + margin: 0; +} + +.awi-empty { + border: 1px dashed var(--border); + border-radius: var(--radius-sm); + padding: 32px 20px; + text-align: center; + color: var(--muted); + font-size: 13px; +} + +.awi-hint { + display: flex; + flex-direction: column; + gap: 8px; + padding: 24px 8px; + color: var(--muted); + font-size: 13px; +} + +/* ── Responsive ───────────────────────────────────────── */ + +@media (max-width: 768px) { + .apikeys-workspace-root { + grid-template-columns: minmax(0, 1fr); + min-height: auto; + } + .apikeys-workspace-rail { + border-right: none; + padding-right: 0; + border-bottom: 1px solid var(--border); + padding-bottom: var(--space-3); + } + .apikeys-workspace-rail-list { + max-height: 320px; + } + .apikeys-workspace-main { + padding: var(--space-2) 0 0; + } +} diff --git a/gui/src/styles.css b/gui/src/styles.css index dc4b7bff6..89e4d020c 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -17,6 +17,7 @@ @import "./styles-dashboard-workspace.css"; @import "./styles-subagents-workspace.css"; @import "./styles-usage-workspace.css"; +@import "./styles-apikeys-workspace.css"; :root { /* default: follow the OS; [data-theme] below pins color-scheme so light-dark() obeys it */ From f3e73851e07d2644ba26eb80f0de4264e07207f3 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:35:07 +0200 Subject: [PATCH 2/6] gui: drop per-tab apikeys toggle (sidebar owns it) --- gui/src/pages/ApiKeys.tsx | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/gui/src/pages/ApiKeys.tsx b/gui/src/pages/ApiKeys.tsx index bec92bd7f..e9a881916 100644 --- a/gui/src/pages/ApiKeys.tsx +++ b/gui/src/pages/ApiKeys.tsx @@ -25,22 +25,13 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { const [copied, setCopied] = useState(false); const [confirmDelete, setConfirmDelete] = useState(null); // Workspace vs Classic: localStorage is the source of truth (same pattern as Providers). - const [workspaceView, setWorkspaceView] = useState(() => { + const [workspaceView] = useState(() => { try { return localStorage.getItem("ocx-apikeys-view") === "workspace"; } catch { return false; } }); - const toggleWorkspace = () => { - const next = !workspaceView; - try { - localStorage.setItem("ocx-apikeys-view", next ? "workspace" : "classic"); - } catch { - /* ignore */ - } - setWorkspaceView(next); - }; const fetchKeys = useCallback(async () => { try { @@ -108,9 +99,6 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) {

{t("api.title")}

-
- -

{t("api.title")}

-
- -

{subtitleParts[0]} From b9bfcdfddd5647d3207bfc48c1cde12ca9bfac9b Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:31:55 +0200 Subject: [PATCH 3/6] fix(gui): wire API Keys workspace to global viewMode Use the sidebar Classic/Workspace preference instead of the legacy per-page ocx-apikeys-view localStorage key. --- gui/src/App.tsx | 2 +- gui/src/pages/ApiKeys.tsx | 12 +++--------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 657d67994..0f3daa799 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -284,7 +284,7 @@ export default function App() { {page === "usage" && } {page === "storage" && } {page === "codex-auth" && } - {page === "api" && } + {page === "api" && } {page === "claude" && } diff --git a/gui/src/pages/ApiKeys.tsx b/gui/src/pages/ApiKeys.tsx index e9a881916..971824370 100644 --- a/gui/src/pages/ApiKeys.tsx +++ b/gui/src/pages/ApiKeys.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import { IconPlus, IconX, IconCheck } from "../icons"; import { useI18n, LOCALES } from "../i18n/shared"; +import { readViewMode, type ViewMode } from "../view-mode"; import ApiKeysWorkspace from "../components/apikeys-workspace/ApiKeysWorkspace"; interface ApiKeyEntry { @@ -14,7 +15,7 @@ function formatCreatedDate(iso: string, localeTag?: string): string { return new Date(iso).toLocaleDateString(localeTag); } -export default function ApiKeys({ apiBase }: { apiBase: string }) { +export default function ApiKeys({ apiBase, viewMode }: { apiBase: string; viewMode?: ViewMode }) { const { t, locale } = useI18n(); const localeTag = LOCALES.find(l => l.code === locale)?.htmlLang; const [keys, setKeys] = useState([]); @@ -24,14 +25,7 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { const [newKey, setNewKey] = useState(null); const [copied, setCopied] = useState(false); const [confirmDelete, setConfirmDelete] = useState(null); - // Workspace vs Classic: localStorage is the source of truth (same pattern as Providers). - const [workspaceView] = useState(() => { - try { - return localStorage.getItem("ocx-apikeys-view") === "workspace"; - } catch { - return false; - } - }); + const workspaceView = (viewMode ?? readViewMode()) === "workspace"; const fetchKeys = useCallback(async () => { try { From 7ef666d29e17f85e05b3fe7d66a4a61fece6787d Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:46:06 +0200 Subject: [PATCH 4/6] fix(gui): address API Keys workspace review findings Restore an Overview path, avoid nested main landmarks, stack via container queries, guard create against Enter repeats, clear the draft name only after successful create, localize the sample input, and replace deprecated word-break. --- .../apikeys-workspace/ApiKeysWorkspace.tsx | 55 +++++++++++++++---- gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/src/pages/ApiKeys.tsx | 20 ++++--- gui/src/styles-apikeys-workspace.css | 28 +++++++++- gui/tests/apikeys-workspace.test.ts | 37 +++++++++++++ 10 files changed, 124 insertions(+), 22 deletions(-) create mode 100644 gui/tests/apikeys-workspace.test.ts diff --git a/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx b/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx index 77745a446..15f9d5631 100644 --- a/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx +++ b/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx @@ -1,9 +1,10 @@ /** * ApiKeysWorkspace — rail + main workspace for the API Keys tab, mirroring the - * Providers workspace DNA. Left rail lists active keys; the main pane shows either - * the overview (endpoint, generate form, usage example) or a per-key detail view. + * Providers workspace DNA. Left rail lists Overview + active keys; the main pane + * shows either the overview (endpoint, generate form, usage example) or a per-key + * detail view. */ -import { useState } from "react"; +import { useRef, useState } from "react"; import { IconCheck, IconPlus, IconTrash, IconX } from "../../icons"; import { useT } from "../../i18n"; @@ -21,7 +22,8 @@ export interface ApiKeysWorkspaceProps { creating: boolean; newKey: string | null; copied: boolean; - onCreate: (name: string) => void; + /** Resolves true only after a successful create so the draft name can be cleared. */ + onCreate: (name: string) => Promise; onDismissNewKey: () => void; onCopyNewKey: () => void; onDelete: (id: string) => void; @@ -47,12 +49,19 @@ export default function ApiKeysWorkspace({ const [selectedId, setSelectedId] = useState(null); const [newName, setNewName] = useState(""); const [confirmDelete, setConfirmDelete] = useState(false); + const createInFlight = useRef(false); const selected = keys.find(k => k.id === selectedId) ?? null; - const handleCreate = () => { - onCreate(newName); - setNewName(""); + const handleCreate = async () => { + if (creating || createInFlight.current) return; + createInFlight.current = true; + try { + const ok = await onCreate(newName); + if (ok) setNewName(""); + } finally { + createInFlight.current = false; + } }; const handleDeleteClick = () => { @@ -66,6 +75,11 @@ export default function ApiKeysWorkspace({ setSelectedId(null); }; + const showOverview = () => { + setSelectedId(null); + setConfirmDelete(false); + }; + return (

@@ -75,6 +89,14 @@ export default function ApiKeysWorkspace({ {keys.length}
+ {keys.length === 0 ? ( {t("api.workspace.noKeysHint")} ) : ( @@ -94,9 +116,12 @@ export default function ApiKeysWorkspace({
-
+
{selected ? (
+

{selected.name}

@@ -174,10 +199,16 @@ export default function ApiKeysWorkspace({ placeholder={t("api.keyNamePlaceholder")} aria-label={t("api.keyNamePlaceholder")} value={newName} + disabled={creating} onChange={e => setNewName(e.target.value)} - onKeyDown={e => { if (e.key === "Enter") handleCreate(); }} + onKeyDown={e => { + if (e.key === "Enter") { + e.preventDefault(); + void handleCreate(); + } + }} /> -
@@ -190,12 +221,12 @@ export default function ApiKeysWorkspace({ -H "Content-Type: application/json" \\ -d '{ "model": "gpt-5.4", - "input": "Hello, world!" + "input": ${JSON.stringify(t("api.usageSampleInput"))} }'`}
)} -
+
); diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 3ff34870e..920b9cbad 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -715,6 +715,7 @@ export const de = { "api.confirm": "Bestätigen", "api.deleteAria": "API-Schlüssel löschen", "api.usageTitle": "Nutzungsbeispiel", + "api.usageSampleInput": "Hallo, Welt!", "api.workspace.overview": "Übersicht", "api.workspace.keyDetails": "Schlüsseldetails", "api.workspace.keyPrefix": "Schlüssel-Präfix", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index bf7773435..630d72b70 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1022,6 +1022,7 @@ export const en = { "api.confirm": "Confirm", "api.deleteAria": "Delete API key", "api.usageTitle": "Usage example", + "api.usageSampleInput": "Hello, world!", "api.workspace.overview": "Overview", "api.workspace.keyDetails": "Key details", "api.workspace.keyPrefix": "Key prefix", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index f241382f1..c0cf7860f 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -977,6 +977,7 @@ export const ja: Record = { "api.confirm": "確認", "api.deleteAria": "API キーを削除", "api.usageTitle": "使用例", + "api.usageSampleInput": "こんにちは、世界!", "api.workspace.overview": "概要", "api.workspace.keyDetails": "キーの詳細", "api.workspace.keyPrefix": "キーのプレフィックス", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 5760e3cc7..05905b428 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -737,6 +737,7 @@ export const ko: Record = { "api.confirm": "확인", "api.deleteAria": "API 키 삭제", "api.usageTitle": "사용 예시", + "api.usageSampleInput": "안녕하세요, 세계!", "api.workspace.overview": "개요", "api.workspace.keyDetails": "키 세부 정보", "api.workspace.keyPrefix": "키 접두사", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index b7cf27c28..f60d894a4 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1022,6 +1022,7 @@ export const ru: Record = { "api.confirm": "Подтвердить", "api.deleteAria": "Удалить API-ключ", "api.usageTitle": "Пример использования", + "api.usageSampleInput": "Привет, мир!", "api.workspace.overview": "Обзор", "api.workspace.keyDetails": "Сведения о ключе", "api.workspace.keyPrefix": "Префикс ключа", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index b36a09482..7380fd8ea 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -737,6 +737,7 @@ export const zh: Record = { "api.confirm": "确认", "api.deleteAria": "删除 API 密钥", "api.usageTitle": "使用示例", + "api.usageSampleInput": "你好,世界!", "api.workspace.overview": "概览", "api.workspace.keyDetails": "密钥详情", "api.workspace.keyPrefix": "密钥前缀", diff --git a/gui/src/pages/ApiKeys.tsx b/gui/src/pages/ApiKeys.tsx index 971824370..20206d73c 100644 --- a/gui/src/pages/ApiKeys.tsx +++ b/gui/src/pages/ApiKeys.tsx @@ -47,7 +47,7 @@ export default function ApiKeys({ apiBase, viewMode }: { apiBase: string; viewMo const responseEndpoint = endpoint || "http://127.0.0.1:10100/v1/responses"; - const handleCreate = async (name?: string) => { + const handleCreate = async (name?: string): Promise => { const effectiveName = name ?? newName; setCreating(true); try { @@ -56,12 +56,14 @@ export default function ApiKeys({ apiBase, viewMode }: { apiBase: string; viewMo headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: effectiveName || "default" }), }); - if (res.ok) { - const data = await res.json(); - setNewKey(data.key); - setNewName(""); - fetchKeys(); - } + if (!res.ok) return false; + const data = await res.json(); + setNewKey(data.key); + setNewName(""); + fetchKeys(); + return true; + } catch { + return false; } finally { setCreating(false); } @@ -101,7 +103,7 @@ export default function ApiKeys({ apiBase, viewMode }: { apiBase: string; viewMo creating={creating} newKey={newKey} copied={copied} - onCreate={name => { void handleCreate(name); }} + onCreate={name => handleCreate(name)} onDismissNewKey={() => setNewKey(null)} onCopyNewKey={copyKey} onDelete={id => { void handleDelete(id); }} @@ -204,7 +206,7 @@ export default function ApiKeys({ apiBase, viewMode }: { apiBase: string; viewMo -H "Content-Type: application/json" \\ -d '{ "model": "gpt-5.4", - "input": "Hello, world!" + "input": ${JSON.stringify(t("api.usageSampleInput"))} }'`}
diff --git a/gui/src/styles-apikeys-workspace.css b/gui/src/styles-apikeys-workspace.css index 9c4360c9d..2eddf9fe1 100644 --- a/gui/src/styles-apikeys-workspace.css +++ b/gui/src/styles-apikeys-workspace.css @@ -11,6 +11,8 @@ .apikeys-workspace-shell { width: 100%; min-width: 0; + container-type: inline-size; + container-name: apikeys-workspace; } .apikeys-workspace-root { @@ -237,7 +239,7 @@ margin: 0; flex: 1; min-width: 0; - word-break: break-word; + overflow-wrap: anywhere; } .awi-kv-row dd code { @@ -252,6 +254,10 @@ margin-bottom: 16px; } +.awi-back { + margin-bottom: 8px; +} + .awi-detail-title { font-size: 1.15rem; font-weight: 600; @@ -299,6 +305,26 @@ } /* ── Responsive ───────────────────────────────────────── */ +/* Keep @container and @media stacking rules in sync when changing breakpoints. */ + +@container apikeys-workspace (max-width: 720px) { + .apikeys-workspace-root { + grid-template-columns: minmax(0, 1fr); + min-height: auto; + } + .apikeys-workspace-rail { + border-right: none; + padding-right: 0; + border-bottom: 1px solid var(--border); + padding-bottom: var(--space-3); + } + .apikeys-workspace-rail-list { + max-height: 320px; + } + .apikeys-workspace-main { + padding: var(--space-2) 0 0; + } +} @media (max-width: 768px) { .apikeys-workspace-root { diff --git a/gui/tests/apikeys-workspace.test.ts b/gui/tests/apikeys-workspace.test.ts new file mode 100644 index 000000000..72f7d860a --- /dev/null +++ b/gui/tests/apikeys-workspace.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from "bun:test"; + +test("ApiKeys uses global viewMode (no per-page toggle) and workspace shell", async () => { + const page = await Bun.file(new URL("../src/pages/ApiKeys.tsx", import.meta.url)).text(); + const app = await Bun.file(new URL("../src/App.tsx", import.meta.url)).text(); + const css = await Bun.file(new URL("../src/styles.css", import.meta.url)).text(); + + expect(page).toContain("viewMode"); + expect(page).toContain("readViewMode"); + expect(page).toContain("ApiKeysWorkspace"); + expect(page).toContain('workspaceView = (viewMode ?? readViewMode()) === "workspace"'); + expect(page).not.toContain("ocx-apikeys-view"); + expect(page).not.toContain("pws.workspaceToggle"); + expect(page).not.toContain("pws.classicToggle"); + + expect(app).toContain(""); + expect(css).toContain('@import "./styles-apikeys-workspace.css"'); +}); + +test("ApiKeys workspace avoids nested main and stacks via container query", async () => { + const src = await Bun.file(new URL("../src/components/apikeys-workspace/ApiKeysWorkspace.tsx", import.meta.url)).text(); + const css = await Bun.file(new URL("../src/styles-apikeys-workspace.css", import.meta.url)).text(); + + expect(src).toContain('
"); + expect(src).toContain('t("api.usageSampleInput")'); + + expect(css).toContain("container-name: apikeys-workspace"); + expect(css).toContain("container-type: inline-size"); + expect(css).toContain("@container apikeys-workspace (max-width: 720px)"); + expect(css).toContain("@media (max-width: 768px)"); + expect(css).toContain("overflow-wrap: anywhere"); + expect(css).not.toContain("word-break: break-word"); +}); From 1eecd372c30c132bced3dfd27e179d85124d0485 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:46:30 +0200 Subject: [PATCH 5/6] test(gui): align API Keys workspace create-guard assertion --- gui/tests/apikeys-workspace.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gui/tests/apikeys-workspace.test.ts b/gui/tests/apikeys-workspace.test.ts index 72f7d860a..9c860e07c 100644 --- a/gui/tests/apikeys-workspace.test.ts +++ b/gui/tests/apikeys-workspace.test.ts @@ -24,9 +24,10 @@ test("ApiKeys workspace avoids nested main and stacks via container query", asyn expect(src).toContain('
"); expect(src).toContain('t("api.usageSampleInput")'); + expect(src).toContain("createInFlight"); expect(css).toContain("container-name: apikeys-workspace"); expect(css).toContain("container-type: inline-size"); From 54ea2223e00f7902ac222c50dbfabf15a346d87b Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:50:59 +0200 Subject: [PATCH 6/6] fix(gui): harden API Keys workspace overview, create, and landmarks Add a reliable Overview rail path, parent-level single-flight create with IME-safe Enter handling, clear the draft name only after success, use a section landmark with localized details label, and cover the behaviors with component tests. --- .../apikeys-workspace/ApiKeysWorkspace.tsx | 18 +- gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/src/pages/ApiKeys.tsx | 13 +- gui/tests/apikeys-workspace.test.ts | 7 +- gui/tests/apikeys-workspace.test.tsx | 355 ++++++++++++++++++ 10 files changed, 387 insertions(+), 12 deletions(-) create mode 100644 gui/tests/apikeys-workspace.test.tsx diff --git a/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx b/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx index 15f9d5631..7b088b767 100644 --- a/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx +++ b/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx @@ -93,7 +93,7 @@ export default function ApiKeysWorkspace({ type="button" className={`apikeys-workspace-rail-row${selectedId === null ? " apikeys-workspace-rail-row--selected" : ""}`} onClick={showOverview} - aria-current={selectedId === null ? "true" : undefined} + aria-current={selectedId === null ? "page" : undefined} > {t("api.workspace.overview")} @@ -106,7 +106,7 @@ export default function ApiKeysWorkspace({ type="button" className={`apikeys-workspace-rail-row${selectedId === k.id ? " apikeys-workspace-rail-row--selected" : ""}`} onClick={() => { setSelectedId(k.id); setConfirmDelete(false); }} - aria-current={selectedId === k.id ? "true" : undefined} + aria-current={selectedId === k.id ? "page" : undefined} > {k.name} {k.prefix} · {formatCreatedDate(k.createdAt, localeTag)} @@ -116,7 +116,7 @@ export default function ApiKeysWorkspace({ -
+
{selected ? (