diff --git a/gui/src/pages/Debug.tsx b/gui/src/pages/Debug.tsx index cf2e2c904..34719b0ae 100644 --- a/gui/src/pages/Debug.tsx +++ b/gui/src/pages/Debug.tsx @@ -1,98 +1,67 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +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 { IconRefresh } from "../icons"; -import { Switch } from "../ui"; - -interface DebugSettings { - enabled: boolean; - usage: boolean; - injection: boolean; - claude: boolean; - runtimeOverride: Partial>; - env: Record<"debug" | "usage" | "injection" | "claude", boolean>; -} - -interface DebugLogEntry { - seq: number; - at: number; - line: string; -} - -interface ClaudeInboundEntry { - at: number; - endpoint: string; - model: string; - resolvedModel?: string; - stream?: boolean; - maxTokens?: number; - thinkingType?: string; - thinkingBudgetTokens?: number; - outputConfigEffort?: string; - metadataKeys?: string[]; - hasMetadataUserId: boolean; - hasSystem: boolean; - anthropicBeta?: string; - userIdTag?: string; - systemTag?: string; -} - -type LogStream = "provider" | "usage" | "injection"; - -const STREAMS = ["provider", "usage", "injection"] as const; - -function formatLogTime(at: number): string { - return at > 0 ? `[${new Date(at).toLocaleTimeString()}] ` : ""; -} - -function formatClaudeInboundTime(at: number): string { - return new Date(at).toLocaleTimeString(); -} - -function isStreamEnabled(debug: DebugSettings | null, stream: LogStream): boolean { - return stream === "provider" ? !!debug?.enabled : stream === "usage" ? !!debug?.usage : !!debug?.injection; -} - -function isDebugFlagEnabled(debug: DebugSettings, flag: keyof DebugSettings["env"]): boolean { - return flag === "debug" ? debug.enabled : flag === "usage" ? debug.usage : flag === "injection" ? debug.injection : debug.claude; +import { DebugClaudeInboundPanel } from "./debug-claude-inbound-panel"; +import { DebugLogViewer } from "./debug-log-viewer"; +import { DebugPageHeader, DebugSettingsPanel } from "./debug-settings-panel"; +import { + DEBUG_STREAMS, + type DebugSettings, + type LogStream, + isStreamEnabled, +} from "./debug-shared"; + +function debugSettingsKey(apiBase: string): string { + return `debug-settings:${apiBase}`; } export default function Debug({ apiBase, embedded }: { apiBase: string; embedded?: boolean }) { const { t } = useI18n(); - const [debug, setDebug] = useState(null); const [debugBusy, setDebugBusy] = useState(false); const [stream, setStream] = useState("provider"); - const [entries, setEntries] = useState([]); + const [entries, setEntries] = useState([]); const [follow, setFollow] = useState(true); const [refreshing, setRefreshing] = useState(false); - const [claudeEntries, setClaudeEntries] = useState([]); const afterRef = useRef(0); + const mutationGenerationRef = useRef(0); + const mutationQueueRef = useRef | null>(null); const scrollContainerRef = useRef(null); - // TanStack Virtual returns unstable function identities; React Compiler skips this call. + const debugPoll = useKeyedClientResource( + debugSettingsKey(apiBase), + [apiBase], + async (signal) => { + const res = await fetch(`${apiBase}/api/debug`, { signal }); + if (!res.ok) return null; + return res.json() as Promise; + }, + { pollMs: 2000 }, + ); + const debug = debugPoll.data ?? null; + + const claudePoll = useKeyedClientResource( + `debug-claude-inbound:${apiBase}`, + [apiBase, debug?.claude], + async (signal) => { + const res = await fetch(`${apiBase}/api/claude/inbound-debug`, { signal }); + if (!res.ok) return [] as import("./debug-shared").ClaudeInboundEntry[]; + const data = await res.json() as { entries?: import("./debug-shared").ClaudeInboundEntry[] }; + return Array.isArray(data.entries) ? data.entries : []; + }, + { pollMs: 2000, enabled: !!debug?.claude }, + ); + const claudeEntries = claudePoll.data ?? []; + // eslint-disable-next-line react-hooks/incompatible-library -- known useVirtualizer limitation const lineVirtualizer = useVirtualizer({ count: entries.length, getScrollElement: () => scrollContainerRef.current, estimateSize: () => 20, overscan: 30, - // Rows are a rolling 2000-entry window: key by the server-assigned seq so - // cached measurements track entry identity when the head is trimmed. getItemKey: index => entries[index]!.seq, }); - useEffect(() => { - const fetchDebug = async () => { - try { - const res = await fetch(`${apiBase}/api/debug`); - if (res.ok) setDebug(await res.json()); - } catch { /* ignore */ } - }; - void fetchDebug(); - const interval = setInterval(() => void fetchDebug(), 2000); - return () => clearInterval(interval); - }, [apiBase]); - const streamIsOn = useCallback( (candidate: LogStream): boolean => isStreamEnabled(debug, candidate), [debug], @@ -100,7 +69,7 @@ export default function Debug({ apiBase, embedded }: { apiBase: string; embedded useEffect(() => { if (!debug || streamIsOn(stream)) return; - const next = STREAMS.find(streamIsOn); + const next = DEBUG_STREAMS.find(streamIsOn); if (!next) return; const timeout = window.setTimeout(() => setStream(next), 0); return () => window.clearTimeout(timeout); @@ -126,7 +95,7 @@ export default function Debug({ apiBase, embedded }: { apiBase: string; embedded if (!initial && afterRef.current > 0) params.set("after", String(afterRef.current)); const res = await fetch(`${logsPath}?${params}`); if (!res.ok) return; - const next = await res.json() as DebugLogEntry[]; + const next = await res.json() as import("./debug-shared").DebugLogEntry[]; if (next.length === 0) return; setEntries(prev => (initial ? next : [...prev, ...next]).slice(-2000)); afterRef.current = next[next.length - 1]!.seq; @@ -144,240 +113,92 @@ export default function Debug({ apiBase, embedded }: { apiBase: string; embedded return () => window.clearTimeout(timeout); }, [stream, streamEnabled, fetchLogs]); + const pollLogs = useEffectEvent((initial: boolean) => { + void fetchLogs(initial); + }); + useEffect(() => { if (!follow || !streamEnabled) return; - const interval = setInterval(() => void fetchLogs(false), 1000); + const interval = setInterval(() => pollLogs(false), 1000); return () => clearInterval(interval); - }, [follow, streamEnabled, fetchLogs]); + }, [follow, streamEnabled]); useEffect(() => { if (follow && entries.length > 0) { - lineVirtualizer.scrollToIndex(entries.length - 1, { align: 'end' }); + lineVirtualizer.scrollToIndex(entries.length - 1, { align: "end" }); } }, [entries, follow, lineVirtualizer]); - useEffect(() => { - if (!debug?.claude) { - setClaudeEntries([]); - return; - } - const fetchClaude = async () => { + const runDebugMutation = async (body: Record) => { + const generation = ++mutationGenerationRef.current; + setDebugBusy(true); + // Serialize PUTs so server writes follow user-action order. Latest-wins + // response filtering alone cannot prevent out-of-order server state. + const run = async () => { try { - const res = await fetch(`${apiBase}/api/claude/inbound-debug`); - if (res.ok) { - const data = await res.json() as { entries?: ClaudeInboundEntry[] }; - setClaudeEntries(Array.isArray(data.entries) ? data.entries : []); - } + const res = await fetch(`${apiBase}/api/debug`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) return; + const next = await res.json() as DebugSettings; + if (generation !== mutationGenerationRef.current) return; + setClientResourceData(debugSettingsKey(apiBase), next); } catch { /* ignore */ } }; - void fetchClaude(); - const interval = setInterval(() => void fetchClaude(), 2000); - return () => clearInterval(interval); - }, [apiBase, debug?.claude]); - - const setDebugFlag = async (flag: "debug" | "usage" | "injection" | "claude", enabled: boolean) => { - setDebugBusy(true); + const previous = mutationQueueRef.current ?? Promise.resolve(); + const queued = previous.then(run, run); + mutationQueueRef.current = queued.then(() => undefined, () => undefined); try { - const res = await fetch(`${apiBase}/api/debug`, { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ [flag]: enabled }), - }); - if (res.ok) setDebug(await res.json()); - } catch { /* ignore */ } finally { - setDebugBusy(false); + await queued; + } finally { + if (generation === mutationGenerationRef.current) setDebugBusy(false); } }; + const setDebugFlag = async (flag: "debug" | "usage" | "injection" | "claude", enabled: boolean) => { + await runDebugMutation({ [flag]: enabled }); + }; + const resetDebug = async () => { - setDebugBusy(true); - try { - const res = await fetch(`${apiBase}/api/debug`, { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ reset: true }), - }); - if (res.ok) setDebug(await res.json()); - } catch { /* ignore */ } finally { - setDebugBusy(false); - } + await runDebugMutation({ reset: true }); }; return ( <> -
- {!embedded &&

{t("debug.title")}

} -
- - -
-
-

{t("debug.subtitle")}

+ void fetchLogs(true)} + onFollowChange={setFollow} + /> {!debug ? (
{t("debug.loading")}
) : ( -
-
-
- {(["debug", "usage", "injection", "claude"] as const).map(flag => { - const checked = isDebugFlagEnabled(debug, flag); - return ( -
- void setDebugFlag(flag, !checked)} - /> - {t(`debug.${flag}`)} -
- ); - })} -
- -
- - {(debug.enabled || debug.usage || debug.injection) && ( -
- {debug.enabled && ( - - )} - {debug.usage && ( - - )} - {debug.injection && ( - - )} -
- )} -
+ { void setDebugFlag(flag, enabled); }} + onReset={() => { void resetDebug(); }} + onStreamChange={setStream} + /> )} - {debug?.claude && ( -
-
{t("debug.claudeInbound.title")}
-
{t("debug.claudeInbound.sub")}
- {claudeEntries.length === 0 ? ( -
{t("debug.claudeInbound.empty")}
- ) : ( -
- - - - - - - {/* Protocol field names from Claude inbound capture — not prose. */} - - - - - - - - - {claudeEntries.map((entry, i) => ( - - - - - - - - - - - ))} - -
{t("debug.claudeInbound.time")}{t("debug.claudeInbound.endpoint")}{t("debug.claudeInbound.model")}thinkingeffortbetametadatasystem
{formatClaudeInboundTime(entry.at)}{entry.endpoint} - {entry.model} - {entry.resolvedModel && entry.resolvedModel !== entry.model && ( - → {entry.resolvedModel} - )} - - {entry.thinkingType ?? "-"} - {entry.thinkingBudgetTokens !== undefined && ({entry.thinkingBudgetTokens})} - {entry.outputConfigEffort ?? "-"}{entry.anthropicBeta ?? "-"} - {entry.hasMetadataUserId ? `user_id ${entry.userIdTag ?? ""}` : t("debug.claudeInbound.none")} - {entry.hasSystem ? entry.systemTag ?? "yes" : t("debug.claudeInbound.none")}
-
- )} -
- )} + {debug?.claude && } - {debug && !streamEnabled ? ( -
-
{t("debug.emptyTitle")}
-
{t("debug.empty")}
-
- ) : debug && streamEnabled && entries.length === 0 ? ( -
-
{t("debug.noLinesTitle")}
-
{t(`debug.noLines.${stream}`)}
-
- ) : debug && streamEnabled ? ( -
-
- {lineVirtualizer.getVirtualItems().map(virtualRow => ( -
- {`${formatLogTime(entries[virtualRow.index]!.at)}${entries[virtualRow.index]!.line}`} -
- ))} -
-
- ) : null} + ); } diff --git a/gui/src/pages/Startup.tsx b/gui/src/pages/Startup.tsx index 9f8a367d3..ccd3f7922 100644 --- a/gui/src/pages/Startup.tsx +++ b/gui/src/pages/Startup.tsx @@ -1,80 +1,19 @@ -import { useCallback, useEffect, useState } from "react"; -import { IconAlert, IconCheck, IconPower, IconRefresh, IconTerminal } from "../icons"; -import { useI18n, type TKey } from "../i18n/shared"; -import { startupRiskDetailKey } from "../startup-health-ui"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { IconRefresh } from "../icons"; +import { useI18n } from "../i18n/shared"; import { EmptyState } from "../ui"; - -type StartupStatus = "native" | "protected" | "at-risk"; -type StartupProtection = "service" | "shim" | "none"; -type StartupInstallAction = "install-service" | "install-shim"; - -interface StartupHealthData { - status: StartupStatus; - routingKind: "native" | "opencodex-local" | "custom-local" | "custom-remote" | "unknown"; - routingInjected: boolean; - localRoutingDependency: boolean; - autostartEnabled: boolean; - rebootSafe: boolean; - protection: StartupProtection; - serviceInstalled: boolean; - serviceViable: boolean; - serviceEnabled: boolean; - serviceRunning: boolean; - serviceStale: boolean; - serviceConflict: boolean; - serviceSupported: boolean; - shimInstalled: boolean; - shimHealthy: boolean; - shimCoverage: "full" | "cli-only" | "none"; - platform: string; - recommendedCommand: string | null; - diagnosticStale: boolean; - commands: { - installService: string; - installShim: string; - restoreNative: string; - }; -} - -interface TrayStatusData { - supported: boolean; - installed: boolean; - running: boolean; - stale: boolean; - summary: string; -} - -function isTrayStatusData(value: unknown): value is TrayStatusData { - if (!value || typeof value !== "object") return false; - const row = value as Record; - return typeof row.supported === "boolean" - && typeof row.installed === "boolean" - && typeof row.running === "boolean" - && typeof row.stale === "boolean" - && typeof row.summary === "string"; -} - -const STATUS_KEYS: Record = { - native: "startup.status.native", - protected: "startup.status.protected", - "at-risk": "startup.status.atRisk", -}; - -const SUMMARY_KEYS: Record = { - native: "startup.summary.native", - protected: "startup.summary.protected", - "at-risk": "startup.summary.atRisk", -}; - -const PROTECTION_KEYS: Record = { - service: "startup.protection.service", - shim: "startup.protection.shim", - none: "startup.protection.none", -}; - -function StateBadge({ ok, yes, no }: { ok: boolean; yes: string; no: string }) { - return {ok ? yes : no}; -} +import { + StartupDetailsSection, + StartupHeroSection, + StartupRecoverySection, + StartupTraySection, +} from "./startup-sections"; +import { + isTrayStatusData, + type StartupHealthData, + type StartupInstallAction, + type TrayStatusData, +} from "./startup-shared"; export default function Startup({ apiBase }: { apiBase: string }) { const { t } = useI18n(); @@ -90,15 +29,17 @@ export default function Startup({ apiBase }: { apiBase: string }) { const [installResult, setInstallResult] = useState<{ kind: "success" | "error"; action: StartupInstallAction; detail?: string } | null>(null); const [codexRuntimeWarning, setCodexRuntimeWarning] = useState(null); const [codexRuntimeFix, setCodexRuntimeFix] = useState(null); + const loadGenerationRef = useRef(0); const refresh = useCallback(async (signal?: AbortSignal) => { + const generation = ++loadGenerationRef.current; setLoading(true); setTrayLoading(true); try { const res = await fetch(`${apiBase}/api/startup-health`, { signal }); if (!res.ok) throw new Error("fetch failed"); const next = await res.json() as StartupHealthData; - if (signal?.aborted) return; + if (signal?.aborted || generation !== loadGenerationRef.current) return; setData(next); setFailed(next.diagnosticStale); try { @@ -111,9 +52,7 @@ export default function Startup({ apiBase }: { apiBase: string }) { catalogClamp?: { active?: boolean; removedEfforts?: string[]; runtimeVersion?: string | null }; }; }; - if (!signal?.aborted) { - // newerAvailable comes from /api/settings full resolveCodexRuntime() - // (memoized alternative discovery), not discoverAlternatives:false. + if (!signal?.aborted && generation === loadGenerationRef.current) { const runtime = settings.codexRuntime; const clampActive = Boolean(runtime?.catalogClamp?.active); const newer = Boolean(runtime?.newerAvailable); @@ -140,12 +79,12 @@ export default function Startup({ apiBase }: { apiBase: string }) { : null, ); } - } else if (!signal?.aborted) { + } else if (!signal?.aborted && generation === loadGenerationRef.current) { setCodexRuntimeWarning(null); setCodexRuntimeFix(null); } } catch { - if (!signal?.aborted) { + if (!signal?.aborted && generation === loadGenerationRef.current) { setCodexRuntimeWarning(null); setCodexRuntimeFix(null); } @@ -157,29 +96,27 @@ export default function Startup({ apiBase }: { apiBase: string }) { if (!trayRes.ok) throw new Error("tray status failed"); const trayNext = await trayRes.json() as unknown; if (!isTrayStatusData(trayNext)) throw new Error("invalid tray status"); - if (!signal?.aborted) { + if (!signal?.aborted && generation === loadGenerationRef.current) { setTray(trayNext); setTrayError(false); } } catch { - if (!signal?.aborted) { + if (!signal?.aborted && generation === loadGenerationRef.current) { setTray(null); setTrayError(true); } - } finally { - if (!signal?.aborted) setTrayLoading(false); } - } else { - setTrayLoading(false); } } catch { - if (signal?.aborted) return; + if (signal?.aborted || generation !== loadGenerationRef.current) return; setFailed(true); setTray(null); setTrayError(true); - setTrayLoading(false); } finally { - if (!signal?.aborted) setLoading(false); + if (generation === loadGenerationRef.current) { + setTrayLoading(false); + setLoading(false); + } } }, [apiBase, t]); @@ -188,6 +125,9 @@ export default function Startup({ apiBase }: { apiBase: string }) { const timer = window.setTimeout(() => { void refresh(controller.signal); }, 0); return () => { window.clearTimeout(timer); + // Invalidate before abort so a superseded request's finally cannot clear + // loading in the gap before the deferred replacement increments generation. + loadGenerationRef.current += 1; controller.abort(); }; }, [refresh]); @@ -239,8 +179,10 @@ export default function Startup({ apiBase }: { apiBase: string }) { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action }), }); - const body = await res.json().catch(() => null) as { error?: unknown } | null; - if (!res.ok) throw new Error(typeof body?.error === "string" ? body.error : "installation failed"); + if (!res.ok) { + const body = await res.json().catch(() => null) as { error?: unknown } | null; + throw new Error(typeof body?.error === "string" ? body.error : "installation failed"); + } setInstallResult({ kind: "success", action }); await refresh(); } catch (error) { @@ -250,21 +192,6 @@ export default function Startup({ apiBase }: { apiBase: string }) { } }; - const routingKey: TKey = data?.routingKind === "opencodex-local" ? "startup.routing.proxy" - : data?.routingKind === "custom-local" ? "startup.routing.customLocal" - : data?.routingKind === "custom-remote" ? "startup.routing.customRemote" - : data?.routingKind === "unknown" ? "startup.routing.unknown" - : "startup.routing.native"; - - const statusClass = failed - ? "startup-hero--risk" - : data?.status === "protected" - ? "startup-hero--safe" - : data?.status === "at-risk" - ? "startup-hero--risk" - : "startup-hero--native"; - const StatusIcon = failed || data?.status === "at-risk" ? IconAlert : IconCheck; - return ( <>
@@ -289,11 +216,7 @@ export default function Startup({ apiBase }: { apiBase: string }) {

{codexRuntimeWarning}

{codexRuntimeFix && (

- {codexRuntimeFix} @@ -301,165 +224,24 @@ export default function Startup({ apiBase }: { apiBase: string }) { )}

)} -
-
-
- - {t(failed ? "startup.status.atRisk" : STATUS_KEYS[data.status])} - -

{t(failed ? "startup.error" : SUMMARY_KEYS[data.status])}

-

{failed - ? t("startup.staleData") - : data.status === "at-risk" - ? t(startupRiskDetailKey(data)) - : t("startup.safeDetail")}

-
-
- -
-
-
{t("startup.routing")}
-
{t(routingKey)}
-
-
-
{t("startup.restartProtection")}
-
{t(PROTECTION_KEYS[data.protection])}
-
-
-
{t("startup.preference")}
-
{t(data.autostartEnabled ? "startup.enabled" : "startup.disabled")}
-
-
- -
-
-

{t("startup.details")}

- {data.platform} -
-
-
{t("startup.service")}{t("startup.serviceHint")}
-
- - {data.serviceSupported && !data.serviceInstalled && ( - - )} -
-
-
-
{t("startup.shim")}{t("startup.shimHint")}
-
- - {!data.shimInstalled && ( - - )} -
-
- {installResult && ( -
- {installResult.kind === "success" - ? t(installResult.action === "install-service" ? "startup.serviceInstalled" : "startup.shimInstalled") - : `${t("startup.installFailed")} ${installResult.detail ?? ""}`} -
- )} -
- + + { void runInstallAction(action); }} + /> {data.platform === "win32" && ( -
-
-

{t("startup.tray.title")}

- -
-

{t("startup.tray.hint")}

-
-
- {t("startup.tray.login")} - {t("startup.tray.notProtection")} -
- {trayLoading || trayError || !tray - ? {t(trayLoading ? "startup.tray.loading" : "startup.tray.unavailable")} - : } -
-
- {!trayLoading && !trayError && tray && !tray.installed && !tray.stale && ( - - )} - {!trayLoading && !trayError && tray?.installed && !tray.stale && !tray.running && ( - - )} - {!trayLoading && !trayError && tray?.running && !tray.stale && ( - - )} - {!trayLoading && !trayError && tray && (tray.installed || tray.stale) && ( - - )} -
- {(trayError || tray?.stale) &&
{t("startup.tray.error")}
} -
+ { void runTrayAction(action); }} + /> )} - -
-
-

{t("startup.recovery")}

- -
-

{t("startup.recoveryHint")}

-
- {data.serviceSupported && ( -
-
- {t("startup.command.service")} - {data.commands.installService} -
- -
- )} -
-
- {t("startup.command.shim")} - {data.commands.installShim} -
- -
-
-
- {t("startup.command.native")} - {data.commands.restoreNative} -
- -
-
- {data.status === "at-risk" && ( -
- {t("startup.recommended", { cmd: data.recommendedCommand ?? data.commands.installService })} -
- )} -
+ { void copyCommand(command); }} /> ) : null} diff --git a/gui/src/pages/Storage.tsx b/gui/src/pages/Storage.tsx index 5bed88d3f..38c86ef66 100644 --- a/gui/src/pages/Storage.tsx +++ b/gui/src/pages/Storage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useI18n, type TFn, type TKey, type Locale } from "../i18n/shared"; import { EmptyState } from "../ui"; import { IconRefresh } from "../icons"; @@ -115,20 +115,24 @@ export default function Storage({ apiBase }: { apiBase: string }) { const { t, locale } = useI18n(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); + const loadGenerationRef = useRef(0); const fetchStorage = useCallback(async (signal?: AbortSignal) => { + const generation = ++loadGenerationRef.current; setLoading(true); try { const res = await fetch(`${apiBase}/api/storage`, { signal }); if (!res.ok) throw new Error("fetch failed"); const json = await res.json() as StorageReport; - if (signal?.aborted) return; + if (signal?.aborted || generation !== loadGenerationRef.current) return; setData(json); } catch { - if (signal?.aborted) return; + if (signal?.aborted || generation !== loadGenerationRef.current) return; setData(null); } finally { - if (!signal?.aborted) setLoading(false); + // Only the current request may clear loading — a superseded abort must not + // settle the UI while a newer fetch is still in flight. + if (generation === loadGenerationRef.current) setLoading(false); } }, [apiBase]); @@ -140,6 +144,9 @@ export default function Storage({ apiBase }: { apiBase: string }) { }, 0); return () => { window.clearTimeout(timeout); + // Invalidate before abort so a superseded request's finally cannot clear + // loading in the gap before the deferred replacement increments generation. + loadGenerationRef.current += 1; controller.abort(); }; }, [fetchStorage]); diff --git a/gui/src/pages/Usage.tsx b/gui/src/pages/Usage.tsx index 5df10e41a..54d310248 100644 --- a/gui/src/pages/Usage.tsx +++ b/gui/src/pages/Usage.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useI18n, type TFn, type Locale } from "../i18n/shared"; import { formatTokens } from "../format-tokens"; +import { formatEstimatedUsdValue as formatUsdEstimate } from "../intl-formatters"; import { EmptyState, Notice } from "../ui"; import { modelLabel } from "../model-display"; @@ -84,14 +85,6 @@ function formatPct(ratio: number): string { return `${Math.round(ratio * 100)}%`; } -function formatEstimatedUsdValue(value: number, locale: Locale): string { - if (!Number.isFinite(value) || value < 0) return "\u2014"; - return `~$${new Intl.NumberFormat(locale, { - minimumFractionDigits: 4, - maximumFractionDigits: 4, - }).format(value)}`; -} - // Stable per-model bar color: hash the provider/model id to a hue so the same model keeps its color // across days and renders. Saturation/lightness are fixed for a cohesive palette on the dark chart. function modelColor(model: string, provider: string): string { @@ -292,7 +285,7 @@ function UsageSummaryCards({
{t("usage.cost.total")} - {formatEstimatedUsdValue(summary.estimatedCostUsd, locale)} + {formatUsdEstimate(summary.estimatedCostUsd, locale)} {t("usage.cost.disclaimer")} {((summary.unpricedRequests ?? 0) + (summary.unmeteredRequests ?? 0)) > 0 && ( @@ -592,23 +585,27 @@ export default function Usage({ apiBase }: { apiBase: string }) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [modelQuery, setModelQuery] = useState(""); + const loadGenerationRef = useRef(0); const fetchUsage = useCallback(async (nextRange: Range, nextSurface: UsageSurface, signal: AbortSignal) => { + const generation = ++loadGenerationRef.current; setLoading(true); setError(null); try { const res = await fetch(`${apiBase}/api/usage?range=${nextRange}&surface=${nextSurface}`, { signal }); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`.trim()); const json = await res.json() as UsageResponse; - if (signal.aborted) return; + if (signal.aborted || generation !== loadGenerationRef.current) return; setData(json); } catch (cause) { // A stale request (range/apiBase changed, or unmount) must not overwrite newer state. - if (signal.aborted) return; + if (signal.aborted || generation !== loadGenerationRef.current) return; const detail = cause instanceof Error ? cause.message : ""; setError(detail ? `${t("usage.loadError")} ${detail}` : t("usage.loadError")); } finally { - if (!signal.aborted) setLoading(false); + // Only the current request may clear loading — a superseded abort must not + // settle the UI while a newer fetch is still in flight. + if (generation === loadGenerationRef.current) setLoading(false); } }, [apiBase, t]); @@ -619,6 +616,9 @@ export default function Usage({ apiBase }: { apiBase: string }) { }, 0); return () => { window.clearTimeout(timeout); + // Invalidate before abort so a superseded request's finally cannot clear + // loading in the gap before the deferred replacement increments generation. + loadGenerationRef.current += 1; controller.abort(); }; }, [fetchUsage, range, surface]); @@ -629,7 +629,7 @@ export default function Usage({ apiBase }: { apiBase: string }) { const filteredModels = useMemo(() => { const q = modelQuery.trim().toLowerCase(); const models = data?.models ?? []; - const sorted = [...models].sort((a, b) => b.totalTokens - a.totalTokens); + const sorted = models.toSorted((a, b) => b.totalTokens - a.totalTokens); if (!q) return sorted.slice(0, 100); return sorted.filter(m => m.model.toLowerCase().includes(q) || @@ -639,7 +639,7 @@ export default function Usage({ apiBase }: { apiBase: string }) { }, [data?.models, modelQuery]); const sortedProviders = useMemo(() => - [...(data?.providers ?? [])].sort((a, b) => b.totalTokens - a.totalTokens), + (data?.providers ?? []).toSorted((a, b) => b.totalTokens - a.totalTokens), [data?.providers], ); diff --git a/gui/src/pages/debug-claude-inbound-panel.tsx b/gui/src/pages/debug-claude-inbound-panel.tsx new file mode 100644 index 000000000..1450f23e3 --- /dev/null +++ b/gui/src/pages/debug-claude-inbound-panel.tsx @@ -0,0 +1,58 @@ +import { useI18n } from "../i18n/shared"; +import type { ClaudeInboundEntry } from "./debug-shared"; +import { formatClaudeInboundTime } from "./debug-shared"; + +export function DebugClaudeInboundPanel({ entries }: { entries: ClaudeInboundEntry[] }) { + const { t } = useI18n(); + + return ( +
+
{t("debug.claudeInbound.title")}
+
{t("debug.claudeInbound.sub")}
+ {entries.length === 0 ? ( +
{t("debug.claudeInbound.empty")}
+ ) : ( +
+ + + + + + + + + + + + + + + {entries.map(entry => ( + + + + + + + + + + + ))} + +
{t("debug.claudeInbound.time")}{t("debug.claudeInbound.endpoint")}{t("debug.claudeInbound.model")}thinkingeffortbetametadatasystem
{formatClaudeInboundTime(entry.at)}{entry.endpoint} + {entry.model} + {entry.resolvedModel && entry.resolvedModel !== entry.model && ( + → {entry.resolvedModel} + )} + + {entry.thinkingType ?? "-"} + {entry.thinkingBudgetTokens !== undefined && ({entry.thinkingBudgetTokens})} + {entry.outputConfigEffort ?? "-"}{entry.anthropicBeta ?? "-"} + {entry.hasMetadataUserId ? `user_id ${entry.userIdTag ?? ""}` : t("debug.claudeInbound.none")} + {entry.hasSystem ? entry.systemTag ?? "yes" : t("debug.claudeInbound.none")}
+
+ )} +
+ ); +} diff --git a/gui/src/pages/debug-log-viewer.tsx b/gui/src/pages/debug-log-viewer.tsx new file mode 100644 index 000000000..c7f580bff --- /dev/null +++ b/gui/src/pages/debug-log-viewer.tsx @@ -0,0 +1,75 @@ +import type { Virtualizer } from "@tanstack/react-virtual"; +import { useI18n } from "../i18n/shared"; +import type { DebugLogEntry, LogStream } from "./debug-shared"; +import { formatLogTime } from "./debug-shared"; + +export function DebugLogViewer({ + debug, + stream, + streamEnabled, + entries, + scrollContainerRef, + lineVirtualizer, +}: { + debug: boolean; + stream: LogStream; + streamEnabled: boolean; + entries: DebugLogEntry[]; + scrollContainerRef: React.RefObject; + lineVirtualizer: Virtualizer; +}) { + const { t } = useI18n(); + + if (!debug) return null; + + if (!streamEnabled) { + return ( +
+
{t("debug.emptyTitle")}
+
{t("debug.empty")}
+
+ ); + } + + if (entries.length === 0) { + return ( +
+
{t("debug.noLinesTitle")}
+
{t(`debug.noLines.${stream}`)}
+
+ ); + } + + return ( +
+
+ {lineVirtualizer.getVirtualItems().map(virtualRow => ( +
+ {`${formatLogTime(entries[virtualRow.index]!.at)}${entries[virtualRow.index]!.line}`} +
+ ))} +
+
+ ); +} diff --git a/gui/src/pages/debug-settings-panel.tsx b/gui/src/pages/debug-settings-panel.tsx new file mode 100644 index 000000000..6cc06c5d3 --- /dev/null +++ b/gui/src/pages/debug-settings-panel.tsx @@ -0,0 +1,122 @@ +import { useI18n } from "../i18n/shared"; +import { IconRefresh } from "../icons"; +import { Switch } from "../ui"; +import type { DebugSettings, LogStream } from "./debug-shared"; +import { isDebugFlagEnabled } from "./debug-shared"; + +export function DebugSettingsPanel({ + debug, + debugBusy, + stream, + onSetFlag, + onReset, + onStreamChange, +}: { + debug: DebugSettings; + debugBusy: boolean; + stream: LogStream; + onSetFlag: (flag: "debug" | "usage" | "injection" | "claude", enabled: boolean) => void; + onReset: () => void; + onStreamChange: (stream: LogStream) => void; +}) { + const { t } = useI18n(); + + return ( +
+
+
+ {(["debug", "usage", "injection", "claude"] as const).map(flag => { + const checked = isDebugFlagEnabled(debug, flag); + return ( +
+ onSetFlag(flag, !checked)} + /> + {t(`debug.${flag}`)} +
+ ); + })} +
+ +
+ + {(debug.enabled || debug.usage || debug.injection) && ( +
+ {debug.enabled && ( + + )} + {debug.usage && ( + + )} + {debug.injection && ( + + )} +
+ )} +
+ ); +} + +export function DebugPageHeader({ + embedded, + refreshing, + streamEnabled, + follow, + onRefresh, + onFollowChange, +}: { + embedded?: boolean; + refreshing: boolean; + streamEnabled: boolean; + follow: boolean; + onRefresh: () => void; + onFollowChange: (follow: boolean) => void; +}) { + const { t } = useI18n(); + + return ( + <> +
+ {!embedded &&

{t("debug.title")}

} +
+ + +
+
+

{t("debug.subtitle")}

+ + ); +} diff --git a/gui/src/pages/debug-shared.ts b/gui/src/pages/debug-shared.ts new file mode 100644 index 000000000..1d7acf37b --- /dev/null +++ b/gui/src/pages/debug-shared.ts @@ -0,0 +1,53 @@ +export interface DebugSettings { + enabled: boolean; + usage: boolean; + injection: boolean; + claude: boolean; + runtimeOverride: Partial>; + env: Record<"debug" | "usage" | "injection" | "claude", boolean>; +} + +export interface DebugLogEntry { + seq: number; + at: number; + line: string; +} + +export interface ClaudeInboundEntry { + id: number; + at: number; + endpoint: string; + model: string; + resolvedModel?: string; + stream?: boolean; + maxTokens?: number; + thinkingType?: string; + thinkingBudgetTokens?: number; + outputConfigEffort?: string; + metadataKeys?: string[]; + hasMetadataUserId: boolean; + hasSystem: boolean; + anthropicBeta?: string; + userIdTag?: string; + systemTag?: string; +} + +export type LogStream = "provider" | "usage" | "injection"; + +export const DEBUG_STREAMS = ["provider", "usage", "injection"] as const; + +export function formatLogTime(at: number): string { + return at > 0 ? `[${new Date(at).toLocaleTimeString()}] ` : ""; +} + +export function formatClaudeInboundTime(at: number): string { + return new Date(at).toLocaleTimeString(); +} + +export function isStreamEnabled(debug: DebugSettings | null, stream: LogStream): boolean { + return stream === "provider" ? !!debug?.enabled : stream === "usage" ? !!debug?.usage : !!debug?.injection; +} + +export function isDebugFlagEnabled(debug: DebugSettings, flag: keyof DebugSettings["env"]): boolean { + return flag === "debug" ? debug.enabled : flag === "usage" ? debug.usage : flag === "injection" ? debug.injection : debug.claude; +} diff --git a/gui/src/pages/startup-sections.tsx b/gui/src/pages/startup-sections.tsx new file mode 100644 index 000000000..98659f889 --- /dev/null +++ b/gui/src/pages/startup-sections.tsx @@ -0,0 +1,253 @@ +import { useI18n, type TKey } from "../i18n/shared"; +import { startupRiskDetailKey } from "../startup-health-ui"; +import { IconAlert, IconCheck, IconPower, IconTerminal } from "../icons"; +import type { + StartupHealthData, + StartupInstallAction, + TrayStatusData, +} from "./startup-shared"; +import { + PROTECTION_KEYS, + STATUS_KEYS, + SUMMARY_KEYS, +} from "./startup-shared"; + +function StartupStateBadge({ ok, yes, no }: { ok: boolean; yes: string; no: string }) { + return {ok ? yes : no}; +} + +export function StartupHeroSection({ + failed, + data, +}: { + failed: boolean; + data: StartupHealthData; +}) { + const { t } = useI18n(); + const statusClass = failed + ? "startup-hero--risk" + : data.status === "protected" + ? "startup-hero--safe" + : data.status === "at-risk" + ? "startup-hero--risk" + : "startup-hero--native"; + const StatusIcon = failed || data.status === "at-risk" ? IconAlert : IconCheck; + + const routingKey: TKey = data.routingKind === "opencodex-local" ? "startup.routing.proxy" + : data.routingKind === "custom-local" ? "startup.routing.customLocal" + : data.routingKind === "custom-remote" ? "startup.routing.customRemote" + : data.routingKind === "unknown" ? "startup.routing.unknown" + : "startup.routing.native"; + + return ( + <> +
+
+
+ + {t(failed ? "startup.status.atRisk" : STATUS_KEYS[data.status])} + +

{t(failed ? "startup.error" : SUMMARY_KEYS[data.status])}

+

{failed + ? t("startup.staleData") + : data.status === "at-risk" + ? t(startupRiskDetailKey(data)) + : t("startup.safeDetail")}

+
+
+ +
+
+
{t("startup.routing")}
+
{t(routingKey)}
+
+
+
{t("startup.restartProtection")}
+
{t(PROTECTION_KEYS[data.protection])}
+
+
+
{t("startup.preference")}
+
{t(data.autostartEnabled ? "startup.enabled" : "startup.disabled")}
+
+
+ + ); +} + +export function StartupDetailsSection({ + data, + failed, + installBusy, + installResult, + onInstall, +}: { + data: StartupHealthData; + failed: boolean; + installBusy: StartupInstallAction | null; + installResult: { kind: "success" | "error"; action: StartupInstallAction; detail?: string } | null; + onInstall: (action: StartupInstallAction) => void; +}) { + const { t } = useI18n(); + + return ( +
+
+

{t("startup.details")}

+ {data.platform} +
+
+
{t("startup.service")}{t("startup.serviceHint")}
+
+ + {data.serviceSupported && !data.serviceInstalled && ( + + )} +
+
+
+
{t("startup.shim")}{t("startup.shimHint")}
+
+ + {!data.shimInstalled && ( + + )} +
+
+ {installResult && ( +
+ {installResult.kind === "success" + ? t(installResult.action === "install-service" ? "startup.serviceInstalled" : "startup.shimInstalled") + : `${t("startup.installFailed")} ${installResult.detail ?? ""}`} +
+ )} +
+ ); +} + +export function StartupTraySection({ + tray, + trayLoading, + trayError, + trayBusy, + onTrayAction, +}: { + tray: TrayStatusData | null; + trayLoading: boolean; + trayError: boolean; + trayBusy: boolean; + onTrayAction: (action: "install" | "start" | "stop" | "uninstall") => void; +}) { + const { t } = useI18n(); + + return ( +
+
+

{t("startup.tray.title")}

+ +
+

{t("startup.tray.hint")}

+
+
+ {t("startup.tray.login")} + {t("startup.tray.notProtection")} +
+ {trayLoading || trayError || !tray + ? {t(trayLoading ? "startup.tray.loading" : "startup.tray.unavailable")} + : } +
+
+ {!trayLoading && !trayError && tray && !tray.installed && !tray.stale && ( + + )} + {!trayLoading && !trayError && tray?.installed && !tray.stale && !tray.running && ( + + )} + {!trayLoading && !trayError && tray?.running && !tray.stale && ( + + )} + {!trayLoading && !trayError && tray && (tray.installed || tray.stale) && ( + + )} +
+ {(trayError || tray?.stale) &&
{t("startup.tray.error")}
} +
+ ); +} + +export function StartupRecoverySection({ + data, + copied, + onCopy, +}: { + data: StartupHealthData; + copied: string | null; + onCopy: (command: string) => void; +}) { + const { t } = useI18n(); + + return ( +
+
+

{t("startup.recovery")}

+ +
+

{t("startup.recoveryHint")}

+
+ {data.serviceSupported && ( +
+
+ {t("startup.command.service")} + {data.commands.installService} +
+ +
+ )} +
+
+ {t("startup.command.shim")} + {data.commands.installShim} +
+ +
+
+
+ {t("startup.command.native")} + {data.commands.restoreNative} +
+ +
+
+ {data.status === "at-risk" && ( +
+ {t("startup.recommended", { cmd: data.recommendedCommand ?? data.commands.installService })} +
+ )} +
+ ); +} diff --git a/gui/src/pages/startup-shared.ts b/gui/src/pages/startup-shared.ts new file mode 100644 index 000000000..dd61a2469 --- /dev/null +++ b/gui/src/pages/startup-shared.ts @@ -0,0 +1,69 @@ +import type { TKey } from "../i18n/shared"; + +export type StartupStatus = "native" | "protected" | "at-risk"; +export type StartupProtection = "service" | "shim" | "none"; +export type StartupInstallAction = "install-service" | "install-shim"; + +export interface StartupHealthData { + status: StartupStatus; + routingKind: "native" | "opencodex-local" | "custom-local" | "custom-remote" | "unknown"; + routingInjected: boolean; + localRoutingDependency: boolean; + autostartEnabled: boolean; + rebootSafe: boolean; + protection: StartupProtection; + serviceInstalled: boolean; + serviceViable: boolean; + serviceEnabled: boolean; + serviceRunning: boolean; + serviceStale: boolean; + serviceConflict: boolean; + serviceSupported: boolean; + shimInstalled: boolean; + shimHealthy: boolean; + shimCoverage: "full" | "cli-only" | "none"; + platform: string; + recommendedCommand: string | null; + diagnosticStale: boolean; + commands: { + installService: string; + installShim: string; + restoreNative: string; + }; +} + +export interface TrayStatusData { + supported: boolean; + installed: boolean; + running: boolean; + stale: boolean; + summary: string; +} + +export function isTrayStatusData(value: unknown): value is TrayStatusData { + if (!value || typeof value !== "object") return false; + const row = value as Record; + return typeof row.supported === "boolean" + && typeof row.installed === "boolean" + && typeof row.running === "boolean" + && typeof row.stale === "boolean" + && typeof row.summary === "string"; +} + +export const STATUS_KEYS: Record = { + native: "startup.status.native", + protected: "startup.status.protected", + "at-risk": "startup.status.atRisk", +}; + +export const SUMMARY_KEYS: Record = { + native: "startup.summary.native", + protected: "startup.summary.protected", + "at-risk": "startup.summary.atRisk", +}; + +export const PROTECTION_KEYS: Record = { + service: "startup.protection.service", + shim: "startup.protection.shim", + none: "startup.protection.none", +}; diff --git a/gui/tests/debug-claude-inbound-keys.test.tsx b/gui/tests/debug-claude-inbound-keys.test.tsx new file mode 100644 index 000000000..5485e92ab --- /dev/null +++ b/gui/tests/debug-claude-inbound-keys.test.tsx @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import { DebugClaudeInboundPanel } from "../src/pages/debug-claude-inbound-panel"; +import type { ClaudeInboundEntry } from "../src/pages/debug-shared"; + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +test("Claude inbound rows with identical at/endpoint/model stay distinct via capture id", async () => { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + + const shared = { + at: 1_700_000_000_000, + endpoint: "messages", + model: "claude-opus-4-8-ncb", + hasMetadataUserId: false, + hasSystem: false, + } as const; + + const entries: ClaudeInboundEntry[] = [ + { ...shared, id: 11, thinkingType: "adaptive", outputConfigEffort: "max" }, + { ...shared, id: 12, thinkingType: "enabled", thinkingBudgetTokens: 10000 }, + ]; + + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render( + + + , + ); + }); + + const rows = container.querySelectorAll("tbody tr"); + expect(rows).toHaveLength(2); + expect(rows[0]?.textContent).toContain("adaptive"); + expect(rows[0]?.textContent).toContain("max"); + expect(rows[1]?.textContent).toContain("enabled"); + expect(rows[1]?.textContent).toContain("10000"); + + // Update the second row only — unique keys must keep the first row intact. + await act(async () => { + root.render( + + + , + ); + }); + + const updated = container.querySelectorAll("tbody tr"); + expect(updated).toHaveLength(2); + expect(updated[0]?.textContent).toContain("adaptive"); + expect(updated[0]?.textContent).toContain("max"); + expect(updated[1]?.textContent).toContain("disabled"); + expect(updated[1]?.textContent).not.toContain("10000"); + + await act(async () => { + root.unmount(); + }); + container.remove(); +}); diff --git a/gui/tests/debug-mutation-busy.test.tsx b/gui/tests/debug-mutation-busy.test.tsx new file mode 100644 index 000000000..0e2b7d65d --- /dev/null +++ b/gui/tests/debug-mutation-busy.test.tsx @@ -0,0 +1,319 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import Debug from "../src/pages/Debug"; +import type { DebugSettings } from "../src/pages/debug-shared"; + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT", "ResizeObserver"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +const originalFetch = globalThis.fetch; + +const BASE_SETTINGS: DebugSettings = { + enabled: false, + usage: false, + injection: false, + claude: false, + runtimeOverride: {}, + env: { debug: false, usage: false, injection: false, claude: false }, +}; + +function installLayoutStubs(win: Window): void { + const proto = win.HTMLElement.prototype as unknown as HTMLElement; + Object.defineProperty(proto, "clientHeight", { configurable: true, get() { return 800; } }); + Object.defineProperty(proto, "clientWidth", { configurable: true, get() { return 1200; } }); + Object.defineProperty(proto, "offsetHeight", { configurable: true, get() { return 800; } }); + Object.defineProperty(proto, "offsetWidth", { configurable: true, get() { return 1200; } }); + Object.defineProperty(proto, "scrollHeight", { configurable: true, get() { return 800; } }); + Object.defineProperty(proto, "getBoundingClientRect", { + configurable: true, + value() { + return { + x: 0, y: 0, top: 0, left: 0, bottom: 800, right: 1200, width: 1200, height: 800, + toJSON() { return this; }, + }; + }, + }); + + class ResizeObserverStub { + #cb: ResizeObserverCallback; + constructor(cb: ResizeObserverCallback) { this.#cb = cb; } + observe(target: Element) { + this.#cb( + [{ + target, + contentRect: { + x: 0, y: 0, top: 0, left: 0, bottom: 800, right: 1200, width: 1200, height: 800, + toJSON() { return this; }, + }, + borderBoxSize: [], + contentBoxSize: [], + devicePixelContentBoxSize: [], + } as unknown as ResizeObserverEntry], + this as unknown as ResizeObserver, + ); + } + unobserve() {} + disconnect() {} + } + Object.defineProperty(globalThis, "ResizeObserver", { configurable: true, value: ResizeObserverStub }); + Object.defineProperty(win, "ResizeObserver", { configurable: true, value: ResizeObserverStub }); +} + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/#debug" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + installLayoutStubs(testWindow); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +async function waitFor(predicate: () => boolean, timeoutMs = 1500): Promise { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out"); + await act(async () => { + await new Promise(resolve => testWindow.setTimeout(resolve, 10)); + }); + } +} + +test("rapid Debug flag/reset keeps controls busy and applies only the latest mutation", async () => { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + + type Gate = { resolve: () => void }; + const putGates: Gate[] = []; + const putBodies: unknown[] = []; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const method = (init?.method ?? "GET").toUpperCase(); + if (url.includes("/api/debug/logs") || url.includes("/api/debug/usage-logs") || url.includes("/api/debug/injection-logs")) { + return Response.json([]); + } + if (url.endsWith("/api/debug") && method === "GET") { + return Response.json(BASE_SETTINGS); + } + if (url.endsWith("/api/debug") && method === "PUT") { + const body = JSON.parse(String(init?.body ?? "{}")) as Record; + putBodies.push(body); + await new Promise(resolve => { + putGates.push({ resolve }); + }); + if (body.reset === true) { + return Response.json({ + ...BASE_SETTINGS, + enabled: false, + usage: false, + injection: false, + claude: false, + runtimeOverride: {}, + } satisfies DebugSettings); + } + return Response.json({ + ...BASE_SETTINGS, + enabled: body.debug === true ? true : BASE_SETTINGS.enabled, + usage: body.usage === true ? true : BASE_SETTINGS.usage, + injection: body.injection === true ? true : BASE_SETTINGS.injection, + claude: body.claude === true ? true : BASE_SETTINGS.claude, + runtimeOverride: { + ...(body.debug === true ? { debug: true } : {}), + ...(body.usage === true ? { usage: true } : {}), + ...(body.injection === true ? { injection: true } : {}), + ...(body.claude === true ? { claude: true } : {}), + }, + } satisfies DebugSettings); + } + return new Response(null, { status: 404 }); + }) as typeof fetch; + + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render( + + + , + ); + }); + await waitFor(() => container.querySelector('button.switch[aria-label="Usage extraction"]') != null); + + const usageSwitch = () => container.querySelector('button.switch[aria-label="Usage extraction"]'); + const resetBtn = () => Array.from(container.querySelectorAll("button")).find(btn => + (btn.textContent ?? "").includes("Clear runtime overrides"), + ); + + // Fire flag + reset before the first busy paint can serialize them via disabled controls. + await act(async () => { + usageSwitch()?.click(); + resetBtn()?.click(); + }); + // Serialized: only the first PUT is in flight until it settles. + await waitFor(() => putGates.length === 1); + expect(putBodies).toEqual([{ usage: true }]); + expect(usageSwitch()?.disabled).toBe(true); + expect(resetBtn()?.disabled).toBe(true); + + await act(async () => { + putGates[0]!.resolve(); + await Promise.resolve(); + }); + await waitFor(() => putGates.length === 2); + expect(putBodies[1]).toEqual({ reset: true }); + expect(usageSwitch()?.disabled).toBe(true); + + await act(async () => { + putGates[1]!.resolve(); + await Promise.resolve(); + }); + await waitFor(() => usageSwitch()?.disabled === false); + expect(usageSwitch()?.getAttribute("aria-pressed")).toBe("false"); + expect(resetBtn()?.disabled).toBe(false); + + await act(async () => { + root.unmount(); + }); + container.remove(); +}); + +test("Debug PUTs are serialized so reverse-order response settlement cannot leave server ahead of UI", async () => { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + + type Gate = { resolve: () => void }; + const putGates: Gate[] = []; + const putStartOrder: string[] = []; + let inFlight = 0; + let maxInFlight = 0; + let serverSettings: DebugSettings = { ...BASE_SETTINGS, runtimeOverride: {}, env: { ...BASE_SETTINGS.env } }; + const serverSnapshots: DebugSettings[] = []; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const method = (init?.method ?? "GET").toUpperCase(); + if (url.includes("/api/debug/logs") || url.includes("/api/debug/usage-logs") || url.includes("/api/debug/injection-logs")) { + return Response.json([]); + } + if (url.endsWith("/api/debug") && method === "GET") { + return Response.json(serverSettings); + } + if (url.endsWith("/api/debug") && method === "PUT") { + const body = JSON.parse(String(init?.body ?? "{}")) as Record; + putStartOrder.push(body.reset === true ? "reset" : Object.keys(body).sort().join(",")); + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + try { + await new Promise(resolve => { + putGates.push({ resolve }); + }); + // Apply when the response settles. Concurrent PUTs + reverse resolve + // order (reset, then usage) would leave usage=true on the server while + // browser generation checks show the reset UI — serialization prevents that. + if (body.reset === true) { + serverSettings = { + ...BASE_SETTINGS, + enabled: false, + usage: false, + injection: false, + claude: false, + runtimeOverride: {}, + env: { ...BASE_SETTINGS.env }, + }; + } else { + serverSettings = { + ...serverSettings, + enabled: body.debug === undefined ? serverSettings.enabled : body.debug === true, + usage: body.usage === undefined ? serverSettings.usage : body.usage === true, + injection: body.injection === undefined ? serverSettings.injection : body.injection === true, + claude: body.claude === undefined ? serverSettings.claude : body.claude === true, + runtimeOverride: { + ...serverSettings.runtimeOverride, + ...(body.debug === undefined ? {} : { debug: body.debug === true }), + ...(body.usage === undefined ? {} : { usage: body.usage === true }), + ...(body.injection === undefined ? {} : { injection: body.injection === true }), + ...(body.claude === undefined ? {} : { claude: body.claude === true }), + }, + }; + } + serverSnapshots.push({ + ...serverSettings, + runtimeOverride: { ...serverSettings.runtimeOverride }, + env: { ...serverSettings.env }, + }); + return Response.json(serverSettings); + } finally { + inFlight -= 1; + } + } + return new Response(null, { status: 404 }); + }) as typeof fetch; + + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render( + + + , + ); + }); + await waitFor(() => container.querySelector('button.switch[aria-label="Usage extraction"]') != null); + + const usageSwitch = () => container.querySelector('button.switch[aria-label="Usage extraction"]'); + const resetBtn = () => Array.from(container.querySelectorAll("button")).find(btn => + (btn.textContent ?? "").includes("Clear runtime overrides"), + ); + + await act(async () => { + usageSwitch()?.click(); + resetBtn()?.click(); + }); + + await waitFor(() => putGates.length === 1); + expect(putStartOrder).toEqual(["usage"]); + // Attempt reverse-order settlement: only the in-flight PUT can resolve. + // A concurrent implementation would have putGates[1] available here. + expect(putGates[1]).toBeUndefined(); + await act(async () => { + putGates[0]!.resolve(); + await Promise.resolve(); + }); + await waitFor(() => putGates.length === 2); + expect(putStartOrder).toEqual(["usage", "reset"]); + expect(serverSnapshots[0]?.usage).toBe(true); + + await act(async () => { + putGates[1]!.resolve(); + await Promise.resolve(); + }); + await waitFor(() => usageSwitch()?.disabled === false); + + expect(maxInFlight).toBe(1); + expect(serverSettings.usage).toBe(false); + expect(serverSettings.runtimeOverride).toEqual({}); + expect(usageSwitch()?.getAttribute("aria-pressed")).toBe("false"); + expect(serverSnapshots.at(-1)?.usage).toBe(false); + + await act(async () => { + root.unmount(); + }); + container.remove(); +}); diff --git a/gui/tests/storage-loading-race.test.tsx b/gui/tests/storage-loading-race.test.tsx new file mode 100644 index 000000000..208b78f56 --- /dev/null +++ b/gui/tests/storage-loading-race.test.tsx @@ -0,0 +1,210 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act, useState } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import Storage from "../src/pages/Storage"; + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +const originalFetch = globalThis.fetch; + +const REPORT_A = { + codexHome: "/tmp/a", + generatedAt: 1, + total: { bytes: 10, fileCount: 1 }, + buckets: [{ key: "other", label: "Other", bytes: 10, fileCount: 1 }], +}; + +const REPORT_B = { + codexHome: "/tmp/b", + generatedAt: 2, + total: { bytes: 20, fileCount: 2 }, + buckets: [{ key: "other", label: "Other", bytes: 20, fileCount: 2 }], +}; + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +async function waitFor(predicate: () => boolean, timeoutMs = 1000): Promise { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out"); + await act(async () => { + await new Promise(resolve => testWindow.setTimeout(resolve, 10)); + }); + } +} + +test("an aborted Storage fetch must not clear loading while its replacement is in flight", async () => { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + + type Gate = { resolve: (body: unknown) => void }; + const gates: Gate[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (!url.includes("/api/storage")) return new Response(null, { status: 404 }); + const body = await new Promise(resolve => { + gates.push({ resolve }); + }); + return Response.json(body); + }) as typeof fetch; + + function Harness() { + const [apiBase, setApiBase] = useState("http://old"); + (window as unknown as { __bumpApiBase?: () => void }).__bumpApiBase = () => setApiBase("http://new"); + return ( + + + + ); + } + + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render(); + }); + await act(async () => { + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); + }); + await waitFor(() => gates.length === 1); + + // Replacement starts; the first request is aborted by effect cleanup. + await act(async () => { + (window as unknown as { __bumpApiBase: () => void }).__bumpApiBase(); + }); + await act(async () => { + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); + }); + await waitFor(() => gates.length === 2); + + // Stale aborted request completes after the replacement has already begun. + await act(async () => { + gates[0]!.resolve(REPORT_A); + await Promise.resolve(); + }); + await act(async () => { + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); + }); + + const refresh = container.querySelector("button.btn"); + expect(container.textContent).toContain("Scanning storage"); + expect(refresh?.disabled).toBe(true); + expect(container.textContent).not.toContain("/tmp/a"); + + await act(async () => { + gates[1]!.resolve(REPORT_B); + await Promise.resolve(); + }); + await waitFor(() => (container.textContent ?? "").includes("/tmp/b")); + expect(container.textContent).not.toContain("/tmp/a"); + expect(refresh?.disabled).toBe(false); + + await act(async () => { + root.unmount(); + }); + container.remove(); +}); + +test("effect cleanup invalidates generation before abort so loading stays owned across the deferred replacement gap", async () => { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + + type Gate = { + resolve: (body: unknown) => void; + reject: (reason?: unknown) => void; + }; + const gates: Gate[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (!url.includes("/api/storage")) return new Response(null, { status: 404 }); + const signal = init?.signal; + const body = await new Promise((resolve, reject) => { + const gate: Gate = { resolve, reject }; + gates.push(gate); + if (signal?.aborted) { + reject(new DOMException("The operation was aborted.", "AbortError")); + return; + } + signal?.addEventListener("abort", () => { + reject(new DOMException("The operation was aborted.", "AbortError")); + }, { once: true }); + }); + return Response.json(body); + }) as typeof fetch; + + function Harness() { + const [apiBase, setApiBase] = useState("http://old"); + (window as unknown as { __bumpApiBase?: () => void }).__bumpApiBase = () => setApiBase("http://new"); + return ( + + + + ); + } + + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render(); + }); + await act(async () => { + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); + }); + await waitFor(() => gates.length === 1); + + // Cleanup aborts + invalidates generation; replacement is still deferred (setTimeout 0). + await act(async () => { + (window as unknown as { __bumpApiBase: () => void }).__bumpApiBase(); + }); + + // Flush abort rejection / finally microtasks without running the deferred fetch. + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const refresh = container.querySelector("button.btn"); + expect(gates.length).toBe(1); + expect(container.textContent).toContain("Scanning storage"); + expect(refresh?.disabled).toBe(true); + + await act(async () => { + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); + }); + await waitFor(() => gates.length === 2); + + await act(async () => { + gates[1]!.resolve(REPORT_B); + await Promise.resolve(); + }); + await waitFor(() => (container.textContent ?? "").includes("/tmp/b")); + expect(refresh?.disabled).toBe(false); + + await act(async () => { + root.unmount(); + }); + container.remove(); +}); diff --git a/src/claude/inbound-debug.ts b/src/claude/inbound-debug.ts index df60798e6..ea408dfcf 100644 --- a/src/claude/inbound-debug.ts +++ b/src/claude/inbound-debug.ts @@ -16,6 +16,8 @@ import { createHmac, randomBytes } from "node:crypto"; import { isClaudeDebugEnabled } from "../lib/debug-settings"; export interface ClaudeInboundDebugEntry { + /** Monotonic capture id — unique even when several entries share Date.now(). */ + id: number; at: number; endpoint: "messages" | "count_tokens"; model: string; @@ -39,6 +41,7 @@ const RING_LIMIT = 20; const ring: ClaudeInboundDebugEntry[] = []; const salt = randomBytes(16).toString("hex"); let lastEnabled = false; +let nextCaptureId = 0; function tag(value: string): string { return createHmac("sha256", salt).update(value).digest("hex").slice(0, 8); @@ -82,6 +85,7 @@ export function captureClaudeInbound( const userId = metadata && typeof metadata.user_id === "string" ? metadata.user_id : undefined; const system = systemText(body.system); const entry: ClaudeInboundDebugEntry = { + id: ++nextCaptureId, at: Date.now(), endpoint, model: typeof body.model === "string" ? body.model : "unknown", diff --git a/tests/claude-inbound-debug.test.ts b/tests/claude-inbound-debug.test.ts index 96627cae4..617bff3db 100644 --- a/tests/claude-inbound-debug.test.ts +++ b/tests/claude-inbound-debug.test.ts @@ -81,4 +81,26 @@ describe("claude inbound debug capture (devlog 130 B1)", () => { const [entry] = getClaudeInboundDebugEntries(); expect(entry!.anthropicBeta).toBe("context-1m-2025-08-07,effort-2025-11-24"); }); + + test("same-millisecond captures get unique monotonic ids", () => { + setDebugSettings({ claude: true }); + const now = Date.now(); + const originalNow = Date.now; + Date.now = () => now; + try { + captureClaudeInbound("messages", body); + captureClaudeInbound("messages", body); + captureClaudeInbound("messages", body); + } finally { + Date.now = originalNow; + } + const entries = getClaudeInboundDebugEntries(); + expect(entries).toHaveLength(3); + expect(entries.every(e => e.at === now)).toBe(true); + const ids = entries.map(e => e.id); + expect(new Set(ids).size).toBe(3); + // Newest-first snapshot; ids remain monotonic in capture order. + expect(ids[0]!).toBeGreaterThan(ids[1]!); + expect(ids[1]!).toBeGreaterThan(ids[2]!); + }); });