Skip to content

Commit 9f82c64

Browse files
committed
fix(gui): revalidate seeded resources and clear React Doctor blockers
Pre-subscribe session seeds skipped the mount fetch, so cache-backed revisits never announced load failures. Move seeding into initialData, quiet-revalidate on first subscribe, and fix the related ApiKeys/Startup/Desktop review gaps.
1 parent ae7f5bd commit 9f82c64

17 files changed

Lines changed: 205 additions & 131 deletions

gui/src/client-resource.ts

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ type Store<T> = {
4040
/** Store-level visibilitychange handler, installed only while this store polls. */
4141
visibilityListener: (() => void) | null;
4242
generation: number;
43+
/**
44+
* Set when `setClientResourceData` publishes while nobody is subscribed (session-cache
45+
* seed). The first subscribe must quiet-revalidate; otherwise mount never fetches and
46+
* seeded rows stay indefinitely stale with `lastAttemptOk: true`.
47+
*/
48+
seedNeedsRevalidate: boolean;
4349
};
4450

4551
/**
@@ -80,6 +86,7 @@ function getStore<T>(key: string): Store<T> {
8086
inflightOwner: null,
8187
visibilityListener: null,
8288
generation: 0,
89+
seedNeedsRevalidate: false,
8390
};
8491
stores.set(key, store);
8592
}
@@ -298,9 +305,13 @@ function subscribeResource<T>(
298305
store.fetcherByListener.set(onStoreChange, fetcher);
299306
store.subscriberCount++;
300307

301-
// Cold start only — keep cached data across transient 0→1 resubscribe gaps.
302-
if (store.subscriberCount === 1 && store.snapshot.data === undefined) {
303-
void runFetch(store, fetcher, { replaceInflight: true, owner: onStoreChange });
308+
// Cold start, or a pre-subscribe seed that still needs a network check. Keep
309+
// cached data across transient 0→1 resubscribe gaps when neither applies.
310+
if (store.subscriberCount === 1) {
311+
if (store.snapshot.data === undefined || store.seedNeedsRevalidate) {
312+
store.seedNeedsRevalidate = false;
313+
void runFetch(store, fetcher, { replaceInflight: true, owner: onStoreChange });
314+
}
304315
}
305316
recomputePoll(store);
306317

@@ -328,7 +339,7 @@ function subscribeResource<T>(
328339
}
329340

330341
/** Module-level fetch cache with useSyncExternalStore subscriptions (no fetch in useEffect). */
331-
export interface ClientResourceOptions {
342+
export interface ClientResourceOptions<T = unknown> {
332343
pollMs?: number;
333344
enabled?: boolean;
334345
/**
@@ -337,18 +348,34 @@ export interface ClientResourceOptions {
337348
* happening off-screen — a restarting server, for instance.
338349
*/
339350
pauseWhenHidden?: boolean;
351+
/**
352+
* Optional seed applied once before the first subscribe (session-cache revisit).
353+
* Lives in the store — not a render-time ref — so React Compiler / react-hooks/refs
354+
* stay quiet while the mount fetch still quiet-revalidates via `seedNeedsRevalidate`.
355+
*/
356+
initialData?: T;
357+
}
358+
359+
/** Seed an empty, unsubscribed store. No-ops when data already exists or someone is listening. */
360+
function seedClientResourceIfEmpty<T>(key: string, data: T): void {
361+
const store = getStore<T>(key);
362+
if (store.subscriberCount !== 0 || store.snapshot.data !== undefined) return;
363+
setClientResourceData(key, data);
340364
}
341365

342366
export function useClientResource<T>(
343367
key: string,
344368
fetcher: (signal: AbortSignal) => Promise<T>,
345-
options?: ClientResourceOptions,
369+
options?: ClientResourceOptions<T>,
346370
): ResourceSnapshot<T> & { refresh: (opts?: { forceLoading?: boolean }) => void } {
347371
const enabled = options?.enabled !== false;
348372
const pollMs = options?.pollMs;
349373
// Default true: a background tab has nobody reading the paint. Opt out for polls that
350374
// must keep running while hidden, such as waiting for a restarted server to answer.
351375
const pauseWhenHidden = options?.pauseWhenHidden !== false;
376+
if (enabled && options?.initialData !== undefined) {
377+
seedClientResourceIfEmpty(key, options.initialData);
378+
}
352379
const fetcherRef = useRef(fetcher);
353380
// Sync latest fetcher every commit. No dep array on purpose: inline fetchers are
354381
// reallocated every render; listing them would re-subscribe forever.
@@ -411,7 +438,7 @@ export function useKeyedClientResource<T>(
411438
key: string,
412439
deps: readonly unknown[],
413440
load: (signal: AbortSignal) => Promise<T>,
414-
options?: ClientResourceOptions,
441+
options?: ClientResourceOptions<T>,
415442
): ResourceSnapshot<T> & { refresh: (opts?: { forceLoading?: boolean }) => void } {
416443
const resource = useClientResource(key, load, options);
417444
const prevDepsRef = useRef<readonly unknown[] | null>(null);
@@ -447,6 +474,9 @@ export function setClientResourceData<T>(key: string, data: T) {
447474
hasSucceeded: true,
448475
lastAttemptOk: true,
449476
};
477+
// Only pre-subscribe seeds need a follow-up fetch. Live publishers (mutation
478+
// results) already hold the fresh value and must not schedule a redundant GET.
479+
store.seedNeedsRevalidate = store.subscriberCount === 0;
450480
emit(store);
451481
}
452482

gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ export interface ApiKeysWorkspaceProps {
3838
copied: boolean;
3939
filteredModels: ExternalModelRow[];
4040
modelsLoading: boolean;
41+
/** Quiet revalidation / retry over rows already on screen — not a skeleton. */
42+
modelsRefreshing?: boolean;
4143
modelsLoadFailed: boolean;
4244
modelCount: number;
4345
hasModelData: boolean;
@@ -74,6 +76,7 @@ export default function ApiKeysWorkspace({
7476
copied,
7577
filteredModels,
7678
modelsLoading,
79+
modelsRefreshing = false,
7780
modelsLoadFailed,
7881
modelCount,
7982
hasModelData,
@@ -418,6 +421,7 @@ export default function ApiKeysWorkspace({
418421
<ApiKeysModelsPanel
419422
filteredModels={filteredModels}
420423
modelsLoading={modelsLoading}
424+
modelsRefreshing={modelsRefreshing}
421425
modelsLoadFailed={modelsLoadFailed}
422426
modelCount={modelCount}
423427
hasModelData={hasModelData}

gui/src/components/section-tabs.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,26 @@ export function SectionTabs({
4141
}
4242
}, []);
4343

44+
/** Timeout path: drop the click lock and re-read the visible section. */
45+
const expireScrollLock = useCallback(() => {
46+
clearScrollLock();
47+
// If the user interrupted smooth scroll, the target may never intersect — without a
48+
// resync `active` would stay on the clicked tab while another section is on screen.
49+
let bestId: string | null = null;
50+
let bestTop = Number.NEGATIVE_INFINITY;
51+
for (const item of items) {
52+
const node = document.getElementById(sectionAnchorId(scope, item.id));
53+
if (!node) continue;
54+
const top = node.getBoundingClientRect().top;
55+
// Match the observer bias: prefer a heading near the top of the viewport.
56+
if (top <= 120 && top > bestTop) {
57+
bestTop = top;
58+
bestId = item.id;
59+
}
60+
}
61+
if (bestId) setActive(bestId);
62+
}, [clearScrollLock, items, scope]);
63+
4464
useEffect(() => () => clearScrollLock(), [clearScrollLock]);
4565

4666
// Follow the scroll position. `rootMargin` biases the observer toward the top of the
@@ -82,7 +102,7 @@ export function SectionTabs({
82102
if (!target) return;
83103
scrollLockRef.current = id;
84104
if (scrollLockTimerRef.current !== null) clearTimeout(scrollLockTimerRef.current);
85-
scrollLockTimerRef.current = setTimeout(clearScrollLock, SECTION_TAB_SCROLL_LOCK_MS);
105+
scrollLockTimerRef.current = setTimeout(expireScrollLock, SECTION_TAB_SCROLL_LOCK_MS);
86106
setActive(id);
87107
// `scroll-margin-top` on the target keeps the heading clear of the pinned strip.
88108
target.scrollIntoView({ behavior: "smooth", block: "start" });

gui/src/data-surface.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ export type DataSurfaceOptions<T> = {
4343
enabled?: boolean;
4444
/** Forwarded to the resource layer; see ClientResourceOptions.pauseWhenHidden. */
4545
pauseWhenHidden?: boolean;
46+
/** Forwarded to the resource layer; see ClientResourceOptions.initialData. */
47+
initialData?: T;
4648
};
4749

4850
/**

gui/src/pages/ApiKeys.tsx

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import {
88
type ExternalModelRow,
99
type GatewayInboundProtocol,
1010
} from "../api-access-models";
11-
import { setClientResourceData } from "../client-resource";
1211
import { readSessionListCache, writeSessionListCache } from "../session-list-cache";
1312
import { createBoundedFetch } from "../bounded-fetch";
1413
import { useDataSurface } from "../data-surface";
@@ -98,17 +97,6 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) {
9897
// "no rules" table, or a row without `usage` throwing on first render.
9998
const cachedKeys = validCachedKeys(readSessionListCache<CachedKeysShape>(keysCacheKey));
10099
const cachedModels = readSessionListCache<ExternalModelRow[]>(modelsCacheKey);
101-
// Seed before subscribe so a revisit does not flash loading status under the page title.
102-
const seededKeysRef = useRef<string | null>(null);
103-
if (seededKeysRef.current !== keysResourceKey) {
104-
if (cachedKeys) setClientResourceData(keysResourceKey, cachedKeys);
105-
seededKeysRef.current = keysResourceKey;
106-
}
107-
const seededModelsRef = useRef<string | null>(null);
108-
if (seededModelsRef.current !== modelsResourceKey) {
109-
if (cachedModels) setClientResourceData(modelsResourceKey, cachedModels);
110-
seededModelsRef.current = modelsResourceKey;
111-
}
112100
const [actionError, setActionError] = useState<string | null>(null);
113101
const [modelQuery, setModelQuery] = useState("");
114102
const [copiedModelId, setCopiedModelId] = useState<string | null>(null);
@@ -178,13 +166,13 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) {
178166
keysResourceKey,
179167
[apiBase],
180168
fetchKeys,
181-
{ isEmpty: data => data.keys.length === 0 },
169+
{ isEmpty: data => data.keys.length === 0, initialData: cachedKeys ?? undefined },
182170
);
183171
const modelsResource = useDataSurface<ExternalModelRow[]>(
184172
modelsResourceKey,
185173
[apiBase],
186174
fetchModels,
187-
{ isEmpty: models => models.length === 0 },
175+
{ isEmpty: models => models.length === 0, initialData: cachedModels ?? undefined },
188176
);
189177
const keysState = keysResource.state;
190178
const modelsState = modelsResource.state;
@@ -427,6 +415,8 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) {
427415
copied={copied}
428416
filteredModels={filteredModels}
429417
modelsLoading={modelsState.showSkeleton && !modelsState.data && !cachedModels}
418+
// Only announce progress on a retry after failure — quiet warm revisits stay silent.
419+
modelsRefreshing={modelsState.refreshing && modelsState.showError && (modelsState.data !== undefined || cachedModels !== null)}
430420
modelsLoadFailed={modelsState.showError}
431421
modelCount={models.length}
432422
// `readSessionListCache` answers `null`, not `undefined`, when it has

gui/src/pages/Claude.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,23 @@ import { readSessionListCache } from "../session-list-cache";
66

77
type ClaudeTab = "code" | "desktop";
88

9+
function readCachedDesktopPort(apiBase: string): number | null {
10+
const cached = readSessionListCache<{ data?: { port?: number } }>(`ocx.claude-desktop.v1:${apiBase}`);
11+
return typeof cached?.data?.port === "number" ? cached.data.port : null;
12+
}
13+
914
export default function Claude({ apiBase }: { apiBase: string }) {
1015
const [tab, setTab] = useState<ClaudeTab>("code");
1116
const t = useT();
1217
const codeTabRef = useRef<HTMLButtonElement>(null);
1318
const desktopTabRef = useRef<HTMLButtonElement>(null);
1419
// Seed Desktop's port subtitle from session cache so the intro above the Code/Desktop
15-
// strip does not wait on the first status paint after a tab hop.
16-
const [desktopPort, setDesktopPort] = useState<number | null>(() => {
17-
const cached = readSessionListCache<{ data?: { port?: number } }>(`ocx.claude-desktop.v1:${apiBase}`);
18-
return typeof cached?.data?.port === "number" ? cached.data.port : null;
19-
});
20+
// strip does not wait on the first status paint after a tab hop. Live updates from
21+
// Desktop win while they match the current apiBase; a base change falls back to cache.
22+
const seededDesktopPort = readCachedDesktopPort(apiBase);
23+
const [liveDesktopPort, setLiveDesktopPort] = useState<{ base: string; port: number | null } | null>(null);
24+
const desktopPort = liveDesktopPort?.base === apiBase ? liveDesktopPort.port : seededDesktopPort;
25+
const setDesktopPort = (port: number | null) => setLiveDesktopPort({ base: apiBase, port });
2026

2127
const selectTab = (next: ClaudeTab) => {
2228
setTab(next);

gui/src/pages/ClaudeCode.tsx

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
2-
import { setClientResourceData } from "../client-resource";
1+
import { useCallback, useMemo, useState, type ReactNode } from "react";
32
import { Notice } from "../ui";
43
import { useI18n, useT, LOCALES } from "../i18n/shared";
54
import { readJsonOrThrow } from "../fetch-json";
@@ -34,12 +33,6 @@ export default function ClaudeCode({ apiBase, active = true }: { apiBase: string
3433
const cacheKey = `ocx.claude-code.v1:${apiBase}`;
3534
const resourceKey = `claude-code:${apiBase}`;
3635
const cached = useMemo(() => seedClaudeCode(cacheKey), [cacheKey]);
37-
// Seed before subscribe so Code↔Desktop hops do not flash "Loading…" under the title.
38-
const seededKeyRef = useRef<string | null>(null);
39-
if (seededKeyRef.current !== resourceKey) {
40-
if (cached) setClientResourceData(resourceKey, cached);
41-
seededKeyRef.current = resourceKey;
42-
}
4336
const [draftState, setState] = useState<ClaudeCodeState | null>(() => cached?.state ?? null);
4437
const [draftRows, setRows] = useState<MapRow[]>(() => cached?.rows ?? []);
4538
const [hasDraftRows, setHasDraftRows] = useState(Boolean(cached));
@@ -83,7 +76,7 @@ export default function ClaudeCode({ apiBase, active = true }: { apiBase: string
8376
resourceKey,
8477
[apiBase],
8578
fetchCode,
86-
{ isEmpty: () => false, enabled: active },
79+
{ isEmpty: () => false, enabled: active, initialData: cached ?? undefined },
8780
);
8881
const loadState = codeResource.state;
8982
const data = loadState.data ?? cached;

0 commit comments

Comments
 (0)