From b05f0171c2f5cfc9fde786e91f3ff9f34fc8f647 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 1 Aug 2026 01:46:43 +0200 Subject: [PATCH 1/6] fix(gui): stop revisit flashes and layout shift across dashboard tabs Seed session caches and drop revalidation status lines that flashed over known state. Align Claude/API/Usage/Storage chrome and reserve Models Combos space so rail and heading baselines stop jumping. --- gui/src/components/CodexAccountPool.tsx | 11 -- gui/src/components/section-tabs.tsx | 32 +++- .../storage-workspace/StorageWorkspace.tsx | 20 +-- gui/src/pages/ApiKeys.tsx | 39 ++--- gui/src/pages/Claude.tsx | 36 ++++- gui/src/pages/ClaudeCode.tsx | 54 ++++--- gui/src/pages/ClaudeDesktop.tsx | 108 ++++++++++--- gui/src/pages/Debug.tsx | 15 +- gui/src/pages/Grok.tsx | 15 +- gui/src/pages/Logs.tsx | 64 +++++--- gui/src/pages/Models.tsx | 150 ++++++++++-------- gui/src/pages/Startup.tsx | 23 ++- gui/src/pages/Subagents.tsx | 10 +- gui/src/pages/Usage.tsx | 13 +- gui/src/pages/claude-code-sections.tsx | 6 +- gui/src/pages/claude-code-settings.tsx | 2 +- gui/src/pages/dashboard-overview-panels.tsx | 6 +- gui/src/pages/dashboard-overview-sections.tsx | 9 +- gui/src/section-anchors.ts | 3 + gui/src/select-position.ts | 4 +- gui/src/styles-apikeys-workspace.css | 9 +- gui/src/styles-claudecode-workspace.css | 46 +++++- gui/src/styles-dashboard-workspace.css | 14 ++ gui/src/styles-models-workspace.css | 18 ++- gui/src/styles-storage-workspace.css | 57 ++++--- gui/src/styles.css | 77 ++++++++- gui/tests/claudecode-layout.test.ts | 10 +- gui/tests/debug-cache-revisit.test.tsx | 116 ++++++++++++++ gui/tests/page-loading-contract.test.tsx | 15 +- gui/tests/section-tabs-scroll-lock.test.tsx | 139 ++++++++++++++++ gui/tests/select-position.test.ts | 6 +- gui/tests/startup-revisit-cache.test.tsx | 110 +++++++++++++ 32 files changed, 975 insertions(+), 262 deletions(-) create mode 100644 gui/tests/debug-cache-revisit.test.tsx create mode 100644 gui/tests/section-tabs-scroll-lock.test.tsx create mode 100644 gui/tests/startup-revisit-cache.test.tsx diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index 6ab3e2f6fc..5c6b54c30e 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -11,7 +11,6 @@ import CodexPoolStrategySetting from "./CodexPoolStrategySetting"; import { useCodexAutoSwitch } from "../hooks/useCodexAutoSwitch"; import { readJsonIfOk } from "../fetch-json"; import { CodexAccountPoolCards, CodexAccountPoolReauthBanner } from "./codex-account-pool-cards"; -import { DataSurfaceStatus } from "./data-surface"; import { CodexAccountSwitchModal } from "./codex-account-switch-modal"; import { CodexAccountResetModal } from "./codex-account-reset-modal"; import { CodexAccountPoolLoadStates, CodexAccountPoolMainCard, CodexAccountPoolPageHead } from "./codex-account-pool-main-card"; @@ -57,10 +56,6 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const ownController = useCodexAccountPool(apiBase, !injectedController); const controller = injectedController ?? ownController; const { accounts, activeId, loadState, switchingId, pauseUpdatingId, pausingExhausted, load } = controller; - // The controller owns the visible progress signal. A forced quota refresh keeps rows on screen - // and can take ~1s (longer when the server has to refill per-account quota), so without this the - // wait is invisible; `refreshingQuota` below stays local because it only guards its own button. - const { refreshing } = controller; const [confirm, setConfirm] = useState(null); const [showAdd, setShowAdd] = useState(false); const [reauthId, setReauthId] = useState(null); @@ -277,12 +272,6 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban onRetry={() => { void load(); }} /> - {/* Revalidation over existing rows. The cold branch above already owns a live region, so - this only renders once rows are on screen, keeping one announcement per transition. */} - {refreshing && accounts.length > 0 && ( - {t("common.loading")} - )} - {!(loadState === "loading" && accounts.length === 0) && ( <> (null); + /** While set, scroll-spy ignores intermediate sections during smooth scroll-to-click. */ + const scrollLockRef = useRef(null); + const scrollLockTimerRef = useRef | null>(null); + + const clearScrollLock = useCallback(() => { + scrollLockRef.current = null; + if (scrollLockTimerRef.current !== null) { + clearTimeout(scrollLockTimerRef.current); + scrollLockTimerRef.current = null; + } + }, []); + + useEffect(() => () => clearScrollLock(), [clearScrollLock]); // Follow the scroll position. `rootMargin` biases the observer toward the top of the // viewport so the heading you are reading wins, not whatever is technically centred. @@ -41,6 +54,16 @@ export function SectionTabs({ const observer = new IntersectionObserver( entries => { + const locked = scrollLockRef.current; + if (locked) { + const lockedNode = document.getElementById(sectionAnchorId(scope, locked)); + const lockedVisible = entries.some(entry => entry.isIntersecting && entry.target === lockedNode); + if (lockedVisible) { + clearScrollLock(); + setActive(locked); + } + return; + } const visible = entries .filter(entry => entry.isIntersecting) .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0]; @@ -52,11 +75,14 @@ export function SectionTabs({ ); for (const node of nodes) observer.observe(node); return () => observer.disconnect(); - }, [items, scope]); + }, [clearScrollLock, items, scope]); const go = (id: string) => { const target = document.getElementById(sectionAnchorId(scope, id)); if (!target) return; + scrollLockRef.current = id; + if (scrollLockTimerRef.current !== null) clearTimeout(scrollLockTimerRef.current); + scrollLockTimerRef.current = setTimeout(clearScrollLock, SECTION_TAB_SCROLL_LOCK_MS); setActive(id); // `scroll-margin-top` on the target keeps the heading clear of the pinned strip. target.scrollIntoView({ behavior: "smooth", block: "start" }); diff --git a/gui/src/components/storage-workspace/StorageWorkspace.tsx b/gui/src/components/storage-workspace/StorageWorkspace.tsx index 2ae1be2071..dedcce01e2 100644 --- a/gui/src/components/storage-workspace/StorageWorkspace.tsx +++ b/gui/src/components/storage-workspace/StorageWorkspace.tsx @@ -169,18 +169,18 @@ export default function StorageWorkspace({ report, locale }: StorageWorkspacePro ) : (
-
-
-
{t("storage.card.total")}
-
{formatBytes(report.total.bytes, locale)}
+
+
+
{t("storage.card.total")}
+
{formatBytes(report.total.bytes, locale)}
-
-
{t("storage.card.files")}
-
{report.total.fileCount.toLocaleString(locale)}
+
+
{t("storage.card.files")}
+
{report.total.fileCount.toLocaleString(locale)}
-
-
{t("storage.card.home")}
-
{report.codexHome}
+
+
{t("storage.card.home")}
+
{report.codexHome}
diff --git a/gui/src/pages/ApiKeys.tsx b/gui/src/pages/ApiKeys.tsx index 33ee543a01..2bcebc4887 100644 --- a/gui/src/pages/ApiKeys.tsx +++ b/gui/src/pages/ApiKeys.tsx @@ -8,10 +8,11 @@ import { type ExternalModelRow, type GatewayInboundProtocol, } from "../api-access-models"; +import { setClientResourceData } from "../client-resource"; import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; import { createBoundedFetch } from "../bounded-fetch"; import { useDataSurface } from "../data-surface"; -import { DataSurfaceSkeleton, DataSurfaceStatus } from "../components/data-surface"; +import { DataSurfaceSkeleton } from "../components/data-surface"; import ApiKeysWorkspace from "../components/apikeys-workspace/ApiKeysWorkspace"; import { DEFAULT_ENDPOINTS, @@ -90,11 +91,24 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { // turn stale client state into an apparently authoritative empty auth table. const keysCacheKey = `ocx.apikeys.list.v2:${apiBase}`; const modelsCacheKey = `ocx.apikeys.models.v1:${apiBase}`; + const keysResourceKey = `api-keys:${apiBase}`; + const modelsResourceKey = `api-models:${apiBase}`; // A cache entry is arbitrary parsed JSON. Trusting it would reintroduce exactly // what the network path refuses: an empty matrix rendering as an authoritative // "no rules" table, or a row without `usage` throwing on first render. const cachedKeys = validCachedKeys(readSessionListCache(keysCacheKey)); const cachedModels = readSessionListCache(modelsCacheKey); + // Seed before subscribe so a revisit does not flash loading status under the page title. + const seededKeysRef = useRef(null); + if (seededKeysRef.current !== keysResourceKey) { + if (cachedKeys) setClientResourceData(keysResourceKey, cachedKeys); + seededKeysRef.current = keysResourceKey; + } + const seededModelsRef = useRef(null); + if (seededModelsRef.current !== modelsResourceKey) { + if (cachedModels) setClientResourceData(modelsResourceKey, cachedModels); + seededModelsRef.current = modelsResourceKey; + } const [actionError, setActionError] = useState(null); const [modelQuery, setModelQuery] = useState(""); const [copiedModelId, setCopiedModelId] = useState(null); @@ -161,13 +175,13 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { // Keys and models intentionally remain independent resources: a slow catalog must never // block endpoint/key management, and each cache key retains its own session seed. const keysResource = useDataSurface( - `api-keys:${apiBase}`, + keysResourceKey, [apiBase], fetchKeys, { isEmpty: data => data.keys.length === 0 }, ); const modelsResource = useDataSurface( - `api-models:${apiBase}`, + modelsResourceKey, [apiBase], fetchModels, { isEmpty: models => models.length === 0 }, @@ -370,7 +384,10 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { const subtitleParts = t("api.subtitle").split("{authHeader}"); return ( -
+

{t("api.title")}

@@ -395,20 +412,6 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { ) : ( <> - {/* Keys and models revalidate independently and can be in flight together. Only one - region may announce per transition, so keys take precedence and models steps down to - visual-only while keys is speaking. */} - {keysState.refreshing && keysData && ( - {t("api.activeKeysLoading")} - )} - {/* `modelsState.data` alone misses the session-cached case: after a - failure the rows on screen come from `cachedModels`, and a retry - then ran with no visible progress at all. */} - {modelsState.refreshing && (modelsState.data !== undefined || cachedModels !== null) && ( - - {t("api.modelsLoading")} - - )} (null); const desktopTabRef = useRef(null); + // Seed Desktop's port subtitle from session cache so the intro above the Code/Desktop + // strip does not wait on the first status paint after a tab hop. + const [desktopPort, setDesktopPort] = useState(() => { + const cached = readSessionListCache<{ data?: { port?: number } }>(`ocx.claude-desktop.v1:${apiBase}`); + return typeof cached?.data?.port === "number" ? cached.data.port : null; + }); const selectTab = (next: ClaudeTab) => { setTab(next); - window.requestAnimationFrame(() => (next === "code" ? codeTabRef : desktopTabRef).current?.focus()); + // preventScroll: focusing the tab must not scroll the page — otherwise the + // Code/Desktop panels' different header heights make the tab strip jump. + window.requestAnimationFrame(() => { + (next === "code" ? codeTabRef : desktopTabRef).current?.focus({ preventScroll: true }); + }); }; const handleTabKey = (event: KeyboardEvent) => { @@ -31,6 +42,22 @@ export default function Claude({ apiBase }: { apiBase: string }) { return (
+ {/* Title/subtitle sit above the Code/Desktop strip so the page reads title → selector → body. */} +
+
+

{tab === "code" ? t("claude.pageTitle") : t("claudeDesktop.title")}

+
+ {tab === "code" ? ( +

{t("claude.subtitle")}

+ ) : ( +

+ {desktopPort != null + ? t("claudeDesktop.subtitle", { port: desktopPort }) + : t("claudeDesktop.loading")} +

+ )} +
+
); diff --git a/gui/src/pages/ClaudeCode.tsx b/gui/src/pages/ClaudeCode.tsx index ec49e2a375..0e0cf55244 100644 --- a/gui/src/pages/ClaudeCode.tsx +++ b/gui/src/pages/ClaudeCode.tsx @@ -1,10 +1,11 @@ -import { useCallback, useMemo, useState, type ReactNode } from "react"; +import { useCallback, useMemo, useRef, useState, type ReactNode } from "react"; +import { setClientResourceData } from "../client-resource"; import { Notice } from "../ui"; import { useI18n, useT, LOCALES } from "../i18n/shared"; import { readJsonOrThrow } from "../fetch-json"; import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; import { useDataSurface } from "../data-surface"; -import { DataSurfaceSkeleton, DataSurfaceStatus } from "../components/data-surface"; +import { DataSurfaceSkeleton } from "../components/data-surface"; import { backgroundHelperOptions } from "./claude-code-helper-options"; import { reconcileAutoConnectState } from "./claude-autoconnect"; import { buildManualEnv } from "./claude-manual-env"; @@ -31,7 +32,14 @@ export default function ClaudeCode({ apiBase, active = true }: { apiBase: string const { locale } = useI18n(); const localeTag = LOCALES.find(l => l.code === locale)?.htmlLang ?? "en"; const cacheKey = `ocx.claude-code.v1:${apiBase}`; + const resourceKey = `claude-code:${apiBase}`; const cached = useMemo(() => seedClaudeCode(cacheKey), [cacheKey]); + // Seed before subscribe so Code↔Desktop hops do not flash "Loading…" under the title. + const seededKeyRef = useRef(null); + if (seededKeyRef.current !== resourceKey) { + if (cached) setClientResourceData(resourceKey, cached); + seededKeyRef.current = resourceKey; + } const [draftState, setState] = useState(() => cached?.state ?? null); const [draftRows, setRows] = useState(() => cached?.rows ?? []); const [hasDraftRows, setHasDraftRows] = useState(Boolean(cached)); @@ -72,7 +80,7 @@ export default function ClaudeCode({ apiBase, active = true }: { apiBase: string }, [apiBase, cacheKey, t]); const codeResource = useDataSurface( - `claude-code:${apiBase}`, + resourceKey, [apiBase], fetchCode, { isEmpty: () => false, enabled: active }, @@ -152,7 +160,7 @@ export default function ClaudeCode({ apiBase, active = true }: { apiBase: string } if (!state) return null; - const sections: Array<{ id: string; label: string; body: ReactNode }> = [ + const sections: Array<{ id: string; label: string; meta?: string; body: ReactNode }> = [ { id: "settings", label: t("claude.workspace.settings"), @@ -185,6 +193,7 @@ export default function ClaudeCode({ apiBase, active = true }: { apiBase: string { id: "modelMap", label: t("claude.modelMap"), + meta: String(rows.length), body: { setHasDraftRows(true); setRows(nextRows); @@ -193,6 +202,7 @@ export default function ClaudeCode({ apiBase, active = true }: { apiBase: string { id: "aliases", label: t("claude.aliases"), + meta: String(state.aliases.length), body: , }, ]; @@ -203,22 +213,9 @@ export default function ClaudeCode({ apiBase, active = true }: { apiBase: string return (
-
-

{t("claude.pageTitle")}

- {sectionEditable && ( -
- -
- )} -
-

{t("claude.subtitle")}

+ {/* Page title/subtitle live on Claude.tsx above the Code/Desktop strip. */} {status && {status}} {loadState.showError && {t("claude.loadFail")}} - {loadState.refreshing && ( - {t("claude.loading")} - )}
+
+

+ {selected.label} + {selected.meta != null ? {selected.meta} : null} +

+
+ +
+
{selected.body}
diff --git a/gui/src/pages/ClaudeDesktop.tsx b/gui/src/pages/ClaudeDesktop.tsx index 9fc1801fc9..7ec7dcfa5f 100644 --- a/gui/src/pages/ClaudeDesktop.tsx +++ b/gui/src/pages/ClaudeDesktop.tsx @@ -1,13 +1,14 @@ -import { useCallback, useMemo, useRef, useState, type ChangeEvent, type DragEvent } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent } from "react"; import { LANE_PAGE, defaultCollapsedFamilies, laneView, rowStartsOpen } from "./claude-desktop-lane"; import { makeCollapseStore, toggleInSet } from "./collapse-store"; import { IconChevron } from "../icons"; import { EmptyState, Notice } from "../ui"; import { useT, type TFn, type TKey } from "../i18n/shared"; import { readJsonIfOk, readJsonOrThrow } from "../fetch-json"; +import { setClientResourceData } from "../client-resource"; import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; import { useDataSurface } from "../data-surface"; -import { DataSurfaceSkeleton, DataSurfaceStatus } from "../components/data-surface"; +import { DataSurfaceSkeleton } from "../components/data-surface"; const FAMILIES = ["opus", "fable", "sonnet", "haiku"] as const; type Family = typeof FAMILIES[number]; @@ -143,10 +144,27 @@ function seedDesktop(cacheKey: string) { }; } -export default function ClaudeDesktop({ apiBase, active = true }: { apiBase: string; active?: boolean }) { +export default function ClaudeDesktop({ + apiBase, + active = true, + onPortChange, +}: { + apiBase: string; + active?: boolean; + /** Keeps the Claude page intro subtitle in sync once /api/claude-desktop resolves a port. */ + onPortChange?: (port: number) => void; +}) { const t = useT(); const cacheKey = `ocx.claude-desktop.v1:${apiBase}`; + const resourceKey = `claude-desktop:${apiBase}`; const cached = useMemo(() => seedDesktop(cacheKey), [cacheKey]); + // Seed before subscribe so Code↔Desktop hops do not flash "Loading…" under the title. + const seededKeyRef = useRef(null); + if (seededKeyRef.current !== resourceKey) { + const held = readDesktopCache(cacheKey); + if (held) setClientResourceData(resourceKey, held); + seededKeyRef.current = resourceKey; + } const [draftProfile, setProfile] = useState(() => cached.profile); const [savedDraftProfile, setSavedProfile] = useState(() => cached.savedProfile); const [draftDestinations, setDestinations] = useState>(() => cached.destinations); @@ -197,7 +215,7 @@ export default function ClaudeDesktop({ apiBase, active = true }: { apiBase: str }, [apiBase, cacheKey, t]); const desktopResource = useDataSurface( - `claude-desktop:${apiBase}`, + resourceKey, [apiBase], fetchDesktop, { isEmpty: () => false, enabled: active }, @@ -212,6 +230,10 @@ export default function ClaudeDesktop({ apiBase, active = true }: { apiBase: str : {} as Record; const destinations = Object.keys(draftDestinations).length > 0 ? draftDestinations : resourceDestinations; + useEffect(() => { + if (typeof data?.port === "number") onPortChange?.(data.port); + }, [data?.port, onPortChange]); + const dirty = useMemo( () => profile !== null && savedProfile !== null && JSON.stringify(profile) !== JSON.stringify(savedProfile), [profile, savedProfile], @@ -236,18 +258,29 @@ export default function ClaudeDesktop({ apiBase, active = true }: { apiBase: str // The status poll is a separate resource: visibility pauses it without unmounting the // profile editor, which keeps its drafts intact across Code/Desktop tab switches. + const statusCacheKey = `ocx.claude-desktop.status.v1:${apiBase}`; + const statusResourceKey = `claude-desktop-status:${apiBase}`; + const cachedStatus = readSessionListCache(statusCacheKey); + // Seed before subscribe so the status bar does not mount ~one RTT after the profile and shove + // the assignment lanes down (CLS on every Desktop revisit while status is still "not applied"). + const seededStatusRef = useRef(null); + if (seededStatusRef.current !== statusResourceKey) { + if (cachedStatus) setClientResourceData(statusResourceKey, cachedStatus); + seededStatusRef.current = statusResourceKey; + } const statusResource = useDataSurface( - `claude-desktop-status:${apiBase}`, + statusResourceKey, [apiBase], async (signal) => { const response = await fetch(`${apiBase}/api/claude-desktop/status`, { signal }); const next = await readJsonIfOk(response); if (!next) throw new Error("Claude Desktop status unavailable"); + writeSessionListCache(statusCacheKey, next); return next; }, { isEmpty: () => false, pollMs: 5000, enabled: active }, ); - const status = statusResource.state.data ?? null; + const status = statusResource.state.data ?? cachedStatus ?? null; const moveModel = (route: string, family: Family) => { if (!profile || profile.assignments[route]?.family === family) return; @@ -300,6 +333,8 @@ export default function ClaudeDesktop({ apiBase, active = true }: { apiBase: str setMessage({ tone: "ok", text: t("claudeDesktop.saved") }); setAnnouncement(t("claudeDesktop.savedAnnounce")); } + // Apply/save change the bar tone; do not wait for the 5s poll or the strip flips late. + void statusResource.refresh(); } catch (error) { const text = error instanceof Error ? error.message : t("claudeDesktop.updateFailed"); setMessage({ tone: "err", text }); @@ -362,11 +397,8 @@ export default function ClaudeDesktop({ apiBase, active = true }: { apiBase: str return ( <> -
-
-

{t("claudeDesktop.title")}

-

{t("claudeDesktop.subtitle", { port: data.port })}

-
+ {/* Title/subtitle live on Claude.tsx above the Code/Desktop strip. */} +
void importProfile(event)} /> @@ -374,23 +406,51 @@ export default function ClaudeDesktop({ apiBase, active = true }: { apiBase: str
- {status && ( -
- - {/* Desktop serving another profile outranks content drift: stale config that is - read still works, a config that is never read does not. */} - {status.activeProfile === false ? t("claudeDesktop.status.notActiveProfile") : status.stale ? t("claudeDesktop.status.stale") : status.applied ? t("claudeDesktop.status.applied") : t("claudeDesktop.status.notApplied")} - {status.health.lastRequestAt && {t("claudeDesktop.health.lastRequest")}: {new Date(status.health.lastRequestAt).toLocaleTimeString()}} - {status.health.requestCount > 0 && {t("claudeDesktop.health.stats", { count: status.health.requestCount, errors: status.health.errorCount })}} -
- )} + {/* Always mount the bar (pending strut when status is still cold) so a late /status + response cannot insert a full row under the title and shove the lanes down. */} +
+ + {/* Desktop serving another profile outranks content drift: stale config that is + read still works, a config that is never read does not. */} + + {!status + ? t("claudeDesktop.loading") + : status.activeProfile === false + ? t("claudeDesktop.status.notActiveProfile") + : status.stale + ? t("claudeDesktop.status.stale") + : status.applied + ? t("claudeDesktop.status.applied") + : t("claudeDesktop.status.notApplied")} + + {status?.health.lastRequestAt && ( + + {t("claudeDesktop.health.lastRequest")}: {new Date(status.health.lastRequestAt).toLocaleTimeString()} + + )} + {status && status.health.requestCount > 0 && ( + + {t("claudeDesktop.health.stats", { count: status.health.requestCount, errors: status.health.errorCount })} + + )} +
{announcement}
{message && {message.text}} {loadState.showError && {t("claudeDesktop.loadFail")}} - {loadState.refreshing && ( - {t("claudeDesktop.loading")} - )}
{dirty ? t("claudeDesktop.unsaved") : t("claudeDesktop.upToDate")} diff --git a/gui/src/pages/Debug.tsx b/gui/src/pages/Debug.tsx index b72db73764..323eef1f54 100644 --- a/gui/src/pages/Debug.tsx +++ b/gui/src/pages/Debug.tsx @@ -4,7 +4,7 @@ import { setClientResourceData, useKeyedClientResource } from "../client-resourc import { useI18n } from "../i18n/shared"; import { Notice } from "../ui"; import { useDataSurface } from "../data-surface"; -import { DataSurfaceSkeleton, DataSurfaceStatus } from "../components/data-surface"; +import { DataSurfaceSkeleton } from "../components/data-surface"; import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; import { DebugClaudeInboundPanel } from "./debug-claude-inbound-panel"; import { DebugLogViewer } from "./debug-log-viewer"; @@ -24,6 +24,12 @@ export default function Debug({ apiBase, embedded, active = true }: { apiBase: s const { t } = useI18n(); const settingsCacheKey = `ocx.debug.settings.v1:${apiBase}`; const cachedSettings = readSessionListCache(settingsCacheKey); + const debugResourceKey = debugSettingsKey(apiBase); + const seededKeyRef = useRef(null); + if (seededKeyRef.current !== debugResourceKey) { + if (cachedSettings) setClientResourceData(debugResourceKey, cachedSettings); + seededKeyRef.current = debugResourceKey; + } const [debugBusy, setDebugBusy] = useState(false); const [stream, setStream] = useState("provider"); const [entries, setEntries] = useState([]); @@ -39,7 +45,7 @@ export default function Debug({ apiBase, embedded, active = true }: { apiBase: s const streamIdentityRef = useRef(null); const debugPoll = useDataSurface( - debugSettingsKey(apiBase), + debugResourceKey, [apiBase], async (signal) => { const res = await fetch(`${apiBase}/api/debug`, { signal }); @@ -236,11 +242,6 @@ export default function Debug({ apiBase, embedded, active = true }: { apiBase: s /> )} - {/* Revalidation over a panel that is already rendered: keep the controls usable and say - that a read is in flight, instead of silently swapping values under the user. */} - {debug && debugState.refreshing && ( - {t("debug.loading")} - )} {debug && debugState.showError && {t("debug.loadFailed")}} {debug?.claude && } diff --git a/gui/src/pages/Grok.tsx b/gui/src/pages/Grok.tsx index 01419fac54..e345eeb9c6 100644 --- a/gui/src/pages/Grok.tsx +++ b/gui/src/pages/Grok.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { EmptyState, Notice, Switch } from "../ui"; import { IconChevron } from "../icons"; import { useT, type TKey } from "../i18n/shared"; @@ -6,7 +6,7 @@ import { readJsonOrThrow } from "../fetch-json"; import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; import { useDataSurface } from "../data-surface"; import { setClientResourceData } from "../client-resource"; -import { DataSurfaceSkeleton, DataSurfaceStatus } from "../components/data-surface"; +import { DataSurfaceSkeleton } from "../components/data-surface"; import { makeCollapseStore, toggleInSet } from "./collapse-store"; import { grokGroupView, type GrokCandidate } from "./grok-groups"; @@ -84,6 +84,12 @@ export default function Grok({ apiBase }: { apiBase: string }) { // Request ownership lives in the shared resource layer, so a route change during the first // load cannot drop the request the way the old deferred timer did. const resourceKey = `grok-status:${apiBase}`; + // Seed before subscribe so a revisit does not flash a loading status under the page title. + const seededKeyRef = useRef(null); + if (seededKeyRef.current !== resourceKey) { + if (cached) setClientResourceData(resourceKey, cached); + seededKeyRef.current = resourceKey; + } const resource = useDataSurface( resourceKey, [apiBase], @@ -210,7 +216,7 @@ export default function Grok({ apiBase }: { apiBase: string }) { } return ( -
+

{t("grok.title")}

{t("grok.subtitle")}

@@ -220,9 +226,6 @@ export default function Grok({ apiBase }: { apiBase: string }) { {/* A refresh that fails while cached data is on screen must say so instead of leaving the page looking settled; the notice then owns the live region for this transition. */} {state.showError && {t("grok.loadFail")}} - {state.refreshing && ( - {t("grok.loading")} - )} {status && status.candidates.length > 0 && (
diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index 56c7d80ba6..18d03115d4 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -6,9 +6,10 @@ import { hashLogConversationQuery, matchesLogConversationId } from "../log-conve import { statusCodeInfo } from "../status-codes"; import { IconX } from "../icons"; import { modelLabel } from "../model-display"; +import { setClientResourceData } from "../client-resource"; import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; import { useDataSurface } from "../data-surface"; -import { DataSurfaceSkeleton, DataSurfaceStatus } from "../components/data-surface"; +import { DataSurfaceSkeleton } from "../components/data-surface"; import { EmptyState, Notice } from "../ui"; import Debug from "./Debug"; @@ -285,22 +286,26 @@ function statusColor(status: number): string { return "var(--amber)"; } -function formatLogTimestamp(ts: number, localeTag?: string, timeZone?: string): string { +/** Date and time as separate locale strings (no joining comma) for stacked table cells. */ +function formatLogDateParts(ts: number, localeTag?: string, timeZone?: string): { date: string; time: string } { + const zone = timeZone ? { timeZone } : undefined; try { - return new Date(ts).toLocaleTimeString(localeTag, timeZone ? { timeZone } : undefined); + return { + date: new Date(ts).toLocaleDateString(localeTag, zone), + time: new Date(ts).toLocaleTimeString(localeTag, zone), + }; } catch { - // An IANA zone the browser's ICU build does not know throws RangeError, which would take - // the whole row render down. A timestamp in the wrong zone beats no log list at all. - return new Date(ts).toLocaleTimeString(localeTag); + // An IANA zone the browser's ICU build does not know throws RangeError. + return { + date: new Date(ts).toLocaleDateString(localeTag), + time: new Date(ts).toLocaleTimeString(localeTag), + }; } } function formatLogDateTime(ts: number, localeTag?: string, timeZone?: string): string { - try { - return new Date(ts).toLocaleString(localeTag, timeZone ? { timeZone } : undefined); - } catch { - return new Date(ts).toLocaleString(localeTag); - } + const { date, time } = formatLogDateParts(ts, localeTag, timeZone); + return `${date} ${time}`; } function modelTitle(log: LogEntry): string { @@ -346,7 +351,14 @@ function summarizeFilteredLogs(entries: LogEntry[]): { export default function Logs({ apiBase }: { apiBase: string }) { const { t, locale } = useI18n(); - const cachedLogs = readSessionListCache(logsCacheKey(apiBase)); + const resourceKey = logsCacheKey(apiBase); + const cachedLogs = readSessionListCache(resourceKey); + // Seed before subscribe so a revisit with session cache does not flash "Loading…" over rows. + const seededKeyRef = useRef(null); + if (seededKeyRef.current !== resourceKey) { + if (cachedLogs) setClientResourceData(resourceKey, cachedLogs); + seededKeyRef.current = resourceKey; + } const [autoRefresh, setAutoRefresh] = useState(true); const [detail, setDetail] = useState(null); const [surfaceFilter, setSurfaceFilter] = useState("all"); @@ -405,15 +417,15 @@ export default function Logs({ apiBase }: { apiBase: string }) { if (!res.ok) throw new Error(`${res.status} ${res.statusText}`.trim()); const body = await res.json() as LogEntry[] | { logs?: LogEntry[] }; const next = Array.isArray(body) ? body : (body.logs ?? []); - writeSessionListCache(logsCacheKey(apiBase), next); + writeSessionListCache(resourceKey, next); return next; - }, [apiBase]); + }, [apiBase, resourceKey]); // The resource layer owns the request and the 2s poll. It keeps held rows through a quiet // poll on its own, which is what the old silent/non-silent split was hand-rolling — and an // empty successful response is now a real empty result rather than a cold load. const logsResource = useDataSurface( - logsCacheKey(apiBase), + resourceKey, [apiBase], loadLogs, { @@ -481,7 +493,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { : 0; return ( - <> +

{t("nav.logs")}

{tab === "logs" && ( @@ -610,12 +622,6 @@ export default function Logs({ apiBase }: { apiBase: string }) { )} - {/* Progress is reported only for a forced read: a two-second heartbeat that announced - itself would talk over the table continuously. */} - {logsResource.loading && logs.length > 0 && ( - {t("common.loading")} - )} - {/* A run of failed polls is no longer transient: say the rows below are stale rather than letting them read as current. Cleared by the first successful poll. */} {pollFailing && logs.length > 0 && ( @@ -648,7 +654,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { {t("logs.col.provider")} {t("logs.col.status")} {t("logs.col.request")} - {t("logs.col.duration")} + {t("logs.col.duration")} @@ -660,13 +666,19 @@ export default function Logs({ apiBase }: { apiBase: string }) { {virtualRows.map(virtualRow => { const log = filteredLogs[filteredLogs.length - 1 - virtualRow.index]; const reasoningWire = reasoningWireLabel(log); + const when = formatLogDateParts(log.timestamp, localeTag, serverTimeZone); return ( - {formatLogTimestamp(log.timestamp, localeTag, serverTimeZone)} + + + {when.date} + {when.time} + + {(() => { const tokenTotal = displayContextTokenTotal(log); @@ -732,7 +744,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { {log.requestId ?? "-"} - {log.durationMs}ms + {log.durationMs}ms ); })} @@ -763,7 +775,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { /> )}
- +
); } diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index be32b99688..90452002af 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Switch, Notice, EmptyState, Select, Tooltip } from "../ui"; import { IconChevron, IconBoxes, IconInfo, IconShuffle } from "../icons"; import { useT } from "../i18n/shared"; @@ -9,7 +9,7 @@ import { readJsonIfOk, readJsonOrThrow } from "../fetch-json"; import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; import { setClientResourceData } from "../client-resource"; import { useDataSurface } from "../data-surface"; -import { DataSurfaceSkeleton, DataSurfaceStatus } from "../components/data-surface"; +import { DataSurfaceSkeleton } from "../components/data-surface"; import { buildProviderModelGroups, type ConfiguredProviderSummary, @@ -60,6 +60,12 @@ export default function Models({ apiBase }: { apiBase: string }) { const t: TFn = useT(); const cacheKey = `ocx.models.catalog.v1:${apiBase}`; const cached = useMemo(() => readSessionListCache(cacheKey), [cacheKey]); + // Seed before subscribe so a revisit does not flash "Loading…" under the page title. + const seededKeyRef = useRef(null); + if (seededKeyRef.current !== cacheKey) { + if (cached) setClientResourceData(cacheKey, cached); + seededKeyRef.current = cacheKey; + } const [models, setModels] = useState(() => cached?.models ?? []); const [providers, setProviders] = useState(() => cached?.providers ?? []); const [disabled, setDisabled] = useState>(() => new Set(cached?.disabled ?? [])); @@ -102,11 +108,21 @@ export default function Models({ apiBase }: { apiBase: string }) { const hoverTimerRef = useRef | null>(null); const [shadowCall, setShadowCall] = useState(null); const [shadowCallSaving, setShadowCallSaving] = useState(false); - // Combo summary section. null = loading or failed (section hidden on failure — - // an API error must never masquerade as "no combos configured"). - const [combos, setCombos] = useState(null); + // Combo summary section. null = cold load with no seed (pending strut). Failed reads stay + // null + combosError so an API error never masquerades as "no combos configured". + const combosCacheKey = `ocx.models.combos.v1:${apiBase}`; + const [combos, setCombos] = useState(() => { + const own = readSessionListCache(combosCacheKey); + if (own) return own; + // Reuse the Combos workspace session snapshot when Models opens first in the session. + const workspace = readSessionListCache<{ combos?: ComboItem[] }>(`ocx.combos.workspace.v1:${apiBase}`); + return Array.isArray(workspace?.combos) ? workspace.combos : null; + }); const [combosError, setCombosError] = useState(false); const [combosOpen, setCombosOpen] = useState(readCombosOpen); + // True once we have painted a combos card (seed or fetch) so a later fetch failure cannot + // unmount it and yank the catalog down. + const combosHeldRef = useRef(combos !== null); // App owns the in-session view mode; fallback to persisted mode for isolated renders/tests. const [selectedProvider, setSelectedProvider] = useState(null); @@ -123,18 +139,20 @@ export default function Models({ apiBase }: { apiBase: string }) { const r = await fetch(`${apiBase}/api/combos`); const j = await readJsonOrThrow(r); if (!cancelled) { - setCombos(parseComboList(j)); + const next = parseComboList(j); + writeSessionListCache(combosCacheKey, next); + combosHeldRef.current = true; + setCombos(next); setCombosError(false); } } catch { - if (!cancelled) { - setCombos(null); + if (!cancelled && !combosHeldRef.current) { setCombosError(true); } } })(); return () => { cancelled = true; }; - }, [apiBase]); + }, [apiBase, combosCacheKey]); useEffect(() => () => { if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current); @@ -236,14 +254,6 @@ export default function Models({ apiBase }: { apiBase: string }) { const catalogState = catalogResource.state; const catalogRefresh = catalogResource.refresh; - useLayoutEffect(() => { - if (!cached) return; - // Seed before the subscription's first visible paint, then immediately revalidate. This keeps - // a failed first refresh in the stale-data branch instead of replacing the catalog as cold. - setClientResourceData(cacheKey, cached); - catalogRefresh(); - }, [cacheKey, cached, catalogRefresh]); - const load = useCallback(async (force = false): Promise => { if (loadPendingRef.current && !force) return false; loadPendingRef.current = true; @@ -1037,50 +1047,65 @@ export default function Models({ apiBase }: { apiBase: string }) { const combosBlock = ( <> - {combos !== null && !combosError && combos.length === 0 && ( -
-
-
-
- {t("models.combosSetup")} -
-
- )} - {combos !== null && !combosError && combos.length > 0 && ( -
-
- - {t("models.combosSetup")} -
- {combosOpen && ( -
- {combos.map(c => ( -
- {c.model} - {c.strategy} · {c.targets.length} -
- ))} - - + {t("models.combosAdd")} - -
- )} -
- )} + {/* Pending strut matches the empty-card chrome so a late /api/combos cannot insert a row. */} + {combos === null && !combosError && ( +
+
+
+
+ +
+
+ )} + {combos !== null && !combosError && combos.length === 0 && ( +
+
+
+
+ {t("models.combosSetup")} +
+
+ )} + {combos !== null && !combosError && combos.length > 0 && ( +
+
+ + {t("models.combosSetup")} +
+ {combosOpen && ( +
+ {combos.map(c => ( +
+ {c.model} + {c.strategy} · {c.targets.length} +
+ ))} + + + {t("models.combosAdd")} + +
+ )} +
+ )} ); @@ -1300,9 +1325,6 @@ export default function Models({ apiBase }: { apiBase: string }) { {status && {status}} {/* Keep the last-good catalog interactive but make a failed revalidation explicit. */} {catalogState.showError && {t("models.loadFail")}} - {catalogState.refreshing && ( - {t("models.loading")} - )}