diff --git a/gui/src/client-resource.ts b/gui/src/client-resource.ts index 4e70fde2e..03b6d1d8f 100644 --- a/gui/src/client-resource.ts +++ b/gui/src/client-resource.ts @@ -40,6 +40,12 @@ type Store = { /** Store-level visibilitychange handler, installed only while this store polls. */ visibilityListener: (() => void) | null; generation: number; + /** + * Set when `setClientResourceData` publishes while nobody is subscribed (session-cache + * seed). The first subscribe must quiet-revalidate; otherwise mount never fetches and + * seeded rows stay indefinitely stale with `lastAttemptOk: true`. + */ + seedNeedsRevalidate: boolean; }; /** @@ -80,6 +86,7 @@ function getStore(key: string): Store { inflightOwner: null, visibilityListener: null, generation: 0, + seedNeedsRevalidate: false, }; stores.set(key, store); } @@ -223,6 +230,8 @@ async function runFetch( try { const data = await fetcher(controller.signal); if (gen !== store.generation || controller.signal.aborted) return; + // Cleared on settle (not at subscribe) so StrictMode's aborted first mount still revalidates. + store.seedNeedsRevalidate = false; store.snapshot = { data, error: undefined, @@ -233,6 +242,7 @@ async function runFetch( }; } catch (error) { if (gen !== store.generation || controller.signal.aborted) return; + store.seedNeedsRevalidate = false; store.snapshot = { ...store.snapshot, // Normalize so a loader that rejects with `undefined` still reads as a failure. @@ -298,9 +308,12 @@ function subscribeResource( store.fetcherByListener.set(onStoreChange, fetcher); store.subscriberCount++; - // Cold start only — keep cached data across transient 0→1 resubscribe gaps. - if (store.subscriberCount === 1 && store.snapshot.data === undefined) { - void runFetch(store, fetcher, { replaceInflight: true, owner: onStoreChange }); + // Cold start, or a pre-subscribe seed that still needs a network check. Keep + // cached data across transient 0→1 resubscribe gaps when neither applies. + if (store.subscriberCount === 1) { + if (store.snapshot.data === undefined || store.seedNeedsRevalidate) { + void runFetch(store, fetcher, { replaceInflight: true, owner: onStoreChange }); + } } recomputePoll(store); @@ -328,7 +341,7 @@ function subscribeResource( } /** Module-level fetch cache with useSyncExternalStore subscriptions (no fetch in useEffect). */ -export interface ClientResourceOptions { +export interface ClientResourceOptions { pollMs?: number; enabled?: boolean; /** @@ -337,18 +350,34 @@ export interface ClientResourceOptions { * happening off-screen — a restarting server, for instance. */ pauseWhenHidden?: boolean; + /** + * Optional seed applied once before the first subscribe (session-cache revisit). + * Lives in the store — not a render-time ref — so React Compiler / react-hooks/refs + * stay quiet while the mount fetch still quiet-revalidates via `seedNeedsRevalidate`. + */ + initialData?: T; +} + +/** Seed an empty, unsubscribed store. No-ops when data already exists or someone is listening. */ +function seedClientResourceIfEmpty(key: string, data: T): void { + const store = getStore(key); + if (store.subscriberCount !== 0 || store.snapshot.data !== undefined) return; + setClientResourceData(key, data); } export function useClientResource( key: string, fetcher: (signal: AbortSignal) => Promise, - options?: ClientResourceOptions, + options?: ClientResourceOptions, ): ResourceSnapshot & { refresh: (opts?: { forceLoading?: boolean }) => void } { const enabled = options?.enabled !== false; const pollMs = options?.pollMs; // Default true: a background tab has nobody reading the paint. Opt out for polls that // must keep running while hidden, such as waiting for a restarted server to answer. const pauseWhenHidden = options?.pauseWhenHidden !== false; + if (enabled && options?.initialData !== undefined) { + seedClientResourceIfEmpty(key, options.initialData); + } const fetcherRef = useRef(fetcher); // Sync latest fetcher every commit. No dep array on purpose: inline fetchers are // reallocated every render; listing them would re-subscribe forever. @@ -411,7 +440,7 @@ export function useKeyedClientResource( key: string, deps: readonly unknown[], load: (signal: AbortSignal) => Promise, - options?: ClientResourceOptions, + options?: ClientResourceOptions, ): ResourceSnapshot & { refresh: (opts?: { forceLoading?: boolean }) => void } { const resource = useClientResource(key, load, options); const prevDepsRef = useRef(null); @@ -447,6 +476,9 @@ export function setClientResourceData(key: string, data: T) { hasSucceeded: true, lastAttemptOk: true, }; + // Only pre-subscribe seeds need a follow-up fetch. Live publishers (mutation + // results) already hold the fresh value and must not schedule a redundant GET. + store.seedNeedsRevalidate = store.subscriberCount === 0; emit(store); } diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index 6ab3e2f6f..5c6b54c30 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; + } + }, []); + + /** Timeout path: drop the click lock and re-read the visible section. */ + const expireScrollLock = useCallback(() => { + clearScrollLock(); + // If the user interrupted smooth scroll, the target may never cross the observer threshold — + // without a resync `active` would stay on the clicked tab while another section is on screen. + // Prefer the heading nearest the sticky-strip reading line (not only those with top <= 120), + // so a destination that stopped mid-viewport still wins over an off-screen prior heading. + let bestId: string | null = null; + let bestDistance = Number.POSITIVE_INFINITY; + const readingLine = 72; + for (const item of items) { + const node = document.getElementById(sectionAnchorId(scope, item.id)); + if (!node) continue; + const distance = Math.abs(node.getBoundingClientRect().top - readingLine); + if (distance < bestDistance) { + bestDistance = distance; + bestId = item.id; + } + } + if (bestId) setActive(bestId); + }, [clearScrollLock, items, scope]); + + 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 +75,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 +96,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(expireScrollLock, 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" }); @@ -64,7 +111,6 @@ export function SectionTabs({ return (
) : (
-
-
-
{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/data-surface.ts b/gui/src/data-surface.ts index 5f6f37364..917460e68 100644 --- a/gui/src/data-surface.ts +++ b/gui/src/data-surface.ts @@ -43,6 +43,8 @@ export type DataSurfaceOptions = { enabled?: boolean; /** Forwarded to the resource layer; see ClientResourceOptions.pauseWhenHidden. */ pauseWhenHidden?: boolean; + /** Forwarded to the resource layer; see ClientResourceOptions.initialData. */ + initialData?: T; }; /** diff --git a/gui/src/pages/ApiKeys.tsx b/gui/src/pages/ApiKeys.tsx index 9bf316697..f8ab9c63d 100644 --- a/gui/src/pages/ApiKeys.tsx +++ b/gui/src/pages/ApiKeys.tsx @@ -11,7 +11,7 @@ import { 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, @@ -92,6 +92,8 @@ 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. @@ -164,16 +166,16 @@ 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 }, + { isEmpty: data => data.keys.length === 0, initialData: cachedKeys ?? undefined }, ); const modelsResource = useDataSurface( - `api-models:${apiBase}`, + modelsResourceKey, [apiBase], fetchModels, - { isEmpty: models => models.length === 0 }, + { isEmpty: models => models.length === 0, initialData: cachedModels ?? undefined }, ); const keysState = keysResource.state; const modelsState = modelsResource.state; @@ -374,7 +376,10 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) { const subtitleParts = t("api.subtitle").split("{authHeader}"); return ( -
+

{t("api.title")}

@@ -399,20 +404,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")} - - )} (`ocx.claude-desktop.v1:${apiBase}`); + return typeof cached?.data?.port === "number" ? cached.data.port : null; +} + export default function Claude({ apiBase }: { apiBase: string }) { const [tab, setTab] = useState("code"); const t = useT(); const codeTabRef = useRef(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. Live updates from + // Desktop win while they match the current apiBase; a base change falls back to cache. + const seededDesktopPort = readCachedDesktopPort(apiBase); + const [liveDesktopPort, setLiveDesktopPort] = useState<{ base: string; port: number | null } | null>(null); + const desktopPort = liveDesktopPort?.base === apiBase ? liveDesktopPort.port : seededDesktopPort; + const desktopSettled = liveDesktopPort?.base === apiBase; + // Skip no-op writes: an unstable callback + always-new object would loop with Desktop's effect. + const setDesktopPort = useCallback((port: number | null) => { + setLiveDesktopPort((prev) => { + if (prev?.base === apiBase && prev.port === port) return prev; + return { base: apiBase, port }; + }); + }, [apiBase]); 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 +55,24 @@ 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 }) + : desktopSettled + ? t("claudeDesktop.loadFail") + : t("claudeDesktop.loading")} +

+ )} +
+
); diff --git a/gui/src/pages/ClaudeCode.tsx b/gui/src/pages/ClaudeCode.tsx index ec49e2a37..8a6e2ccb2 100644 --- a/gui/src/pages/ClaudeCode.tsx +++ b/gui/src/pages/ClaudeCode.tsx @@ -4,7 +4,7 @@ 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"; @@ -22,16 +22,13 @@ export { AutoConnectSetting, SmallFastModelSetting } from "./claude-code-setting type CachedClaudeCode = { state: ClaudeCodeState; rows: MapRow[] }; -function seedClaudeCode(cacheKey: string): CachedClaudeCode | null { - return readSessionListCache(cacheKey); -} - export default function ClaudeCode({ apiBase, active = true }: { apiBase: string; active?: boolean }) { const t = useT(); const { locale } = useI18n(); const localeTag = LOCALES.find(l => l.code === locale)?.htmlLang ?? "en"; const cacheKey = `ocx.claude-code.v1:${apiBase}`; - const cached = useMemo(() => seedClaudeCode(cacheKey), [cacheKey]); + const resourceKey = `claude-code:${apiBase}`; + const cached = useMemo(() => readSessionListCache(cacheKey), [cacheKey]); const [draftState, setState] = useState(() => cached?.state ?? null); const [draftRows, setRows] = useState(() => cached?.rows ?? []); const [hasDraftRows, setHasDraftRows] = useState(Boolean(cached)); @@ -72,10 +69,10 @@ 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 }, + { isEmpty: () => false, enabled: active, initialData: cached ?? undefined }, ); const loadState = codeResource.state; const data = loadState.data ?? cached; @@ -152,7 +149,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 +182,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 +191,7 @@ export default function ClaudeCode({ apiBase, active = true }: { apiBase: string { id: "aliases", label: t("claude.aliases"), + meta: String(state.aliases.length), body: , }, ]; @@ -203,22 +202,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 9fc1801fc..32b3a41f5 100644 --- a/gui/src/pages/ClaudeDesktop.tsx +++ b/gui/src/pages/ClaudeDesktop.tsx @@ -1,13 +1,13 @@ -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 { LOCALES, useI18n, type TFn, type TKey } from "../i18n/shared"; import { readJsonIfOk, 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"; const FAMILIES = ["opus", "fable", "sonnet", "haiku"] as const; type Family = typeof FAMILIES[number]; @@ -131,6 +131,7 @@ function readDesktopCache(cacheKey: string): CachedDesktop | null { function seedDesktop(cacheKey: string) { const cached = readDesktopCache(cacheKey); return { + held: cached, data: cached?.data ?? null, profile: cached?.profile ?? null, savedProfile: cached?.profile ? cloneProfile(cached.profile) : null, @@ -139,13 +140,23 @@ function seedDesktop(cacheKey: string) { cached.data.models.map(model => [model.route, cached.profile.assignments[model.route]?.family ?? "opus"]), ) : {} as Record, - hasCache: Boolean(cached?.data), }; } -export default function ClaudeDesktop({ apiBase, active = true }: { apiBase: string; active?: boolean }) { - const t = useT(); +export default function ClaudeDesktop({ + apiBase, + active = true, + onPortChange, +}: { + apiBase: string; + active?: boolean; + /** Keeps the Claude page intro subtitle in sync once /api/claude-desktop settles (port or failure). */ + onPortChange?: (port: number | null) => void; +}) { + const { t, locale } = useI18n(); + const localeTag = LOCALES.find(l => l.code === locale)?.htmlLang; const cacheKey = `ocx.claude-desktop.v1:${apiBase}`; + const resourceKey = `claude-desktop:${apiBase}`; const cached = useMemo(() => seedDesktop(cacheKey), [cacheKey]); const [draftProfile, setProfile] = useState(() => cached.profile); const [savedDraftProfile, setSavedProfile] = useState(() => cached.savedProfile); @@ -194,13 +205,13 @@ export default function ClaudeDesktop({ apiBase, active = true }: { apiBase: str } writeSessionListCache(cacheKey, next); return next; - }, [apiBase, cacheKey, t]); + }, [apiBase, cacheKey, t, setDestinations, setProfile, setSavedProfile]); const desktopResource = useDataSurface( - `claude-desktop:${apiBase}`, + resourceKey, [apiBase], fetchDesktop, - { isEmpty: () => false, enabled: active }, + { isEmpty: () => false, enabled: active, initialData: cached.held ?? undefined }, ); const loadState = desktopResource.state; const resourceData = loadState.data ?? (cached.data && cached.profile ? { data: cached.data, profile: cached.profile } : null); @@ -212,6 +223,16 @@ export default function ClaudeDesktop({ apiBase, active = true }: { apiBase: str : {} as Record; const destinations = Object.keys(draftDestinations).length > 0 ? draftDestinations : resourceDestinations; + useEffect(() => { + if (!onPortChange) return; + if (typeof data?.port === "number") { + onPortChange(data.port); + return; + } + // Cold failure with no port: stop the parent subtitle from claiming "Loading…" forever. + if (loadState.kind === "failed-cold") onPortChange(null); + }, [data?.port, loadState.kind, onPortChange]); + const dirty = useMemo( () => profile !== null && savedProfile !== null && JSON.stringify(profile) !== JSON.stringify(savedProfile), [profile, savedProfile], @@ -236,18 +257,24 @@ 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); 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 }, + { isEmpty: () => false, pollMs: 5000, enabled: active, initialData: cachedStatus ?? undefined }, ); - const status = statusResource.state.data ?? null; + const statusState = statusResource.state; + const status = statusState.data ?? cachedStatus ?? null; + const statusFailed = statusState.showError; const moveModel = (route: string, family: Family) => { if (!profile || profile.assignments[route]?.family === family) return; @@ -300,6 +327,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 +391,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 +400,57 @@ 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. */} + + {statusFailed && !status + ? t("claudeDesktop.loadFail") + : !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(localeTag)} + + )} + {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")} - )} + {statusFailed && status && {t("claudeDesktop.loadFail")}}
{dirty ? t("claudeDesktop.unsaved") : t("claudeDesktop.upToDate")} diff --git a/gui/src/pages/Debug.tsx b/gui/src/pages/Debug.tsx index b72db7376..58f0f0970 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,7 @@ 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 [debugBusy, setDebugBusy] = useState(false); const [stream, setStream] = useState("provider"); const [entries, setEntries] = useState([]); @@ -39,7 +40,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 }); @@ -50,7 +51,7 @@ export default function Debug({ apiBase, embedded, active = true }: { apiBase: s writeSessionListCache(settingsCacheKey, next); return next; }, - { pollMs: 2000, enabled: active, isEmpty: () => false }, + { pollMs: 2000, enabled: active, isEmpty: () => false, initialData: cachedSettings ?? undefined }, ); const debugState = debugPoll.state; const debug = debugPoll.data ?? cachedSettings ?? null; @@ -178,7 +179,7 @@ export default function Debug({ apiBase, embedded, active = true }: { apiBase: s const next = await res.json() as DebugSettings; if (generation !== mutationGenerationRef.current) return; writeSessionListCache(settingsCacheKey, next); - setClientResourceData(debugSettingsKey(apiBase), next); + setClientResourceData(debugResourceKey, next); } catch { /* ignore */ } }; const previous = mutationQueueRef.current ?? Promise.resolve(); @@ -236,11 +237,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 01419fac5..46eea7af8 100644 --- a/gui/src/pages/Grok.tsx +++ b/gui/src/pages/Grok.tsx @@ -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"; @@ -88,7 +88,7 @@ export default function Grok({ apiBase }: { apiBase: string }) { resourceKey, [apiBase], fetchStatus, - { isEmpty: () => false }, + { isEmpty: () => false, initialData: cached ?? undefined }, ); const { state } = resource; const load = resource.refresh; @@ -210,7 +210,7 @@ export default function Grok({ apiBase }: { apiBase: string }) { } return ( -
+

{t("grok.title")}

{t("grok.subtitle")}

@@ -220,9 +220,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 56c7d80ba..21a402eb6 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -8,7 +8,7 @@ import { IconX } from "../icons"; import { modelLabel } from "../model-display"; 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"; @@ -135,6 +135,25 @@ export interface LogEntry { displayMetrics?: LogDisplayMetrics; } +/** Session-cache entries are arbitrary JSON — reject shapes that would crash the table. */ +function validCachedLogs(cached: LogEntry[] | null): LogEntry[] | null { + if (!Array.isArray(cached)) return null; + for (const entry of cached) { + if ( + !entry + || typeof entry !== "object" + || typeof entry.timestamp !== "number" + || typeof entry.model !== "string" + || typeof entry.provider !== "string" + || typeof entry.status !== "number" + || typeof entry.durationMs !== "number" + ) { + return null; + } + } + return cached; +} + function isCursorUsageProvider(provider: string): boolean { return provider === "cursor" || provider.startsWith("cursor-"); } @@ -285,22 +304,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 +369,8 @@ 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 = validCachedLogs(readSessionListCache(resourceKey)); const [autoRefresh, setAutoRefresh] = useState(true); const [detail, setDetail] = useState(null); const [surfaceFilter, setSurfaceFilter] = useState("all"); @@ -405,21 +429,22 @@ 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, { isEmpty: rows => rows.length === 0, enabled: tab === "logs", pollMs: autoRefresh ? 2000 : undefined, + initialData: cachedLogs ?? undefined, }, ); const logsState = logsResource.state; @@ -443,7 +468,10 @@ export default function Logs({ apiBase }: { apiBase: string }) { } else if (settledFailure && failureStreak.error !== logsState.error) { setFailureStreak(previous => ({ error: logsState.error, count: previous.count + 1 })); } - const pollFailing = failureStreak.count >= STALE_POLL_FAILURE_LIMIT; + // Auto-refresh off: one settled failure is enough — there is no next poll to recover quietly. + const pollFailing = + failureStreak.count >= STALE_POLL_FAILURE_LIMIT + || (!autoRefresh && settledFailure); const detailInfo = detail ? statusCodeInfo(detail.status, locale) : null; const conversationQuery = conversationFilter.trim(); @@ -481,7 +509,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { : 0; return ( - <> +

{t("nav.logs")}

{tab === "logs" && ( @@ -610,14 +638,8 @@ 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. */} + {/* Stale rows after a sustained poll outage, or any settled failure while auto-refresh is + off (no next tick will recover). Cleared by the first successful fetch. */} {pollFailing && logs.length > 0 && ( {t("logs.loadError")}{" "} @@ -648,7 +670,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 +682,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 +760,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { {log.requestId ?? "-"} - {log.durationMs}ms + {log.durationMs}ms ); })} @@ -763,7 +791,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { /> )}
- +
); } diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index be32b9968..a9e6cdadb 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, @@ -56,6 +56,12 @@ type CachedModelsPage = { contextCapValue: number; }; +/** Session JSON is untrusted — only seed rows that survive parseComboList (targets always arrays). */ +function readCachedCombos(value: unknown): ComboItem[] | null { + if (!Array.isArray(value)) return null; + return parseComboList({ combos: value }); +} + export default function Models({ apiBase }: { apiBase: string }) { const t: TFn = useT(); const cacheKey = `ocx.models.catalog.v1:${apiBase}`; @@ -102,10 +108,33 @@ 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); - const [combosError, setCombosError] = useState(false); + // 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 seededCombos = useMemo(() => { + const own = readCachedCombos(readSessionListCache(combosCacheKey)); + if (own !== null) return own; + // Reuse the Combos workspace session snapshot when Models opens first in the session. + const workspace = readSessionListCache<{ combos?: unknown }>(`ocx.combos.workspace.v1:${apiBase}`); + return readCachedCombos(workspace?.combos); + }, [apiBase, combosCacheKey]); + const combosResource = useDataSurface( + `models-combos:${apiBase}`, + [apiBase], + async (signal) => { + const r = await fetch(`${apiBase}/api/combos`, { signal }); + const j = await readJsonOrThrow(r); + const next = parseComboList(j); + writeSessionListCache(combosCacheKey, next); + return next; + }, + { isEmpty: () => false, initialData: seededCombos ?? undefined }, + ); + const combosState = combosResource.state; + // Keep a previously painted card on a later failure so the catalog does not yank down. + const combos = combosState.data ?? seededCombos; + // Announce failures even when stale/seeded rows remain (layout kept; freshness not faked). + const combosError = combosState.showError; const [combosOpen, setCombosOpen] = useState(readCombosOpen); // App owns the in-session view mode; fallback to persisted mode for isolated renders/tests. @@ -116,26 +145,6 @@ export default function Models({ apiBase }: { apiBase: string }) { setCombosOpen(next); }; - useEffect(() => { - let cancelled = false; - void (async () => { - try { - const r = await fetch(`${apiBase}/api/combos`); - const j = await readJsonOrThrow(r); - if (!cancelled) { - setCombos(parseComboList(j)); - setCombosError(false); - } - } catch { - if (!cancelled) { - setCombos(null); - setCombosError(true); - } - } - })(); - return () => { cancelled = true; }; - }, [apiBase]); - useEffect(() => () => { if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current); }, []); @@ -231,18 +240,9 @@ export default function Models({ apiBase }: { apiBase: string }) { applyCatalog(next); return next; }, - { isEmpty: () => false, pollMs: 10_000 }, + { isEmpty: () => false, pollMs: 10_000, initialData: cached ?? undefined }, ); 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; @@ -1037,50 +1037,98 @@ 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 !== null && combos.length === 0 && ( +
+
+
+
+ {combosError ? ( + + ) : ( + {t("models.combosSetup")} + )} +
+
+ )} + {combos !== null && combos.length > 0 && ( +
+
+ + {combosError ? ( + + ) : ( + {t("models.combosSetup")} + )} +
+ {combosOpen && ( +
+ {combos.map(c => ( +
+ {c.model} + {c.strategy} · {c.targets.length} +
+ ))} + + + {t("models.combosAdd")} + +
+ )} +
+ )} ); @@ -1300,9 +1348,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")} - )}