From 904e670e155e9dbf1d78c39ca9f45a2ea9571219 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:10:49 +0200 Subject: [PATCH 1/3] fix(gui): address Codex review on dense workspaces stack --- .../apikeys-workspace/ApiKeysWorkspace.tsx | 247 +++++ .../storage-workspace/StorageWorkspace.tsx | 212 +++++ .../SubagentsWorkspace.tsx | 234 +++++ gui/src/i18n/de.ts | 28 +- gui/src/i18n/en.ts | 28 +- gui/src/i18n/ja.ts | 28 +- gui/src/i18n/ko.ts | 28 +- gui/src/i18n/ru.ts | 28 +- gui/src/i18n/zh.ts | 28 +- gui/src/model-display.ts | 6 +- gui/src/pages/ApiKeys.tsx | 106 ++- gui/src/pages/Claude.tsx | 5 +- gui/src/pages/ClaudeCode.tsx | 125 ++- gui/src/pages/ClaudeDesktop.tsx | 34 +- gui/src/pages/Combos.tsx | 62 +- gui/src/pages/Debug.tsx | 34 +- gui/src/pages/Grok.tsx | 54 +- gui/src/pages/Logs.tsx | 103 ++- gui/src/pages/Models.tsx | 192 ++-- gui/src/pages/Storage.tsx | 863 +++++++++++------- gui/src/pages/Subagents.tsx | 124 +-- gui/src/pages/Usage.tsx | 237 ++++- gui/src/pages/api-keys-panels.tsx | 333 +++++-- gui/src/pages/claude-code-sections.tsx | 150 +-- gui/src/pages/claude-desktop-lane.ts | 5 +- gui/src/pages/models-shared.ts | 17 +- gui/src/styles-apikeys-workspace.css | 652 +++++++++++++ gui/src/styles-claudecode-workspace.css | 227 +++++ gui/src/styles-models-workspace.css | 144 ++- gui/src/styles-storage-workspace.css | 742 +++++++++++++++ gui/src/styles-subagents-workspace.css | 470 ++++++++++ gui/src/styles-usage-workspace.css | 181 ++++ gui/src/styles.css | 5 + gui/tests/apikeys-layout.test.ts | 91 +- gui/tests/apikeys-refresh-preserve.test.tsx | 10 +- gui/tests/apikeys-workspace.test.tsx | 182 ++++ gui/tests/claude-code-sidecar-draft.test.tsx | 1 + .../claude-desktop-row-disclosure.test.tsx | 2 + gui/tests/claude-desktop-vertical.test.tsx | 20 +- gui/tests/claudecode-layout.test.ts | 16 +- gui/tests/logs-surface-filter.test.ts | 21 + gui/tests/storage-loading-race.test.tsx | 29 +- gui/tests/subagents-busy-race.test.tsx | 21 +- gui/tests/subagents-classic.test.ts | 50 +- gui/tests/subagents-classic.test.tsx | 56 +- gui/tests/usage-layout.test.ts | 32 +- 46 files changed, 5294 insertions(+), 969 deletions(-) create mode 100644 gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx create mode 100644 gui/src/components/storage-workspace/StorageWorkspace.tsx create mode 100644 gui/src/components/subagents-workspace/SubagentsWorkspace.tsx create mode 100644 gui/src/styles-apikeys-workspace.css create mode 100644 gui/src/styles-claudecode-workspace.css create mode 100644 gui/src/styles-storage-workspace.css create mode 100644 gui/src/styles-subagents-workspace.css create mode 100644 gui/src/styles-usage-workspace.css create mode 100644 gui/tests/apikeys-workspace.test.tsx create mode 100644 gui/tests/logs-surface-filter.test.ts diff --git a/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx b/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx new file mode 100644 index 0000000000..0af01a1582 --- /dev/null +++ b/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx @@ -0,0 +1,247 @@ +/** + * ApiKeysWorkspace — rail + main for the API tab. Overview hosts the existing + * endpoint/auth/generate/models/usage panels; selecting a key opens detail. + */ +import { useState } from "react"; +import { IconChevron, IconTrash } from "../../icons"; +import { useT } from "../../i18n/shared"; +import type { ExternalModelRow } from "../../api-access-models"; +import { + formatCreatedDate, + type ApiEndpointInfo, + type ApiKeyEntry, + type ModelTestState, +} from "../../pages/api-keys-utils"; +import { + ApiKeysEndpointsPanel, + ApiKeysManagePanel, + ApiKeysModelsPanel, + ApiKeysUsagePanel, +} from "../../pages/api-keys-panels"; + +export interface ApiKeysWorkspaceProps { + keys: ApiKeyEntry[]; + keysLoading: boolean; + keysLoadFailed: boolean; + endpoints: ApiEndpointInfo; + claudeCodeEnabled: boolean; + localeTag?: string; + newName: string; + creating: boolean; + newKey: string | null; + copied: boolean; + filteredModels: ExternalModelRow[]; + modelsLoading: boolean; + modelsLoadFailed: boolean; + modelQuery: string; + copiedModelId: string | null; + modelTests: Record; + onNewNameChange: (value: string) => void; + onCreate: () => void; + onDismissNewKey: () => void; + onCopyKey: () => void; + onDelete: (id: string) => void; + onModelQueryChange: (value: string) => void; + onCopyModelId: (modelId: string) => void; + onTestModel: (model: ExternalModelRow) => void; + sourceLabel: (model: ExternalModelRow) => string; + protocolLabel: (protocol: string) => string; +} + +export default function ApiKeysWorkspace({ + keys, + keysLoading, + keysLoadFailed, + endpoints, + claudeCodeEnabled, + localeTag, + newName, + creating, + newKey, + copied, + filteredModels, + modelsLoading, + modelsLoadFailed, + modelQuery, + copiedModelId, + modelTests, + onNewNameChange, + onCreate, + onDismissNewKey, + onCopyKey, + onDelete, + onModelQueryChange, + onCopyModelId, + onTestModel, + sourceLabel, + protocolLabel, +}: ApiKeysWorkspaceProps) { + const t = useT(); + const [selectedId, setSelectedId] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(false); + + const selected = selectedId ? (keys.find(k => k.id === selectedId) ?? null) : null; + + const showOverview = () => { + setSelectedId(null); + setConfirmDelete(false); + }; + + 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)}
+
+
+
+
+
+ ) : ( +
+
+ {}} + onCancelDelete={() => {}} + onDelete={() => {}} + /> + + +
+
+ +
+
+ )} +
+
+
+ ); +} diff --git a/gui/src/components/storage-workspace/StorageWorkspace.tsx b/gui/src/components/storage-workspace/StorageWorkspace.tsx new file mode 100644 index 0000000000..974b060044 --- /dev/null +++ b/gui/src/components/storage-workspace/StorageWorkspace.tsx @@ -0,0 +1,212 @@ +/** + * StorageWorkspace — rail + main workspace for the Storage tab, mirroring the + * Providers workspace DNA. Left rail lists buckets sorted by size; the main pane + * shows either the overview (totals + largest files across buckets) or a + * per-bucket detail view. + */ +/* eslint-disable react-refresh/only-export-components -- bucket label helper co-locates with the rail rows */ +import { useMemo, useState } from "react"; +import { IconChevron, IconHardDrive } from "../../icons"; +import { useT, type TFn, type TKey, type Locale } from "../../i18n/shared"; +import { formatBytes } from "../../format-bytes"; + +export interface StorageLargestEntry { + path: string; + bytes: number; +} + +export interface StorageBucket { + key: string; + label: string; + bytes: number; + fileCount: number; + oldest?: number; + newest?: number; + largest?: StorageLargestEntry[]; + rows?: number | null; +} + +export interface StorageReport { + codexHome: string; + generatedAt: number; + total: { bytes: number; fileCount: number }; + buckets: StorageBucket[]; + error?: string; +} + +// Known scanner bucket keys → localized labels; unknown future keys fall back to the API label. +const BUCKET_TKEYS: Record = { + sessions: "storage.bucket.sessions", + archived_sessions: "storage.bucket.archived_sessions", + logs_db: "storage.bucket.logs_db", + state_db: "storage.bucket.state_db", + attachments: "storage.bucket.attachments", + deletion_manifests: "storage.bucket.deletion_manifests", + other: "storage.bucket.other", +}; + +export function bucketLabel(bucket: StorageBucket, t: TFn): string { + const tkey = BUCKET_TKEYS[bucket.key]; + return tkey ? t(tkey) : bucket.label; +} + +function formatDate(ms: number | undefined, locale: Locale): string { + return ms === undefined ? "—" : new Date(ms).toLocaleDateString(locale); +} + +function rowsDisplay(bucket: StorageBucket, locale: Locale, t: TFn): string { + if (bucket.rows === undefined) return "—"; + if (bucket.rows === null) return t("storage.rows.unknown"); + return bucket.rows.toLocaleString(locale); +} + +export interface StorageWorkspaceProps { + report: StorageReport; + locale: Locale; +} + +export default function StorageWorkspace({ report, locale }: StorageWorkspaceProps) { + const t = useT(); + const [selectedKey, setSelectedKey] = useState(null); + + const sortedBuckets = useMemo( + () => [...report.buckets].sort((a, b) => b.bytes - a.bytes), + [report.buckets], + ); + const selected = sortedBuckets.find(b => b.key === selectedKey) ?? null; + + const largestAcross = useMemo(() => { + const rows: Array = []; + for (const bucket of report.buckets) { + for (const entry of bucket.largest ?? []) rows.push({ ...entry, bucketKey: bucket.key }); + } + return rows.sort((a, b) => b.bytes - a.bytes).slice(0, 10); + }, [report.buckets]); + + const bucketByKey = useMemo( + () => new Map(report.buckets.map(b => [b.key, b])), + [report.buckets], + ); + + return ( +
+ + +
+ {selected ? ( +
+
+ +
+
+

{bucketLabel(selected, t)}

+
+
+
{t("storage.col.size")}
+
{formatBytes(selected.bytes, locale)}
+
+
+
{t("storage.col.files")}
+
{selected.fileCount.toLocaleString(locale)}
+
+
+
{t("storage.col.oldest")}
+
{formatDate(selected.oldest, locale)}
+
+
+
{t("storage.col.newest")}
+
{formatDate(selected.newest, locale)}
+
+
+
{t("storage.col.rows")}
+
{rowsDisplay(selected, locale, t)}
+
+
+ + {(selected.largest?.length ?? 0) > 0 && ( +
+

{t("storage.section.largest")}

+ {selected.largest!.map(entry => ( +
+ {entry.path} + {formatBytes(entry.bytes, locale)} +
+ ))} +
+ )} +
+
+ ) : ( +
+
+
+
{t("storage.card.total")}
+
{formatBytes(report.total.bytes, locale)}
+
+
+
{t("storage.card.files")}
+
{report.total.fileCount.toLocaleString(locale)}
+
+
+
{t("storage.card.home")}
+
{report.codexHome}
+
+
+ + {largestAcross.length > 0 ? ( +
+

{t("storage.section.largest")}

+ {largestAcross.map(entry => { + const owner = bucketByKey.get(entry.bucketKey); + return ( +
+ {entry.path} + {owner && {bucketLabel(owner, t)}} + {formatBytes(entry.bytes, locale)} +
+ ); + })} +
+ ) : ( +

+

+ )} +
+ )} +
+
+ ); +} diff --git a/gui/src/components/subagents-workspace/SubagentsWorkspace.tsx b/gui/src/components/subagents-workspace/SubagentsWorkspace.tsx new file mode 100644 index 0000000000..7126322e05 --- /dev/null +++ b/gui/src/components/subagents-workspace/SubagentsWorkspace.tsx @@ -0,0 +1,234 @@ +/** + * SubagentsWorkspace — rail + main workspace for the Subagents tab, mirroring the + * Providers/Combos workspace DNA. Left rail lists Featured and Available models with + * add/remove toggles; the main pane shows either the featured roster (reorder + save) + * or a per-model detail view. + */ +import { useMemo, useState } from "react"; +import { + IconArrowDown, + IconArrowUp, + IconBot, + IconCheck, + IconChevron, + IconInfo, + IconPlus, + IconX, +} from "../../icons"; +import { useT } from "../../i18n/shared"; +import { Trans } from "../../i18n/provider"; +import { modelLabel } from "../../model-display"; + +export interface SubagentsWorkspaceProps { + available: string[]; + chosen: string[]; + busy?: boolean; + onToggle: (m: string) => void; + onMove: (i: number, dir: -1 | 1) => void; + onSave: () => void; +} + +const FEATURED_MAX = 5; + +export default function SubagentsWorkspace({ + available, + chosen, + busy = false, + onToggle, + onMove, + onSave, +}: SubagentsWorkspaceProps) { + const t = useT(); + const [query, setQuery] = useState(""); + const [selected, setSelected] = useState(null); + + const chosenSet = useMemo(() => new Set(chosen), [chosen]); + const full = chosen.length >= FEATURED_MAX; + + const featuredFiltered = useMemo(() => { + const q = query.trim().toLowerCase(); + return chosen.filter(m => !q || m.toLowerCase().includes(q)); + }, [chosen, query]); + + const availableFiltered = useMemo(() => { + const q = query.trim().toLowerCase(); + return available.filter(m => !chosenSet.has(m) && (!q || m.toLowerCase().includes(q))); + }, [available, chosenSet, query]); + + const selectedIndex = selected ? chosen.indexOf(selected) : -1; + const selectedIsFeatured = selectedIndex !== -1; + + return ( +
+
+ + +
+ {selected ? ( +
+ +
+ +

{selected}

+
+ +
+
+
+
{t("sub.workspace.selector")}
+
{selected}
+
+
+
{t("sub.workspace.priority")}
+
{selectedIsFeatured ? selectedIndex + 1 : t("sub.workspace.notFeatured")}
+
+
+
+ +
+

{t("sub.featured")}

+
+ {selectedIsFeatured ? ( + + ) : ( + + )} +
+
+
+ ) : ( + <> +
+

{t("sub.featured")}

+ {chosen.length}/{FEATURED_MAX} +
+

+

+ + {chosen.length === 0 ? ( +
{t("sub.noneSelected")}
+ ) : ( +
+ {chosen.map((m, i) => ( +
+ {i + 1} + {modelLabel(m)} + + + + + +
+ ))} +
+ )} + +
+ +
+ + )} +
+
+
+ ); +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index c45f5c37df..a21cdbedc2 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -455,6 +455,16 @@ export const de: Record = { "sub.moveUp": "{m} nach oben", "sub.moveDown": "{m} nach unten", "sub.removeAria": "{m} entfernen", + "sub.workspace.addToFeatured": "{m} zu Hervorgehobenen hinzufügen", + "sub.workspace.allModels": "Alle Modelle", + "sub.workspace.featuredFull": "Hervorgehobene Liste ist voll (max. 5)", + "sub.workspace.mainAria": "Subagent-Modelldetails", + "sub.workspace.notFeatured": "Nicht hervorgehoben", + "sub.workspace.priority": "Priorität", + "sub.workspace.removeFromFeatured": "{m} aus Hervorgehobenen entfernen", + "sub.workspace.selectModel": "Modell auswählen", + "sub.workspace.selectModelDesc": "Wählen Sie ein Modell aus der Liste, um Details anzuzeigen und es für spawn_agent hervorzuheben.", + "sub.workspace.selector": "Öffentlicher Selektor", "logs.title": "Anfrage-Protokolle", "logs.tabLogs": "Protokolle", "logs.tabDebug": "Diagnose", @@ -592,6 +602,8 @@ export const de: Record = { "usage.section.models": "Modelle", "usage.section.providers": "Anbieter", "usage.section.coverage": "Abdeckungs-Aufschlüsselung", + "usage.workspace.report": "Nutzungsbericht", + "usage.workspace.sections": "Nutzungsabschnitte", "usage.coverage.measured": "Gemessen", "usage.coverage.reported": "Anbieter gemeldet", "usage.coverage.estimated": "Geschätzt", @@ -834,6 +846,15 @@ export const de: Record = { "api.activeKeys": "Aktive Schlüssel ({count})", "api.activeKeysLoading": "Aktive Schlüssel", "api.noKeys": "Noch keine API-Schlüssel. Erstelle oben einen.", + "api.workspace.overview": "Übersicht", + "api.workspace.details": "API-Schlüsseldetails", + "api.workspace.keyDetails": "Schlüsseldetails", + "api.workspace.keyPrefix": "Schlüssel-Präfix", + "api.workspace.deleteKey": "Schlüssel löschen", + "api.workspace.deleteConfirm": "Diesen Schlüssel wirklich löschen? Das lässt sich nicht rückgängig machen.", + "api.workspace.noKeysHint": "Noch keine API-Schlüssel. Erstelle einen, um zu starten.", + "api.workspace.selectKeyHint": "Wähle einen Schlüssel aus der Liste, um Details zu sehen.", + "api.workspace.usageExamples": "Nutzungsbeispiele", "api.copyUrlHint": "Klick um URL zu kopieren", "api.urlCopied": "URL kopiert", "api.copyExampleHint": "Klick um Beispiel zu kopieren", @@ -1035,11 +1056,11 @@ export const de: Record = { "storage.policy.invalid": "Ungültige Richtlinienwerte.", "storage.policy.enabled": "Automatische Bereinigung aktivieren", "storage.policy.enabledHint": "Standard ist aus. Bei Aktivierung nur nach gewähltem Zeitplan (oder Jetzt ausführen).", - "storage.policy.threshold": "Wenn Archivgröße größer als", + "storage.policy.threshold": "Wenn Archivgröße größer als (GiB)", "storage.policy.trigger": "Auslöser", "storage.policy.target": "Bereinigungsziel", - "storage.policy.targetPercent": "Älteste Archive entfernen", - "storage.policy.targetReduce": "Archivgröße reduzieren auf", + "storage.policy.targetPercent": "Älteste Archive entfernen (%)", + "storage.policy.targetReduce": "Archivgröße reduzieren auf (GiB)", "storage.policy.thresholdInc": "Schwellwert erhöhen", "storage.policy.thresholdDec": "Schwellwert verringern", "storage.policy.percentInc": "Prozent erhöhen", @@ -1334,6 +1355,7 @@ export const de: Record = { "codexAuth.addIdPlaceholder": "codex-work, codex-alt, team…", "codexAuth.resetCreditsAria": "{count} Reset-Guthaben", "claude.pageTitle": "Claude Code", + "claude.workspace.settings": "Einstellungen", // Combos workspace "cws.loading": "Combos werden geladen…", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 6b81fdc84a..1cb0a0e5e5 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -471,6 +471,16 @@ export const en = { "sub.moveUp": "Move {m} up", "sub.moveDown": "Move {m} down", "sub.removeAria": "Remove {m}", + "sub.workspace.addToFeatured": "Add {m} to featured", + "sub.workspace.allModels": "All models", + "sub.workspace.featuredFull": "Featured list is full (max 5)", + "sub.workspace.mainAria": "Subagent model details", + "sub.workspace.notFeatured": "Not featured", + "sub.workspace.priority": "Priority", + "sub.workspace.removeFromFeatured": "Remove {m} from featured", + "sub.workspace.selectModel": "Select a model", + "sub.workspace.selectModelDesc": "Pick a model from the list to see details and feature it for spawn_agent.", + "sub.workspace.selector": "Public selector", // logs "logs.title": "Request Logs", @@ -614,6 +624,8 @@ export const en = { "usage.section.models": "Models", "usage.section.providers": "Providers", "usage.section.coverage": "Coverage breakdown", + "usage.workspace.report": "Usage report", + "usage.workspace.sections": "Usage sections", "usage.coverage.measured": "Measured", "usage.coverage.reported": "Provider reported", "usage.coverage.estimated": "Estimated", @@ -739,11 +751,11 @@ export const en = { "storage.policy.invalid": "Invalid policy values.", "storage.policy.enabled": "Enable auto-cleanup", "storage.policy.enabledHint": "Default is off. Enabling runs only on the schedule you choose (or Run now).", - "storage.policy.threshold": "When archived size exceeds", + "storage.policy.threshold": "When archived size exceeds (GiB)", "storage.policy.trigger": "Trigger", "storage.policy.target": "Cleanup target", - "storage.policy.targetPercent": "Remove oldest archived", - "storage.policy.targetReduce": "Reduce archived size to", + "storage.policy.targetPercent": "Remove oldest archived (%)", + "storage.policy.targetReduce": "Reduce archived size to (GiB)", "storage.policy.thresholdInc": "Increase threshold", "storage.policy.thresholdDec": "Decrease threshold", "storage.policy.percentInc": "Increase percent", @@ -1249,6 +1261,15 @@ export const en = { "api.activeKeys": "Active keys ({count})", "api.activeKeysLoading": "Active keys", "api.noKeys": "No API keys yet. Generate one above.", + "api.workspace.overview": "Overview", + "api.workspace.details": "API key details", + "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.", + "api.workspace.usageExamples": "Usage examples", "api.copyUrlHint": "Click to copy URL", "api.urlCopied": "URL copied", "api.copyExampleHint": "Click to copy example", @@ -1291,6 +1312,7 @@ export const en = { "nav.claude": "Claude", "claude.subtitle": "Use GPT, Gemini, and other models inside Claude Code.", "claude.pageTitle": "Claude Code", + "claude.workspace.settings": "Settings", "claude.enabledLabel": "Claude connection", "claude.enabledHint": "When off, Claude Code cannot use this proxy.", "claude.authMode": "Auth Mode", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 66f95b360f..0feefa7d08 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -438,6 +438,16 @@ export const ja: Record = { "sub.moveUp": "{m} を上へ移動", "sub.moveDown": "{m} を下へ移動", "sub.removeAria": "{m} を削除", + "sub.workspace.addToFeatured": "{m} をおすすめに追加", + "sub.workspace.allModels": "すべてのモデル", + "sub.workspace.featuredFull": "おすすめリストがいっぱいです(最大 5)", + "sub.workspace.mainAria": "サブエージェントのモデル詳細", + "sub.workspace.notFeatured": "おすすめ未設定", + "sub.workspace.priority": "優先度", + "sub.workspace.removeFromFeatured": "{m} をおすすめから削除", + "sub.workspace.selectModel": "モデルを選択", + "sub.workspace.selectModelDesc": "一覧からモデルを選んで詳細を確認し、spawn_agent のおすすめに設定します。", + "sub.workspace.selector": "公開セレクター", // logs "logs.title": "リクエストログ", @@ -581,6 +591,8 @@ export const ja: Record = { "usage.section.models": "モデル", "usage.section.providers": "プロバイダー", "usage.section.coverage": "カバレッジ内訳", + "usage.workspace.report": "使用量レポート", + "usage.workspace.sections": "使用量セクション", "usage.coverage.measured": "計測", "usage.coverage.reported": "プロバイダー報告", "usage.coverage.estimated": "推定", @@ -706,11 +718,11 @@ export const ja: Record = { "storage.policy.invalid": "方針の値が無効です。", "storage.policy.enabled": "自動クリーンアップを有効化", "storage.policy.enabledHint": "既定はオフです。有効にすると選択したスケジュール(または今すぐ実行)でのみ動きます。", - "storage.policy.threshold": "アーカイブサイズが超えたら", + "storage.policy.threshold": "アーカイブサイズが超えたら(GiB)", "storage.policy.trigger": "トリガー", "storage.policy.target": "クリーンアップ目標", - "storage.policy.targetPercent": "古いアーカイブを削除", - "storage.policy.targetReduce": "アーカイブを次のサイズまで縮小", + "storage.policy.targetPercent": "古いアーカイブを削除(%)", + "storage.policy.targetReduce": "アーカイブを次のサイズまで縮小(GiB)", "storage.policy.thresholdInc": "しきい値を上げる", "storage.policy.thresholdDec": "しきい値を下げる", "storage.policy.percentInc": "パーセントを上げる", @@ -1229,6 +1241,15 @@ export const ja: Record = { "api.activeKeys": "アクティブなキー ({count})", "api.activeKeysLoading": "有効なキー", "api.noKeys": "まだ API キーがありません。上で生成してください。", + "api.workspace.overview": "概要", + "api.workspace.details": "APIキーの詳細", + "api.workspace.keyDetails": "キーの詳細", + "api.workspace.keyPrefix": "キーのプレフィックス", + "api.workspace.deleteKey": "キーを削除", + "api.workspace.deleteConfirm": "このキーを削除しますか?この操作は元に戻せません。", + "api.workspace.noKeysHint": "API キーがまだありません。作成して始めましょう。", + "api.workspace.selectKeyHint": "一覧からキーを選択すると、詳細が表示されます。", + "api.workspace.usageExamples": "使用例", "api.copyUrlHint": "クリックして URL をコピー", "api.urlCopied": "URL をコピーしました", "api.copyExampleHint": "クリックして例をコピー", @@ -1246,6 +1267,7 @@ export const ja: Record = { "nav.claude": "Claude", "claude.subtitle": "Claude Code 内で GPT、Gemini などのモデルを使用します。", "claude.pageTitle": "Claude Code", + "claude.workspace.settings": "設定", "claude.enabledLabel": "Claude 接続", "claude.enabledHint": "オフにすると Claude Code はこのプロキシを使用できません。", "claude.authMode": "認証モード", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index d81b0ece70..8e0931da8f 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -465,6 +465,16 @@ export const ko: Record = { "sub.moveUp": "{m} 위로 이동", "sub.moveDown": "{m} 아래로 이동", "sub.removeAria": "{m} 삭제", + "sub.workspace.addToFeatured": "{m}을(를) 추천에 추가", + "sub.workspace.allModels": "모든 모델", + "sub.workspace.featuredFull": "추천 목록이 가득 찼습니다 (최대 5개)", + "sub.workspace.mainAria": "서브에이전트 모델 세부 정보", + "sub.workspace.notFeatured": "추천되지 않음", + "sub.workspace.priority": "우선순위", + "sub.workspace.removeFromFeatured": "{m}을(를) 추천에서 제거", + "sub.workspace.selectModel": "모델 선택", + "sub.workspace.selectModelDesc": "목록에서 모델을 선택하여 세부 정보를 확인하고 spawn_agent에 추천하세요.", + "sub.workspace.selector": "공개 셀렉터", // logs "logs.title": "요청 로그", @@ -607,6 +617,8 @@ export const ko: Record = { "usage.section.models": "모델", "usage.section.providers": "프로바이더", "usage.section.coverage": "커버리지 상세", + "usage.workspace.report": "사용량 보고서", + "usage.workspace.sections": "사용량 섹션", "usage.coverage.measured": "측정됨", "usage.coverage.reported": "제공자 보고", "usage.coverage.estimated": "추정", @@ -854,6 +866,15 @@ export const ko: Record = { "api.activeKeys": "활성 키 ({count})", "api.activeKeysLoading": "활성 키", "api.noKeys": "아직 API 키가 없습니다. 위에서 하나 생성하세요.", + "api.workspace.overview": "개요", + "api.workspace.details": "API 키 세부 정보", + "api.workspace.keyDetails": "키 세부 정보", + "api.workspace.keyPrefix": "키 접두사", + "api.workspace.deleteKey": "키 삭제", + "api.workspace.deleteConfirm": "이 키를 삭제하시겠습니까? 되돌릴 수 없습니다.", + "api.workspace.noKeysHint": "아직 API 키가 없습니다. 키를 생성하여 시작하세요.", + "api.workspace.selectKeyHint": "목록에서 키를 선택하여 세부 정보를 확인하세요.", + "api.workspace.usageExamples": "사용 예제", "api.copyUrlHint": "클릭하여 URL 복사", "api.urlCopied": "URL 복사됨", "api.copyExampleHint": "클릭하여 예제 복사", @@ -1055,11 +1076,11 @@ export const ko: Record = { "storage.policy.invalid": "정책 값이 올바르지 않습니다.", "storage.policy.enabled": "자동 정리 사용", "storage.policy.enabledHint": "기본은 꺼짐입니다. 켜면 선택한 일정(또는 지금 실행)에만 동작합니다.", - "storage.policy.threshold": "보관 용량이 초과하면", + "storage.policy.threshold": "보관 용량이 초과하면 (GiB)", "storage.policy.trigger": "트리거", "storage.policy.target": "정리 목표", - "storage.policy.targetPercent": "가장 오래된 보관 제거", - "storage.policy.targetReduce": "보관 용량을 다음까지 줄이기", + "storage.policy.targetPercent": "가장 오래된 보관 제거 (%)", + "storage.policy.targetReduce": "보관 용량을 다음까지 줄이기 (GiB)", "storage.policy.thresholdInc": "임계값 증가", "storage.policy.thresholdDec": "임계값 감소", "storage.policy.percentInc": "퍼센트 증가", @@ -1354,6 +1375,7 @@ export const ko: Record = { "codexAuth.addIdPlaceholder": "codex-work, codex-alt, team…", "codexAuth.resetCreditsAria": "리셋 크레딧 {count}개", "claude.pageTitle": "Claude Code", + "claude.workspace.settings": "설정", // Combos workspace "cws.loading": "콤보 불러오는 중…", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 8c2eda35e5..c6940fde3c 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -470,6 +470,16 @@ export const ru: Record = { "sub.moveUp": "Переместить {m} вверх", "sub.moveDown": "Переместить {m} вниз", "sub.removeAria": "Убрать {m}", + "sub.workspace.addToFeatured": "Добавить {m} в избранные", + "sub.workspace.allModels": "Все модели", + "sub.workspace.featuredFull": "Список избранных заполнен (макс. 5)", + "sub.workspace.mainAria": "Сведения о модели субагента", + "sub.workspace.notFeatured": "Не в избранных", + "sub.workspace.priority": "Приоритет", + "sub.workspace.removeFromFeatured": "Убрать {m} из избранных", + "sub.workspace.selectModel": "Выберите модель", + "sub.workspace.selectModelDesc": "Выберите модель из списка, чтобы увидеть детали и добавить её в избранные для spawn_agent.", + "sub.workspace.selector": "Публичный селектор", // logs "logs.title": "Журнал запросов", @@ -613,6 +623,8 @@ export const ru: Record = { "usage.section.models": "Модели", "usage.section.providers": "Провайдеры", "usage.section.coverage": "Детализация покрытия", + "usage.workspace.report": "Отчёт об использовании", + "usage.workspace.sections": "Разделы использования", "usage.coverage.measured": "Измерено", "usage.coverage.reported": "Сообщено провайдером", "usage.coverage.estimated": "Оценено", @@ -738,11 +750,11 @@ export const ru: Record = { "storage.policy.invalid": "Недопустимые значения политики.", "storage.policy.enabled": "Включить автоочистку", "storage.policy.enabledHint": "По умолчанию выкл. При включении работает только по выбранному расписанию (или «Запустить сейчас»).", - "storage.policy.threshold": "Когда размер архива больше", + "storage.policy.threshold": "Когда размер архива больше (ГиБ)", "storage.policy.trigger": "Триггер", "storage.policy.target": "Цель очистки", - "storage.policy.targetPercent": "Удалить самые старые архивы", - "storage.policy.targetReduce": "Уменьшить архив до", + "storage.policy.targetPercent": "Удалить самые старые архивы (%)", + "storage.policy.targetReduce": "Уменьшить архив до (ГиБ)", "storage.policy.thresholdInc": "Увеличить порог", "storage.policy.thresholdDec": "Уменьшить порог", "storage.policy.percentInc": "Увеличить процент", @@ -1271,6 +1283,15 @@ export const ru: Record = { "api.activeKeys": "Активные ключи ({count})", "api.activeKeysLoading": "Активные ключи", "api.noKeys": "API-ключей пока нет. Сгенерируйте ключ выше.", + "api.workspace.overview": "Обзор", + "api.workspace.details": "Сведения об API-ключе", + "api.workspace.keyDetails": "Сведения о ключе", + "api.workspace.keyPrefix": "Префикс ключа", + "api.workspace.deleteKey": "Удалить ключ", + "api.workspace.deleteConfirm": "Удалить этот ключ? Это действие нельзя отменить.", + "api.workspace.noKeysHint": "API-ключей пока нет. Создайте один, чтобы начать.", + "api.workspace.selectKeyHint": "Выберите ключ из списка, чтобы просмотреть сведения.", + "api.workspace.usageExamples": "Примеры использования", "api.copyUrlHint": "Нажмите, чтобы скопировать URL", "api.urlCopied": "URL скопирован", "api.copyExampleHint": "Нажмите, чтобы скопировать пример", @@ -1288,6 +1309,7 @@ export const ru: Record = { "nav.claude": "Claude", "claude.subtitle": "Используйте GPT, Gemini и другие модели внутри Claude Code.", "claude.pageTitle": "Claude Code", + "claude.workspace.settings": "Настройки", "claude.enabledLabel": "Подключение Claude", "claude.enabledHint": "Если выключено, Claude Code не сможет использовать этот прокси.", "claude.authMode": "Режим аутентификации", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 626c70076e..3e026741e0 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -465,6 +465,16 @@ export const zh: Record = { "sub.moveUp": "上移 {m}", "sub.moveDown": "下移 {m}", "sub.removeAria": "移除 {m}", + "sub.workspace.addToFeatured": "将 {m} 添加到精选", + "sub.workspace.allModels": "所有模型", + "sub.workspace.featuredFull": "精选列表已满(最多 5 个)", + "sub.workspace.mainAria": "子代理模型详情", + "sub.workspace.notFeatured": "未设为精选", + "sub.workspace.priority": "优先级", + "sub.workspace.removeFromFeatured": "将 {m} 从精选中移除", + "sub.workspace.selectModel": "选择模型", + "sub.workspace.selectModelDesc": "从列表中选择一个模型以查看详情,并将其设为 spawn_agent 的精选模型。", + "sub.workspace.selector": "公开选择器", // logs "logs.title": "请求日志", @@ -607,6 +617,8 @@ export const zh: Record = { "usage.section.models": "模型", "usage.section.providers": "提供方", "usage.section.coverage": "覆盖率明细", + "usage.workspace.report": "用量报告", + "usage.workspace.sections": "用量分区", "usage.coverage.measured": "已计量", "usage.coverage.reported": "提供方上报", "usage.coverage.estimated": "估算", @@ -854,6 +866,15 @@ export const zh: Record = { "api.activeKeys": "活跃密钥({count})", "api.activeKeysLoading": "有效密钥", "api.noKeys": "还没有 API 密钥。请在上方生成一个。", + "api.workspace.overview": "概览", + "api.workspace.details": "API 密钥详情", + "api.workspace.keyDetails": "密钥详情", + "api.workspace.keyPrefix": "密钥前缀", + "api.workspace.deleteKey": "删除密钥", + "api.workspace.deleteConfirm": "确定要删除此密钥吗?此操作无法撤销。", + "api.workspace.noKeysHint": "还没有 API 密钥。生成一个以开始使用。", + "api.workspace.selectKeyHint": "从列表中选择一个密钥以查看其详情。", + "api.workspace.usageExamples": "用法示例", "api.copyUrlHint": "点击复制 URL", "api.urlCopied": "已复制 URL", "api.copyExampleHint": "点击复制示例", @@ -1055,11 +1076,11 @@ export const zh: Record = { "storage.policy.invalid": "策略值无效。", "storage.policy.enabled": "启用自动清理", "storage.policy.enabledHint": "默认关闭。启用后仅按所选计划(或立即运行)执行。", - "storage.policy.threshold": "当归档大小超过", + "storage.policy.threshold": "当归档大小超过(GiB)", "storage.policy.trigger": "触发条件", "storage.policy.target": "清理目标", - "storage.policy.targetPercent": "删除最旧归档", - "storage.policy.targetReduce": "将归档缩小至", + "storage.policy.targetPercent": "删除最旧归档(%)", + "storage.policy.targetReduce": "将归档缩小至(GiB)", "storage.policy.thresholdInc": "提高阈值", "storage.policy.thresholdDec": "降低阈值", "storage.policy.percentInc": "提高百分比", @@ -1354,6 +1375,7 @@ export const zh: Record = { "codexAuth.addIdPlaceholder": "codex-work, codex-alt, team…", "codexAuth.resetCreditsAria": "{count} 个重置额度", "claude.pageTitle": "Claude Code", + "claude.workspace.settings": "设置", // Combos workspace "cws.loading": "正在加载组合…", diff --git a/gui/src/model-display.ts b/gui/src/model-display.ts index e31b7c951e..0246a06ced 100644 --- a/gui/src/model-display.ts +++ b/gui/src/model-display.ts @@ -14,7 +14,7 @@ const MODEL_ICON_MAP: Record = { "gpt-5.6-luna": IconMoon, }; -const ICON_STYLE = { width: 14, height: 14, flexShrink: 0, verticalAlign: "text-bottom" as const, marginRight: 4 }; +const ICON_STYLE = { width: 14, height: 14, flexShrink: 0, verticalAlign: "text-bottom" as const }; /** Resolve the bare slug from a potentially provider-prefixed id. */ function bareSlug(slug: string): string { @@ -30,8 +30,8 @@ function resolveIcon(slug: string): IconComponent | null { export function modelLabel(slug: string): ReactNode { const Icon = resolveIcon(slug); if (!Icon) return slug; - return createElement("span", { style: { display: "inline-flex", alignItems: "center", gap: 4 } }, - createElement(Icon, { style: ICON_STYLE }), + return createElement("span", { className: "model-label" }, + createElement(Icon, { style: ICON_STYLE, "aria-hidden": true }), slug, ); } diff --git a/gui/src/pages/ApiKeys.tsx b/gui/src/pages/ApiKeys.tsx index 87f0704d74..a9f5f62b1b 100644 --- a/gui/src/pages/ApiKeys.tsx +++ b/gui/src/pages/ApiKeys.tsx @@ -7,6 +7,8 @@ import { externalModelId, type ExternalModelRow, } from "../api-access-models"; +import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; +import ApiKeysWorkspace from "../components/apikeys-workspace/ApiKeysWorkspace"; import { DEFAULT_ENDPOINTS, deriveApiEndpoints, @@ -14,13 +16,6 @@ import { type ApiKeyEntry, type ModelTestState, } from "./api-keys-utils"; -import { - ApiKeysAuthPanel, - ApiKeysEndpointsPanel, - ApiKeysManagePanel, - ApiKeysModelsPanel, - ApiKeysUsagePanel, -} from "./api-keys-panels"; interface KeysResponse { keys?: ApiKeyEntry[]; @@ -37,16 +32,43 @@ interface CreateKeyResponse { key?: unknown; } +type CachedKeysShape = { + keys: ApiKeyEntry[]; + endpoints: ApiEndpointInfo; + claudeCodeEnabled: boolean; +}; + +/** Seed copyable endpoints only when apiBase has a usable origin/host. */ +function seedEndpointsFromApiBase(apiBase: string): ApiEndpointInfo { + const trimmed = apiBase.replace(/\/$/, ""); + if (!trimmed) return DEFAULT_ENDPOINTS; + try { + const url = new URL(trimmed); + if (!url.host) return DEFAULT_ENDPOINTS; + return deriveApiEndpoints(`${trimmed}/v1/responses`); + } catch { + return DEFAULT_ENDPOINTS; + } +} + export default function ApiKeys({ apiBase }: { apiBase: string }) { const { t, locale } = useI18n(); const localeTag = LOCALES.find(l => l.code === locale)?.htmlLang; - const [keys, setKeys] = useState([]); - const [endpoints, setEndpoints] = useState(DEFAULT_ENDPOINTS); - const [claudeCodeEnabled, setClaudeCodeEnabled] = useState(true); + const keysCacheKey = `ocx.apikeys.list.v1:${apiBase}`; + const modelsCacheKey = `ocx.apikeys.models.v1:${apiBase}`; + const cachedKeys = readSessionListCache(keysCacheKey); + const cachedModels = readSessionListCache(modelsCacheKey); + const hasModelsCacheRef = useRef(Boolean(cachedModels)); + const [keys, setKeys] = useState(() => cachedKeys?.keys ?? []); + const [endpoints, setEndpoints] = useState(() => + cachedKeys?.endpoints ?? seedEndpointsFromApiBase(apiBase), + ); + const [claudeCodeEnabled, setClaudeCodeEnabled] = useState(() => cachedKeys?.claudeCodeEnabled ?? true); + const [keysLoading, setKeysLoading] = useState(() => !cachedKeys); const [keysLoadFailed, setKeysLoadFailed] = useState(false); const [actionError, setActionError] = useState(null); - const [models, setModels] = useState([]); - const [modelsLoading, setModelsLoading] = useState(false); + const [models, setModels] = useState(() => cachedModels ?? []); + const [modelsLoading, setModelsLoading] = useState(() => !cachedModels); const [modelsLoadFailed, setModelsLoadFailed] = useState(false); const [modelQuery, setModelQuery] = useState(""); const [copiedModelId, setCopiedModelId] = useState(null); @@ -55,7 +77,6 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { const [creating, setCreating] = useState(false); const [newKey, setNewKey] = useState(null); const [copied, setCopied] = useState(false); - const [confirmDelete, setConfirmDelete] = useState(null); const creatingRef = useRef(false); const fetchKeys = useCallback(async () => { @@ -68,28 +89,39 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { return; } const derived = deriveApiEndpoints(data.endpoint ?? ""); - setKeys(data.keys ?? []); - setEndpoints({ + const nextKeys = data.keys ?? []; + const nextEndpoints = { baseUrl: data.baseUrl ?? derived.baseUrl, responses: data.responsesEndpoint ?? data.endpoint ?? DEFAULT_ENDPOINTS.responses, chatCompletions: data.chatCompletionsEndpoint ?? derived.chatCompletions, messages: data.messagesEndpoint ?? derived.messages, models: data.modelsEndpoint ?? derived.models, + }; + const nextClaude = data.claudeCodeEnabled !== false; + setKeys(nextKeys); + setEndpoints(nextEndpoints); + setClaudeCodeEnabled(nextClaude); + // Prefixes only — never the secret key material. + writeSessionListCache(keysCacheKey, { + keys: nextKeys, + endpoints: nextEndpoints, + claudeCodeEnabled: nextClaude, }); - setClaudeCodeEnabled(data.claudeCodeEnabled !== false); setKeysLoadFailed(false); } catch { setKeysLoadFailed(true); + } finally { + setKeysLoading(false); } - }, [apiBase]); + }, [apiBase, keysCacheKey]); const fetchModels = useCallback(async () => { - setModelsLoading(true); + if (!hasModelsCacheRef.current) setModelsLoading(true); setModelsLoadFailed(false); try { const res = await fetch(`${apiBase}/v1/models`); if (!res.ok) { - setModels([]); + if (!hasModelsCacheRef.current) setModels([]); setModelsLoadFailed(true); return; } @@ -100,7 +132,7 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { ? (data as { data: unknown[] }).data : null); if (!rawRows) { - setModels([]); + if (!hasModelsCacheRef.current) setModels([]); setModelsLoadFailed(true); return; } @@ -113,16 +145,19 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { .map(row => classifyExternalModel(row)) .sort((a, b) => externalModelId(a).localeCompare(externalModelId(b))); setModels(rows); + hasModelsCacheRef.current = true; + writeSessionListCache(modelsCacheKey, rows); } catch { - setModels([]); + if (!hasModelsCacheRef.current) setModels([]); setModelsLoadFailed(true); } finally { setModelsLoading(false); } - }, [apiBase]); + }, [apiBase, modelsCacheKey]); useEffect(() => { const timeout = window.setTimeout(() => { + // Independent: keys panel and model catalog must not block each other. void fetchKeys(); void fetchModels(); }, 0); @@ -182,7 +217,6 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { setActionError(t("api.deleteFailed")); return; } - setConfirmDelete(null); void fetchKeys(); } catch { setActionError(t("api.deleteFailed")); @@ -271,40 +305,34 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { {actionError ?? t("api.keysLoadFailed")} )} - - - { void handleCreate(); }} - onDismissNewKey={() => setNewKey(null)} - onCopyKey={copyKey} - onConfirmDelete={setConfirmDelete} - onCancelDelete={() => setConfirmDelete(null)} - onDelete={(id) => { void handleDelete(id); }} - /> - { void handleCreate(); }} + onDismissNewKey={() => setNewKey(null)} + onCopyKey={copyKey} + onDelete={(id) => { void handleDelete(id); }} onModelQueryChange={setModelQuery} onCopyModelId={(modelId) => { void copyModelId(modelId); }} onTestModel={(model) => { void testModel(model); }} sourceLabel={sourceLabel} protocolLabel={protocolLabel} /> - ); } diff --git a/gui/src/pages/Claude.tsx b/gui/src/pages/Claude.tsx index 9dfd048682..9054f1706c 100644 --- a/gui/src/pages/Claude.tsx +++ b/gui/src/pages/Claude.tsx @@ -62,13 +62,14 @@ export default function Claude({ apiBase }: { apiBase: string }) { + {/* Code stays mounted; Desktop mounts only when selected so its fetch is not forever-stale. */} ); diff --git a/gui/src/pages/ClaudeCode.tsx b/gui/src/pages/ClaudeCode.tsx index de5193d443..0488435324 100644 --- a/gui/src/pages/ClaudeCode.tsx +++ b/gui/src/pages/ClaudeCode.tsx @@ -1,7 +1,8 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { Notice } from "../ui"; import { useI18n, useT, LOCALES } from "../i18n/shared"; import { readJsonOrThrow } from "../fetch-json"; +import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; import { backgroundHelperOptions } from "./claude-code-helper-options"; import { reconcileAutoConnectState } from "./claude-autoconnect"; import { buildManualEnv } from "./claude-manual-env"; @@ -17,17 +18,24 @@ import { SmallFastModelSetting } from "./claude-code-settings"; export { AutoConnectSetting, SmallFastModelSetting } from "./claude-code-settings"; +type CachedClaudeCode = { state: ClaudeCodeState; rows: MapRow[] }; + export default function ClaudeCode({ apiBase }: { apiBase: string }) { const t = useT(); const { locale } = useI18n(); const localeTag = LOCALES.find(l => l.code === locale)?.htmlLang ?? "en"; - const [state, setState] = useState(null); - const [rows, setRows] = useState([]); + const cacheKey = `ocx.claude-code.v1:${apiBase}`; + const cached = readSessionListCache(cacheKey); + const [state, setState] = useState(() => cached?.state ?? null); + const [rows, setRows] = useState(() => cached?.rows ?? []); const [status, setStatus] = useState(""); const [ok, setOk] = useState(false); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(() => !cached?.state); + const [selectedSection, setSelectedSection] = useState("settings"); + const hasCacheRef = useRef(Boolean(cached?.state)); const load = useCallback(async () => { + if (!hasCacheRef.current) setLoading(true); try { const res = await fetch(`${apiBase}/api/claude-code`); const r = await readJsonOrThrow }>( @@ -39,7 +47,7 @@ export default function ClaudeCode({ apiBase }: { apiBase: string }) { setStatus(t("claude.loadFail")); return; } - setState({ + const nextState: ClaudeCodeState = { ...r, // No coercion: an absent config key is AUTO, and coercing it to subscription is // what silently converted an untouched auto config on every save. @@ -51,15 +59,21 @@ export default function ClaudeCode({ apiBase }: { apiBase: string }) { autoCompactWindow: r.autoCompactWindow ?? null, injectAgents: r.injectAgents !== false, effectiveModelEnv: r.effectiveModelEnv ?? {}, - }); - setRows(Object.entries(r.modelMap ?? {}).map(([from, to]) => ({ id: newClientId(), from, to: String(to) }))); + }; + const nextRows = Object.entries(r.modelMap ?? {}).map(([from, to]) => ({ id: newClientId(), from, to: String(to) })); + setState(nextState); + setRows(nextRows); + hasCacheRef.current = true; + writeSessionListCache(cacheKey, { state: nextState, rows: nextRows }); } catch (error) { - setOk(false); - setStatus(error instanceof Error && error.message ? error.message : t("claude.loadFail")); + if (!hasCacheRef.current) { + setOk(false); + setStatus(error instanceof Error && error.message ? error.message : t("claude.loadFail")); + } } finally { setLoading(false); } - }, [apiBase, t]); + }, [apiBase, cacheKey, t]); useEffect(() => { // Deferred initial load (matches Models/Usage): avoids synchronous setState @@ -124,21 +138,86 @@ export default function ClaudeCode({ apiBase }: { apiBase: string }) { if (loading) return
{t("claude.loading")}
; if (!state) return {status || t("claude.loadFail")}; + const sections: Array<{ id: string; label: string; body: ReactNode }> = [ + { + id: "settings", + label: t("claude.workspace.settings"), + body: ( + + ), + }, + { + id: "quickstart", + label: t("claude.quickstart"), + body: , + }, + { + id: "smallFast", + label: t("claude.smallFastModel"), + body: ( + setState({ ...state, smallFastModel })} + /> + ), + }, + { + id: "modelMap", + label: t("claude.modelMap"), + body: , + }, + { + id: "aliases", + label: t("claude.aliases"), + body: , + }, + ]; + const selected = sections.find(s => s.id === selectedSection) ?? sections[0]!; + const sectionEditable = selectedSection === "settings" + || selectedSection === "smallFast" + || selectedSection === "modelMap"; + return ( - <> -

{t("claude.pageTitle")}

+
+
+

{t("claude.pageTitle")}

+ {sectionEditable && ( +
+ +
+ )} +

{t("claude.subtitle")}

{status && {status}} - - - setState({ ...state, smallFastModel })} - /> - { void save(); }} /> - - +
+ +
+
{selected.body}
+
+
+
); } diff --git a/gui/src/pages/ClaudeDesktop.tsx b/gui/src/pages/ClaudeDesktop.tsx index 2eaae112c6..080449f102 100644 --- a/gui/src/pages/ClaudeDesktop.tsx +++ b/gui/src/pages/ClaudeDesktop.tsx @@ -6,6 +6,7 @@ import { EmptyState, Notice } from "../ui"; import { useT, type TFn, type TKey } from "../i18n/shared"; import { readJsonIfOk, readJsonOrThrow } from "../fetch-json"; import { createBoundedFetch } from "../bounded-fetch"; +import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; const FAMILIES = ["opus", "fable", "sonnet", "haiku"] as const; type Family = typeof FAMILIES[number]; @@ -14,7 +15,7 @@ type Family = typeof FAMILIES[number]; * Family collapse lives under its own key: the Models page collapses PROVIDERS, and a * shared key would make folding "opus" here fold a provider of the same name there. */ -const FAMILY_COLLAPSE = makeCollapseStore("ocx.claudeDesktop.collapsedFamilies.v1"); +const FAMILY_COLLAPSE = makeCollapseStore("ocx.claudeDesktop.collapsedFamilies.v2"); interface Assignment { family: Family; @@ -122,12 +123,20 @@ function formatContextWindow(value: number | undefined, t: TFn): string | null { export default function ClaudeDesktop({ apiBase }: { apiBase: string }) { const t = useT(); + const cacheKey = `ocx.claude-desktop.v1:${apiBase}`; + const cached = readSessionListCache<{ data: DesktopResponse; profile: DesktopProfile }>(cacheKey); const [status, setStatus] = useState(null); - const [data, setData] = useState(null); - const [profile, setProfile] = useState(null); - const [savedProfile, setSavedProfile] = useState(null); - const [destinations, setDestinations] = useState>({}); - const [loading, setLoading] = useState(true); + const [data, setData] = useState(() => cached?.data ?? null); + const [profile, setProfile] = useState(() => cached?.profile ?? null); + const [savedProfile, setSavedProfile] = useState(() => ( + cached?.profile ? cloneProfile(cached.profile) : null + )); + const [destinations, setDestinations] = useState>(() => ( + cached?.data + ? Object.fromEntries(cached.data.models.map(model => [model.route, cached.profile.assignments[model.route]?.family ?? "opus"])) + : {} + )); + const [loading, setLoading] = useState(() => !cached?.data); const [loadError, setLoadError] = useState(""); const [message, setMessage] = useState<{ tone: "ok" | "err"; text: string } | null>(null); const [announcement, setAnnouncement] = useState(""); @@ -140,15 +149,16 @@ export default function ClaudeDesktop({ apiBase }: { apiBase: string }) { // Collapse is view state too. It is a plain user-owned Set rather than something // derived per render: modelsByFamily changes on every move, so deriving would fold a // section under the user's cursor the moment they moved the last model out of it. - const [collapsedFamilies, setCollapsedFamilies] = useState>(() => FAMILY_COLLAPSE.read() ?? new Set()); + const [collapsedFamilies, setCollapsedFamilies] = useState>(() => FAMILY_COLLAPSE.read() ?? new Set(FAMILIES)); // Which rows the user has explicitly opened or closed. Deliberately NOT persisted: // a family's fold is a durable preference, but which single model you were inspecting // is not, and restoring five open rows on reload would rebuild the wall this removes. const [openRows, setOpenRows] = useState>({}); const importRef = useRef(null); + const hasCacheRef = useRef(Boolean(cached?.data)); const load = useCallback(async () => { - setLoading(true); + if (!hasCacheRef.current) setLoading(true); setLoadError(""); try { const response = await fetch(`${apiBase}/api/claude-desktop`); @@ -164,6 +174,8 @@ export default function ClaudeDesktop({ apiBase }: { apiBase: string }) { setProfile(normalized); setSavedProfile(cloneProfile(normalized)); setDestinations(Object.fromEntries(payload.models.map(model => [model.route, normalized.assignments[model.route]?.family ?? "opus"]))); + hasCacheRef.current = true; + writeSessionListCache(cacheKey, { data: payload, profile: normalized }); // Fold empty families on load, but only while the user has no stored preference. // Doing it here rather than per render means a later move or import can never // re-fold a section the user opened. @@ -173,11 +185,13 @@ export default function ClaudeDesktop({ apiBase }: { apiBase: string }) { setCollapsedFamilies(defaultCollapsedFamilies(counts)); } } catch (error) { - setLoadError(error instanceof Error ? error.message : t("claudeDesktop.loadFail")); + if (!hasCacheRef.current) { + setLoadError(error instanceof Error ? error.message : t("claudeDesktop.loadFail")); + } } finally { setLoading(false); } - }, [apiBase, t]); + }, [apiBase, cacheKey, t]); useEffect(() => { const timer = window.setTimeout(() => { void load(); }, 0); diff --git a/gui/src/pages/Combos.tsx b/gui/src/pages/Combos.tsx index e48f46e091..d11e23290a 100644 --- a/gui/src/pages/Combos.tsx +++ b/gui/src/pages/Combos.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import ComboWorkspace from "../components/ComboWorkspace"; import { type ComboItem, @@ -7,6 +7,7 @@ import { toPutBody, } from "../combo-workspace-data"; import { hideRedundantChatGptForwardProviders } from "../provider-workspace/catalog"; +import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; import { Notice } from "../ui"; import { useT } from "../i18n/shared"; @@ -27,6 +28,12 @@ type ProviderDto = { authMode?: string; }; type ConfigDto = { providers?: Record }; +type CachedCombosPage = { + combos: ComboItem[]; + providers: ProviderOption[]; + models: ModelOption[]; + cataloguedComboIds: string[]; +}; function responseError(data: unknown): string | undefined { if (!data || typeof data !== "object" || Array.isArray(data)) return undefined; @@ -41,11 +48,16 @@ function responseSucceeded(data: unknown): boolean { export default function Combos({ apiBase }: { apiBase: string }) { const t = useT(); - const [combos, setCombos] = useState([]); - const [providers, setProviders] = useState([]); - const [models, setModels] = useState([]); - const [cataloguedComboIds, setCataloguedComboIds] = useState>(() => new Set()); - const [loading, setLoading] = useState(true); + const cacheKey = `ocx.combos.workspace.v1:${apiBase}`; + const cached = readSessionListCache(cacheKey); + const hasCacheRef = useRef(Boolean(cached)); + const [combos, setCombos] = useState(() => cached?.combos ?? []); + const [providers, setProviders] = useState(() => cached?.providers ?? []); + const [models, setModels] = useState(() => cached?.models ?? []); + const [cataloguedComboIds, setCataloguedComboIds] = useState>( + () => new Set(cached?.cataloguedComboIds ?? []), + ); + const [loading, setLoading] = useState(() => !cached); const [status, setStatus] = useState(""); const [statusOk, setStatusOk] = useState(false); const [adding, setAdding] = useState(false); @@ -66,6 +78,8 @@ export default function Combos({ apiBase }: { apiBase: string }) { }, [status, statusOk]); const fetchAll = useCallback(async () => { + // Soft refresh: keep last-good workspace painted while revalidating. + if (!hasCacheRef.current) setLoading(true); try { const [combosRes, configRes, modelsRes] = await Promise.all([ fetch(`${apiBase}/api/combos`), @@ -85,22 +99,22 @@ export default function Combos({ apiBase }: { apiBase: string }) { ? (modelsRaw as { models: unknown[] }).models : []; - setCombos(parseComboList(combosJson)); + const nextCombos = parseComboList(combosJson); + setCombos(nextCombos); const allProviders = configJson.providers ?? {}; // Collapse canonical forward aliases only in the new-member picker. Validation keeps // every configured provider id, including legacy chatgpt members already in a combo. const visibleProviders = hideRedundantChatGptForwardProviders(allProviders); - setProviders( - Object.entries(allProviders).map(([name, p]) => ({ - name, - disabled: !!p.disabled, - hiddenFromPicker: !Object.hasOwn(visibleProviders, name), - authMode: p.authMode, - adapter: p.adapter, - baseUrl: p.baseUrl, - })), - ); + const nextProviders = Object.entries(allProviders).map(([name, p]) => ({ + name, + disabled: !!p.disabled, + hiddenFromPicker: !Object.hasOwn(visibleProviders, name), + authMode: p.authMode, + adapter: p.adapter, + baseUrl: p.baseUrl, + })); + setProviders(nextProviders); const fromApi: ModelOption[] = []; const catalogued = new Set(); @@ -144,12 +158,19 @@ export default function Combos({ apiBase }: { apiBase: string }) { } setModels(fromApi); + hasCacheRef.current = true; + writeSessionListCache(cacheKey, { + combos: nextCombos, + providers: nextProviders, + models: fromApi, + cataloguedComboIds: [...catalogued], + } satisfies CachedCombosPage); } catch { - notify(t("cws.loadFailed"), false); + if (!hasCacheRef.current) notify(t("cws.loadFailed"), false); } finally { setLoading(false); } - }, [apiBase, t]); + }, [apiBase, cacheKey, t]); useEffect(() => { const timer = window.setTimeout(() => { @@ -211,7 +232,8 @@ export default function Combos({ apiBase }: { apiBase: string }) { } }; - if (loading && combos.length === 0) { + // Cold start only — cached empty lists paint the workspace immediately. + if (loading && !hasCacheRef.current) { return (
{status && ( diff --git a/gui/src/pages/Debug.tsx b/gui/src/pages/Debug.tsx index 34719b0aeb..0280f0fe39 100644 --- a/gui/src/pages/Debug.tsx +++ b/gui/src/pages/Debug.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useEffectEvent, useRef, useState } from "react" import { useVirtualizer } from "@tanstack/react-virtual"; import { setClientResourceData, useKeyedClientResource } from "../client-resource"; import { useI18n } from "../i18n/shared"; +import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; import { DebugClaudeInboundPanel } from "./debug-claude-inbound-panel"; import { DebugLogViewer } from "./debug-log-viewer"; import { DebugPageHeader, DebugSettingsPanel } from "./debug-settings-panel"; @@ -16,8 +17,10 @@ function debugSettingsKey(apiBase: string): string { return `debug-settings:${apiBase}`; } -export default function Debug({ apiBase, embedded }: { apiBase: string; embedded?: boolean }) { +export default function Debug({ apiBase, embedded, active = true }: { apiBase: string; embedded?: boolean; active?: boolean }) { const { t } = useI18n(); + const settingsCacheKey = `ocx.debug.settings.v1:${apiBase}`; + const cachedSettings = readSessionListCache(settingsCacheKey); const [debugBusy, setDebugBusy] = useState(false); const [stream, setStream] = useState("provider"); const [entries, setEntries] = useState([]); @@ -27,6 +30,9 @@ export default function Debug({ apiBase, embedded }: { apiBase: string; embedded const mutationGenerationRef = useRef(0); const mutationQueueRef = useRef | null>(null); const scrollContainerRef = useRef(null); + // Only reset the log viewer when the active stream identity changes — not when + // unrelated debug flags toggle (those used to rebuild fetchLogs and storm GETs). + const streamIdentityRef = useRef(null); const debugPoll = useKeyedClientResource( debugSettingsKey(apiBase), @@ -34,11 +40,13 @@ export default function Debug({ apiBase, embedded }: { apiBase: string; embedded async (signal) => { const res = await fetch(`${apiBase}/api/debug`, { signal }); if (!res.ok) return null; - return res.json() as Promise; + const next = await res.json() as DebugSettings; + writeSessionListCache(settingsCacheKey, next); + return next; }, - { pollMs: 2000 }, + { pollMs: 2000, enabled: active }, ); - const debug = debugPoll.data ?? null; + const debug = debugPoll.data ?? cachedSettings ?? null; const claudePoll = useKeyedClientResource( `debug-claude-inbound:${apiBase}`, @@ -49,7 +57,7 @@ export default function Debug({ apiBase, embedded }: { apiBase: string; embedded const data = await res.json() as { entries?: import("./debug-shared").ClaudeInboundEntry[] }; return Array.isArray(data.entries) ? data.entries : []; }, - { pollMs: 2000, enabled: !!debug?.claude }, + { pollMs: 2000, enabled: active && !!debug?.claude }, ); const claudeEntries = claudePoll.data ?? []; @@ -105,23 +113,30 @@ export default function Debug({ apiBase, embedded }: { apiBase: string; embedded }, [logsPath, streamEnabled]); useEffect(() => { + if (!active) return; + const identity = `${stream}:${streamEnabled}`; + const changed = streamIdentityRef.current !== identity; + streamIdentityRef.current = identity; + if (!changed && entries.length > 0) return; afterRef.current = 0; const timeout = window.setTimeout(() => { - setEntries([]); + if (changed) setEntries([]); void fetchLogs(true); }, 0); return () => window.clearTimeout(timeout); - }, [stream, streamEnabled, fetchLogs]); + // Intentionally omit fetchLogs/entries — identity gate prevents switch storms. + // eslint-disable-next-line react-hooks/exhaustive-deps -- stream identity only + }, [active, stream, streamEnabled]); const pollLogs = useEffectEvent((initial: boolean) => { void fetchLogs(initial); }); useEffect(() => { - if (!follow || !streamEnabled) return; + if (!active || !follow || !streamEnabled) return; const interval = setInterval(() => pollLogs(false), 1000); return () => clearInterval(interval); - }, [follow, streamEnabled]); + }, [active, follow, streamEnabled]); useEffect(() => { if (follow && entries.length > 0) { @@ -144,6 +159,7 @@ export default function Debug({ apiBase, embedded }: { apiBase: string; embedded if (!res.ok) return; const next = await res.json() as DebugSettings; if (generation !== mutationGenerationRef.current) return; + writeSessionListCache(settingsCacheKey, next); setClientResourceData(debugSettingsKey(apiBase), next); } catch { /* ignore */ } }; diff --git a/gui/src/pages/Grok.tsx b/gui/src/pages/Grok.tsx index e30a38ba16..19f79e66aa 100644 --- a/gui/src/pages/Grok.tsx +++ b/gui/src/pages/Grok.tsx @@ -1,8 +1,9 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { EmptyState, Notice, Switch } from "../ui"; import { IconChevron } from "../icons"; import { useT, type TKey } from "../i18n/shared"; import { readJsonOrThrow } from "../fetch-json"; +import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; import { makeCollapseStore, toggleInSet } from "./collapse-store"; import { grokGroupView, type GrokCandidate } from "./grok-groups"; @@ -23,14 +24,16 @@ interface GrokStatus { excluded: string[]; } -/** Same collapse store the Desktop page uses; Grok has only two groups, both open. */ -const GROUP_COLLAPSE = makeCollapseStore("ocx.grok.collapsedGroups.v1"); +/** Same collapse store the Desktop page uses; Grok groups start collapsed. */ +const GROUP_COLLAPSE = makeCollapseStore("ocx.grok.collapsedGroups.v2"); const GROUPS = [ { id: "native", tkey: "grok.groupNative" as TKey }, { id: "routed", tkey: "grok.groupRouted" as TKey }, ] as const; +const DEFAULT_COLLAPSED_GROUPS = new Set(GROUPS.map((group) => group.id)); + /** Same context formatting the Desktop page uses, so the two surfaces read alike. */ function formatContext(value: number | undefined, t: TFn): string { if (!value) return "—"; @@ -52,19 +55,22 @@ function formatContext(value: number | undefined, t: TFn): string { */ export default function Grok({ apiBase }: { apiBase: string }) { const t = useT(); - const [status, setStatus] = useState(null); - const [loading, setLoading] = useState(true); + const cacheKey = `ocx.grok.status.v1:${apiBase}`; + const cached = readSessionListCache(cacheKey); + const [status, setStatus] = useState(() => cached); + const [loading, setLoading] = useState(() => !cached); const [error, setError] = useState(""); - const [excluded, setExcluded] = useState>(new Set()); - const [savedExcluded, setSavedExcluded] = useState>(new Set()); - // null = no stored preference; both groups start open because Grok has only two. - const [collapsed, setCollapsed] = useState>(() => GROUP_COLLAPSE.read() ?? new Set()); + const [excluded, setExcluded] = useState>(() => new Set(cached?.excluded ?? [])); + const [savedExcluded, setSavedExcluded] = useState>(() => new Set(cached?.excluded ?? [])); + // null = no stored preference; groups start collapsed so the list opens on demand. + const [collapsed, setCollapsed] = useState>(() => GROUP_COLLAPSE.read() ?? new Set(DEFAULT_COLLAPSED_GROUPS)); const [pending, setPending] = useState<"save" | "apply" | null>(null); const [message, setMessage] = useState<{ tone: "ok" | "err"; text: string } | null>(null); const [announcement, setAnnouncement] = useState(""); + const hasCacheRef = useRef(Boolean(cached)); const load = useCallback(async () => { - setLoading(true); + if (!hasCacheRef.current) setLoading(true); setError(""); try { const response = await fetch(`${apiBase}/api/grok`); @@ -72,16 +78,21 @@ export default function Grok({ apiBase }: { apiBase: string }) { if (!payload) throw new Error(t("grok.loadFail")); // Tolerate an older proxy that predates the selection routes: the page degrades // to the read-only fence view instead of crashing on a missing field. - setStatus({ ...payload, candidates: payload.candidates ?? [], excluded: payload.excluded ?? [] }); + const next = { ...payload, candidates: payload.candidates ?? [], excluded: payload.excluded ?? [] }; + setStatus(next); const saved = new Set(payload.excluded ?? []); setExcluded(saved); setSavedExcluded(saved); + hasCacheRef.current = true; + writeSessionListCache(cacheKey, next); } catch (err) { - setError(err instanceof Error ? err.message : t("grok.loadFail")); + if (!hasCacheRef.current) { + setError(err instanceof Error ? err.message : t("grok.loadFail")); + } } finally { setLoading(false); } - }, [apiBase, t]); + }, [apiBase, cacheKey, t]); // Deferred like the Desktop page: kicking the fetch off synchronously inside the effect // triggers cascading renders (and the react-doctor lint that guards against them). @@ -106,6 +117,12 @@ export default function Grok({ apiBase }: { apiBase: string }) { setCollapsed(next); }; + const setAllCollapsed = (nextCollapsed: boolean) => { + const next = nextCollapsed ? new Set(GROUPS.map((group) => group.id)) : new Set(); + GROUP_COLLAPSE.write(next); + setCollapsed(next); + }; + const toggleModel = (id: string, currentlyExcluded: boolean) => { setExcluded(current => { const next = new Set(current); @@ -154,6 +171,9 @@ export default function Grok({ apiBase }: { apiBase: string }) { } else { setMessage({ tone: "ok", text: t("grok.saved") }); setAnnouncement(t("grok.saved")); + if (status) { + writeSessionListCache(cacheKey, { ...status, excluded: [...excluded] }); + } } } catch (err) { const text = err instanceof Error ? err.message : t("grok.saveFailed"); @@ -219,6 +239,14 @@ export default function Grok({ apiBase }: { apiBase: string }) { {status && status.candidates.length > 0 && (
+
+ + +
{GROUPS.map(group => { const view = grokGroupView(status.candidates, aliasById, excluded, group.id); if (view.total === 0) return null; diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index a67db7392c..5ba85615b8 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -6,6 +6,7 @@ import { hashLogConversationQuery, matchesLogConversationId } from "../log-conve import { statusCodeInfo } from "../status-codes"; import { IconX } from "../icons"; import { modelLabel } from "../model-display"; +import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; import { EmptyState, Notice } from "../ui"; import Debug from "./Debug"; @@ -13,6 +14,10 @@ import type { LogsTab } from "./logs-tab-keydown"; import { logsTabKeyDown, readTabFromHash, selectLogsTab } from "./logs-tab-keydown"; import { speedLabel } from "./logs-speed-label"; +function logsCacheKey(apiBase: string): string { + return `ocx.logs.list.v1:${apiBase}`; +} + interface UsageBreakdown { inputTokens: number; outputTokens: number; @@ -96,12 +101,23 @@ interface LogAttempt { displayMetrics?: LogDisplayMetrics; } +type LogSurface = "claude" | "claude-desktop" | "grok"; +type LogSurfaceFilter = "all" | "claude" | "codex" | "grok"; + +/** Match Usage surface buckets: Claude includes Desktop; Codex is untagged. */ +export function logMatchesSurface(log: { surface?: LogSurface }, filter: LogSurfaceFilter): boolean { + if (filter === "all") return true; + if (filter === "claude") return log.surface === "claude" || log.surface === "claude-desktop"; + if (filter === "grok") return log.surface === "grok"; + return log.surface === undefined; +} + export interface LogEntry { requestId?: string; timestamp: number; model: string; provider: string; - surface?: "claude"; + surface?: LogSurface; conversationId?: string; requestedEffort?: string; effectiveEffort?: string; @@ -324,19 +340,24 @@ function summarizeFilteredLogs(entries: LogEntry[]): { export default function Logs({ apiBase }: { apiBase: string }) { const { t, locale } = useI18n(); - const [logs, setLogs] = useState([]); - const [loading, setLoading] = useState(true); + const cachedLogs = readSessionListCache(logsCacheKey(apiBase)); + const [logs, setLogs] = useState(() => cachedLogs ?? []); + const [loading, setLoading] = useState(() => !(cachedLogs && cachedLogs.length > 0)); const [error, setError] = useState(null); const [autoRefresh, setAutoRefresh] = useState(true); const [detail, setDetail] = useState(null); - const [surfaceFilter, setSurfaceFilter] = useState<"all" | "claude" | "codex">("all"); + const [surfaceFilter, setSurfaceFilter] = useState("all"); const [conversationFilter, setConversationFilter] = useState(""); const [conversationQueryHash, setConversationQueryHash] = useState(); const scrollContainerRef = useRef(null); + const hasLogsRef = useRef(Boolean(cachedLogs?.length)); const localeTag = LOCALES.find(l => l.code === locale)?.htmlLang; // The hash is the source of truth for the active tab (#logs vs #logs/debug), // so refresh/bookmark/back-forward keep the tab choice. const [tab, setTab] = useState(readTabFromHash); + // Lazy-mount Debug on first visit, then keep it mounted so switch toggles + // and Logs↔Debug hops do not remount (avoids settings/log refetch storms). + const [debugMounted, setDebugMounted] = useState(() => readTabFromHash() === "debug"); useEffect(() => { const onHash = () => setTab(readTabFromHash()); @@ -344,6 +365,10 @@ export default function Logs({ apiBase }: { apiBase: string }) { return () => window.removeEventListener("hashchange", onHash); }, []); + useEffect(() => { + if (tab === "debug") setDebugMounted(true); + }, [tab]); + const selectTab = selectLogsTab; const fetchLogs = useCallback(async (opts?: { silent?: boolean }) => { @@ -354,7 +379,9 @@ export default function Logs({ apiBase }: { apiBase: string }) { try { const res = await fetch(`${apiBase}/api/logs`); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`.trim()); - setLogs(await res.json()); + const next = await res.json() as LogEntry[]; + setLogs(next); + writeSessionListCache(logsCacheKey(apiBase), next); setError(null); } catch (cause) { if (silent) return; @@ -367,12 +394,17 @@ export default function Logs({ apiBase }: { apiBase: string }) { useEffect(() => { if (tab !== "logs") return; - void fetchLogs(); + // Re-entering the Logs tab keeps held rows; only cold mounts flash loading. + void fetchLogs({ silent: hasLogsRef.current }); if (!autoRefresh) return; const interval = setInterval(() => void fetchLogs({ silent: true }), 2000); return () => clearInterval(interval); }, [autoRefresh, fetchLogs, tab]); + useEffect(() => { + hasLogsRef.current = logs.length > 0; + }, [logs.length]); + const detailInfo = detail ? statusCodeInfo(detail.status, locale) : null; const conversationQuery = conversationFilter.trim(); @@ -389,8 +421,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { }, [conversationQuery]); const filteredLogs = logs.filter(log => ( - (surfaceFilter === "all" - || (surfaceFilter === "claude" ? log.surface === "claude" : log.surface !== "claude")) + logMatchesSurface(log, surfaceFilter) && (!conversationQuery || matchesLogConversationId(log.conversationId, conversationQuery, conversationQueryHash)) )); const conversationTotals = conversationQuery ? summarizeFilteredLogs(filteredLogs) : null; @@ -414,7 +445,7 @@ export default function Logs({ apiBase }: { apiBase: string }) {

{t("nav.logs")}

{tab === "logs" && ( -
- {tab === "debug" && ( -
- + {debugMounted && ( + )} - {tab === "logs" && ( -
+