Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions devlog/gui-load-perf-pages-before-after.md
Original file line number Diff line number Diff line change
@@ -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.
39 changes: 32 additions & 7 deletions gui/src/hooks/useCodexAccountPool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { accounts: CodexAccountEntry[]; activeId: string | null }>();

export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccountPoolController {
const [accounts, setAccounts] = useState<CodexAccountEntry[]>([]);
const [activeId, setActiveId] = useState<string | null>(null);
const [loadState, setLoadState] = useState<CodexAccountLoadState>("loading");
const seed = lastGoodByBase.get(apiBase);
const [accounts, setAccounts] = useState<CodexAccountEntry[]>(() => seed?.accounts ?? []);
const [activeId, setActiveId] = useState<string | null>(() => seed?.activeId ?? null);
const [loadState, setLoadState] = useState<CodexAccountLoadState>(() => (seed?.accounts.length ? "ready" : "loading"));
const [switchingId, setSwitchingId] = useState<string | null>(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.
Expand All @@ -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<string | null>(null);
const hasAccountsRef = useRef(Boolean(seed?.accounts.length));

const subscribeLoadObserver = useCallback((observer: CodexAccountLoadObserver) => {
observersRef.current.add(observer);
Expand All @@ -106,14 +111,24 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou
const observers = [...observersRef.current];
const revisions = new Map<CodexAccountLoadObserver, number>();
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<boolean> => {
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;
Expand All @@ -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 };
Expand All @@ -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
Expand Down
52 changes: 38 additions & 14 deletions gui/src/pages/ApiKeys.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
externalModelId,
type ExternalModelRow,
} from "../api-access-models";
import { readSessionListCache, writeSessionListCache } from "../session-list-cache";
import {
DEFAULT_ENDPOINTS,
deriveApiEndpoints,
Expand Down Expand Up @@ -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<ApiKeyEntry[]>([]);
const [endpoints, setEndpoints] = useState<ApiEndpointInfo>(DEFAULT_ENDPOINTS);
const [claudeCodeEnabled, setClaudeCodeEnabled] = useState(true);
const keysCacheKey = `ocx.apikeys.list.v1:${apiBase}`;
const modelsCacheKey = `ocx.apikeys.models.v1:${apiBase}`;
const cachedKeys = readSessionListCache<CachedKeysShape>(keysCacheKey);
const cachedModels = readSessionListCache<ExternalModelRow[]>(modelsCacheKey);
const hasModelsCacheRef = useRef(Boolean(cachedModels));
const [keys, setKeys] = useState<ApiKeyEntry[]>(() => cachedKeys?.keys ?? []);
const [endpoints, setEndpoints] = useState<ApiEndpointInfo>(() => cachedKeys?.endpoints ?? DEFAULT_ENDPOINTS);
const [claudeCodeEnabled, setClaudeCodeEnabled] = useState(() => cachedKeys?.claudeCodeEnabled ?? true);
const [keysLoadFailed, setKeysLoadFailed] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const [models, setModels] = useState<ExternalModelRow[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
const [models, setModels] = useState<ExternalModelRow[]>(() => cachedModels ?? []);
const [modelsLoading, setModelsLoading] = useState(() => !cachedModels);
const [modelsLoadFailed, setModelsLoadFailed] = useState(false);
const [modelQuery, setModelQuery] = useState("");
const [copiedModelId, setCopiedModelId] = useState<string | null>(null);
Expand All @@ -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;
}
Expand All @@ -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;
}
Expand All @@ -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);
Expand Down
5 changes: 3 additions & 2 deletions gui/src/pages/Claude.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,21 +62,22 @@ export default function Claude({ apiBase }: { apiBase: string }) {
</button>
</div>

{/* Keep both mounted so tab switches reuse held data and both can fetch in parallel. */}
<div
id="claude-code-panel"
role="tabpanel"
aria-labelledby="claude-code-tab"
hidden={tab !== "code"}
>
{tab === "code" && <ClaudeCode apiBase={apiBase} />}
<ClaudeCode apiBase={apiBase} />
</div>
<div
id="claude-desktop-panel"
role="tabpanel"
aria-labelledby="claude-desktop-tab"
hidden={tab !== "desktop"}
>
{tab === "desktop" && <ClaudeDesktop apiBase={apiBase} />}
<ClaudeDesktop apiBase={apiBase} />
</div>
</section>
);
Expand Down
33 changes: 23 additions & 10 deletions gui/src/pages/ClaudeCode.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<ClaudeCodeState | null>(null);
const [rows, setRows] = useState<MapRow[]>([]);
const cacheKey = `ocx.claude-code.v1:${apiBase}`;
const cached = readSessionListCache<CachedClaudeCode>(cacheKey);
const [state, setState] = useState<ClaudeCodeState | null>(() => cached?.state ?? null);
const [rows, setRows] = useState<MapRow[]>(() => 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<ClaudeCodeState & { modelMap?: Record<string, string> }>(
Expand All @@ -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.
Expand All @@ -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
Expand Down
Loading
Loading