diff --git a/devlog/gui-load-perf-pages-before-after.md b/devlog/gui-load-perf-pages-before-after.md new file mode 100644 index 000000000..7dd5e7f9f --- /dev/null +++ b/devlog/gui-load-perf-pages-before-after.md @@ -0,0 +1,99 @@ +# GUI page-data TTI — before / after (PERF-2) + +Date: 2026-07-28 +Branch: `perf/gui-page-data` + +## Baseline (cold, local :10100) + +Same proxy measurements as PERF-1: + +| Endpoint | Cold latency | +|----------|----------------| +| `/api/usage?range=30d` | ~5.2s | +| `/api/codex-auth/accounts` | ~1.2s | +| `/api/models` | ~333ms | +| status / config / providers / settings | fast (sub-200ms typical) | + +### Before (symptoms) + +1. **Codex Auth** — Account/quota boxes waited for both `/accounts` (~1.2s) and `/active` to finish before `loadState` became ready. Soft revisits always started from an empty list. +2. **Usage** — Switching provider/surface tabs (all/codex/claude/grok) always re-hit `/api/usage` (~5.2s cold) even when that `(range, surface)` payload was already held in the session. +3. **Storage** — Revisits re-scanned from scratch with no last-good paint. +4. **API Keys** — Keys + `/v1/models` already fired in parallel, but cold remounts always showed empty/loading until both returned. +5. **Claude Code/Desktop** — Tab switch unmounted the inactive panel and remounted it (full refetch). Desktop status poll was already independent. +6. **Grok / Subagents** — Full-page loading gate on every visit; no last-good list seed. +7. **Logs + Debug** — Switching Logs↔Debug unmounted Debug (settings + log streams restarted). Debug log viewer reset whenever `fetchLogs` identity churned around flag toggles. Logs tab re-entry forced a non-silent refetch. + +## Changes + +### Shared + +- `session-list-cache.ts` — sessionStorage read/write for **non-secret** list/summary shapes (usage summaries, storage report, key prefixes, model catalogs, debug flags, Claude/Grok/Subagents configs). No API secrets or auth tokens. + +### Codex Auth + +- In-memory last-good account snapshot (not sessionStorage — emails/ids). +- Progressive ready: paint boxes as soon as `/accounts` returns; soft-refresh when boxes already shown. +- Mode banner seeds from session cache of config-derived mode enums. + +### Usage + +- Module + session cache keyed by `apiBase:range:surface`. +- Surface/range switches with held data paint instantly and revalidate quietly; mismatched prior payload is dropped so the wrong filter never flashes. + +### Storage + +- Seed from last scan; soft-refresh without blanking the report. + +### API Keys + +- Seed active-key prefixes + endpoints and the external model catalog from session cache. +- Keep keys/models fetches independent on mount. + +### Claude / Grok / Subagents + +- Claude Code + Desktop both stay mounted (`hidden`) so tab switches reuse state and both can fetch in parallel on first visit. +- Session-seed + soft-refresh for Claude Code, Claude Desktop, Grok, and Subagents. + +### Logs + Debug + +- Keep Debug mounted under Logs; `active` pauses polls/log streaming while the Debug tab is hidden (no remount storm). +- Logs list seeds from session cache; re-entering the Logs tab uses silent refresh when rows are already held. +- Debug log viewer resets only when stream identity (`stream` + enabled) changes — not when unrelated debug switches toggle. + +## Expected TTI improvement + +| Surface | Expected | +|---------|----------| +| Codex Auth boxes | Visible after `/accounts` (~1.2s), not after `/active` join; revisits instant from memory | +| Usage provider tabs | Instant when `(range,surface)` already held; cold first visit still ~5.2s | +| Storage | Last scan paints immediately on revisit; soft refresh | +| API Keys | Keys + catalog paint from session seed; independent refresh | +| Claude tab switch | No remount refetch | +| Grok / Subagents | Instant re-entry from session seed | +| Logs ↔ Debug | No Debug remount; switch toggles no longer clear/refetch log streams | + +## Remaining risks + +- Session seeds can briefly show stale summaries until revalidate completes. +- Claude mounts both panels on first visit (extra parallel network) — intentional for tab-switch TTI. +- Debug stays mounted while on the Logs tab (polls paused via `active={false}`). +- No measured after numbers in this pass (code-path reasoning + existing GUI tests). + +## Measured locally (2026-07-28, proxy `:10100`) + +Client-pattern timings (same machine/proxy). These approximate TTI gates, not full browser paint. + +| Pattern | ms | +|---------|-----| +| BEFORE Dashboard: core peers then controls (serial) | 5655 | +| AFTER Dashboard: controls only (serial) | 1323 | +| AFTER Dashboard: controls only (parallel) | 625 | +| BEFORE Models: catalog then shadow | 1439 | +| AFTER Models: shadow only | 16 | +| Usage cold | 725 | +| Usage warm (same query) | 9 | +| Usage other surface | 458 | +| Codex `/accounts` alone | 1020 | + +**Read:** PERF-1 wins are mostly “don’t wait for the slow path before enabling controls” (5.6s → ~tens–low hundreds ms for shadow/sidecar). PERF-2 wins are mostly “don’t refetch held page data” (usage revisit ~9ms network; UI session seed is even cheaper). Full browser TTI not instrumented in this pass. diff --git a/gui/src/hooks/useCodexAccountPool.ts b/gui/src/hooks/useCodexAccountPool.ts index f23b7f73c..689f28fad 100644 --- a/gui/src/hooks/useCodexAccountPool.ts +++ b/gui/src/hooks/useCodexAccountPool.ts @@ -73,10 +73,14 @@ export interface CodexAccountPoolController { const REFRESH_INTERVAL_MS = 30_000; +/** In-memory last-good snapshot (not sessionStorage — accounts carry emails/ids). */ +const lastGoodByBase = new Map(); + export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccountPoolController { - const [accounts, setAccounts] = useState([]); - const [activeId, setActiveId] = useState(null); - const [loadState, setLoadState] = useState("loading"); + const seed = lastGoodByBase.get(apiBase); + const [accounts, setAccounts] = useState(() => seed?.accounts ?? []); + const [activeId, setActiveId] = useState(() => seed?.activeId ?? null); + const [loadState, setLoadState] = useState(() => (seed?.accounts.length ? "ready" : "loading")); const [switchingId, setSwitchingId] = useState(null); // Pause leases live in a ref: pausing must not re-render, and the effect below reads // the live set rather than a captured snapshot. @@ -91,6 +95,7 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou // load already finished read it to seed their UI instead of waiting a poll interval. const lastThresholdRef = useRef<{ value: unknown } | null>(null); const switchingRef = useRef(null); + const hasAccountsRef = useRef(Boolean(seed?.accounts.length)); const subscribeLoadObserver = useCallback((observer: CodexAccountLoadObserver) => { observersRef.current.add(observer); @@ -106,14 +111,24 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou const observers = [...observersRef.current]; const revisions = new Map(); for (const observer of observers) revisions.set(observer, observer.beginActiveRead()); - if (!refreshQuota) setLoadState("loading"); + // Soft refresh when boxes are already on screen — avoid full-page loading flash. + if (!refreshQuota && !hasAccountsRef.current) setLoadState("loading"); + + let nextAccounts: CodexAccountEntry[] | null = null; + let nextActiveId: string | null | undefined; const accountsTask = (async (): Promise => { try { const response = await fetch(`${apiBase}/api/codex-auth/accounts${refreshQuota ? "?refresh=1" : ""}`); if (!response.ok) throw new Error("account load failed"); const payload = await response.json(); - if (loadGenerationRef.current === generation) setAccounts(payload.accounts ?? []); + if (loadGenerationRef.current === generation) { + nextAccounts = (payload.accounts ?? []) as CodexAccountEntry[]; + setAccounts(nextAccounts); + hasAccountsRef.current = nextAccounts.length > 0; + // Progressive: paint account/quota boxes as soon as /accounts returns. + setLoadState("ready"); + } return true; } catch { return false; @@ -132,6 +147,7 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou // Stale read: keep the accepted value and let the next load reconcile. } else { pendingActiveIdRef.current = null; + nextActiveId = serverActiveId; setActiveId(serverActiveId); } lastThresholdRef.current = { value: active.autoSwitchThreshold }; @@ -151,8 +167,17 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou const [accountsOk, activeOk] = await Promise.all([accountsTask, activeTask]); // A newer load already superseded this one; leave its state in place. if (loadGenerationRef.current !== generation) return false; - setLoadState(accountsOk && activeOk ? "ready" : "error"); - return accountsOk && activeOk; + if (accountsOk) { + setLoadState("ready"); + const prior = lastGoodByBase.get(apiBase); + lastGoodByBase.set(apiBase, { + accounts: nextAccounts ?? prior?.accounts ?? [], + activeId: nextActiveId !== undefined ? nextActiveId : (prior?.activeId ?? null), + }); + return activeOk; + } + if (!hasAccountsRef.current) setLoadState("error"); + return false; }, [apiBase]); // Initial load plus background refresh. Owned here so mounting or unmounting a surface diff --git a/gui/src/pages/ApiKeys.tsx b/gui/src/pages/ApiKeys.tsx index 87f0704d7..4b35fd0d4 100644 --- a/gui/src/pages/ApiKeys.tsx +++ b/gui/src/pages/ApiKeys.tsx @@ -7,6 +7,7 @@ import { externalModelId, type ExternalModelRow, } from "../api-access-models"; +import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; import { DEFAULT_ENDPOINTS, deriveApiEndpoints, @@ -37,16 +38,27 @@ interface CreateKeyResponse { key?: unknown; } +type CachedKeysShape = { + keys: ApiKeyEntry[]; + endpoints: ApiEndpointInfo; + claudeCodeEnabled: boolean; +}; + 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 ?? DEFAULT_ENDPOINTS); + const [claudeCodeEnabled, setClaudeCodeEnabled] = useState(() => cachedKeys?.claudeCodeEnabled ?? true); 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); @@ -68,28 +80,37 @@ 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); } - }, [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 +121,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 +134,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); diff --git a/gui/src/pages/Claude.tsx b/gui/src/pages/Claude.tsx index 9dfd04868..e08b6db4e 100644 --- a/gui/src/pages/Claude.tsx +++ b/gui/src/pages/Claude.tsx @@ -62,13 +62,14 @@ export default function Claude({ apiBase }: { apiBase: string }) { + {/* Keep both mounted so tab switches reuse held data and both can fetch in parallel. */} ); diff --git a/gui/src/pages/ClaudeCode.tsx b/gui/src/pages/ClaudeCode.tsx index 03e439e61..fa5f05430 100644 --- a/gui/src/pages/ClaudeCode.tsx +++ b/gui/src/pages/ClaudeCode.tsx @@ -1,8 +1,9 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Notice } from "../ui"; import { useI18n, useT, LOCALES } from "../i18n/shared"; import { modelLabel } from "../model-display"; import { readJsonOrThrow } from "../fetch-json"; +import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; import { reconcileAutoConnectState } from "./claude-autoconnect"; import { buildManualEnv } from "./claude-manual-env"; import { @@ -17,17 +18,23 @@ 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 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 +46,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 +58,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 diff --git a/gui/src/pages/ClaudeDesktop.tsx b/gui/src/pages/ClaudeDesktop.tsx index 2eaae112c..fd98e66de 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]; @@ -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(""); @@ -146,9 +155,10 @@ export default function ClaudeDesktop({ apiBase }: { apiBase: string }) { // 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/CodexAuth.tsx b/gui/src/pages/CodexAuth.tsx index 246cf5e5f..2bf0842de 100644 --- a/gui/src/pages/CodexAuth.tsx +++ b/gui/src/pages/CodexAuth.tsx @@ -3,6 +3,7 @@ import { useT } from "../i18n/shared"; import CodexAccountPool from "../components/CodexAccountPool"; import { codexAccountModeState, type CodexAccountModeState } from "../codex-multi-state"; import { ensureOpenAiProvider, openAiAccountProviderState, OpenAiEnableError } from "../provider-payload"; +import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; export type OpenAiAccountBannerState = CodexAccountModeState | "invalid" | null; @@ -68,6 +69,11 @@ function openaiProviderFromConfig(config: unknown): { }; } +type CachedMode = { + bannerState: OpenAiAccountBannerState; + accountModeState: CodexAccountModeState | null; +}; + /** * Codex Auth page — a thin wrapper around CodexAccountPool (WP060 extraction). * The page owns the /api/config fetch feeding the account-mode banner and @@ -75,8 +81,12 @@ function openaiProviderFromConfig(config: unknown): { */ export default function CodexAuth({ apiBase }: { apiBase: string }) { const t = useT(); - const [bannerState, setBannerState] = useState(null); - const [accountModeState, setAccountModeState] = useState(null); + const configCacheKey = `ocx.codex-auth.config.v1:${apiBase}`; + const cached = readSessionListCache(configCacheKey); + const [bannerState, setBannerState] = useState(() => cached?.bannerState ?? null); + const [accountModeState, setAccountModeState] = useState( + () => cached?.accountModeState ?? null, + ); const [enableBusy, setEnableBusy] = useState(false); const [enableError, setEnableError] = useState(""); @@ -89,17 +99,19 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) { if (providerState === "absent" || providerState === "disabled" || providerState === "invalid") { setBannerState(providerState); // Non-canonical / missing rows are not a live Codex account mode. - setAccountModeState(providerState === "disabled" ? "disabled" : "absent"); + const mode = providerState === "disabled" ? "disabled" as const : "absent" as const; + setAccountModeState(mode); + writeSessionListCache(configCacheKey, { bannerState: providerState, accountModeState: mode }); return; } const mode = codexAccountModeState(config); setBannerState(mode); setAccountModeState(mode); + writeSessionListCache(configCacheKey, { bannerState: mode, accountModeState: mode }); } catch { - setBannerState(null); - setAccountModeState(null); + // Keep last-good banner on transient config failures. } - }, [apiBase]); + }, [apiBase, configCacheKey]); useEffect(() => { const timeout = window.setTimeout(() => { void loadMode(); }, 0); diff --git a/gui/src/pages/Debug.tsx b/gui/src/pages/Debug.tsx index 34719b0ae..0280f0fe3 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 e30a38ba1..3d41660c3 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"; @@ -52,19 +53,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()); + const [excluded, setExcluded] = useState>(() => new Set(cached?.excluded ?? [])); + const [savedExcluded, setSavedExcluded] = useState>(() => new Set(cached?.excluded ?? [])); // null = no stored preference; both groups start open because Grok has only two. const [collapsed, setCollapsed] = useState>(() => GROUP_COLLAPSE.read() ?? new Set()); 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 +76,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). diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index 92464397d..12a36eb23 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -6,12 +6,17 @@ 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"; import type { LogsTab } from "./logs-tab-keydown"; import { logsTabKeyDown, readTabFromHash, selectLogsTab } from "./logs-tab-keydown"; +function logsCacheKey(apiBase: string): string { + return `ocx.logs.list.v1:${apiBase}`; +} + interface UsageBreakdown { inputTokens: number; outputTokens: number; @@ -307,8 +312,9 @@ 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); @@ -316,10 +322,14 @@ export default function Logs({ apiBase }: { apiBase: string }) { 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()); @@ -327,6 +337,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 }) => { @@ -337,7 +351,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; @@ -350,12 +366,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(); @@ -432,14 +453,23 @@ export default function Logs({ apiBase }: { apiBase: string }) { - {tab === "debug" && ( -
- + {debugMounted && ( + )} - {tab === "logs" && ( -
+