Skip to content

Commit 63735af

Browse files
authored
Merge pull request #824 from Wibias/fix/gui-ux-bugs
fix(gui): stop revisit flashes and layout shift across dashboard tabs
2 parents aae9426 + c25fcb1 commit 63735af

37 files changed

Lines changed: 1168 additions & 311 deletions

gui/src/client-resource.ts

Lines changed: 38 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
}
@@ -223,6 +230,8 @@ async function runFetch<T>(
223230
try {
224231
const data = await fetcher(controller.signal);
225232
if (gen !== store.generation || controller.signal.aborted) return;
233+
// Cleared on settle (not at subscribe) so StrictMode's aborted first mount still revalidates.
234+
store.seedNeedsRevalidate = false;
226235
store.snapshot = {
227236
data,
228237
error: undefined,
@@ -233,6 +242,7 @@ async function runFetch<T>(
233242
};
234243
} catch (error) {
235244
if (gen !== store.generation || controller.signal.aborted) return;
245+
store.seedNeedsRevalidate = false;
236246
store.snapshot = {
237247
...store.snapshot,
238248
// Normalize so a loader that rejects with `undefined` still reads as a failure.
@@ -298,9 +308,12 @@ function subscribeResource<T>(
298308
store.fetcherByListener.set(onStoreChange, fetcher);
299309
store.subscriberCount++;
300310

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 });
311+
// Cold start, or a pre-subscribe seed that still needs a network check. Keep
312+
// cached data across transient 0→1 resubscribe gaps when neither applies.
313+
if (store.subscriberCount === 1) {
314+
if (store.snapshot.data === undefined || store.seedNeedsRevalidate) {
315+
void runFetch(store, fetcher, { replaceInflight: true, owner: onStoreChange });
316+
}
304317
}
305318
recomputePoll(store);
306319

@@ -328,7 +341,7 @@ function subscribeResource<T>(
328341
}
329342

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

342368
export function useClientResource<T>(
343369
key: string,
344370
fetcher: (signal: AbortSignal) => Promise<T>,
345-
options?: ClientResourceOptions,
371+
options?: ClientResourceOptions<T>,
346372
): ResourceSnapshot<T> & { refresh: (opts?: { forceLoading?: boolean }) => void } {
347373
const enabled = options?.enabled !== false;
348374
const pollMs = options?.pollMs;
349375
// Default true: a background tab has nobody reading the paint. Opt out for polls that
350376
// must keep running while hidden, such as waiting for a restarted server to answer.
351377
const pauseWhenHidden = options?.pauseWhenHidden !== false;
378+
if (enabled && options?.initialData !== undefined) {
379+
seedClientResourceIfEmpty(key, options.initialData);
380+
}
352381
const fetcherRef = useRef(fetcher);
353382
// Sync latest fetcher every commit. No dep array on purpose: inline fetchers are
354383
// reallocated every render; listing them would re-subscribe forever.
@@ -411,7 +440,7 @@ export function useKeyedClientResource<T>(
411440
key: string,
412441
deps: readonly unknown[],
413442
load: (signal: AbortSignal) => Promise<T>,
414-
options?: ClientResourceOptions,
443+
options?: ClientResourceOptions<T>,
415444
): ResourceSnapshot<T> & { refresh: (opts?: { forceLoading?: boolean }) => void } {
416445
const resource = useClientResource(key, load, options);
417446
const prevDepsRef = useRef<readonly unknown[] | null>(null);
@@ -447,6 +476,9 @@ export function setClientResourceData<T>(key: string, data: T) {
447476
hasSucceeded: true,
448477
lastAttemptOk: true,
449478
};
479+
// Only pre-subscribe seeds need a follow-up fetch. Live publishers (mutation
480+
// results) already hold the fresh value and must not schedule a redundant GET.
481+
store.seedNeedsRevalidate = store.subscriberCount === 0;
450482
emit(store);
451483
}
452484

gui/src/components/CodexAccountPool.tsx

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import CodexPoolStrategySetting from "./CodexPoolStrategySetting";
1111
import { useCodexAutoSwitch } from "../hooks/useCodexAutoSwitch";
1212
import { readJsonIfOk } from "../fetch-json";
1313
import { CodexAccountPoolCards, CodexAccountPoolReauthBanner } from "./codex-account-pool-cards";
14-
import { DataSurfaceStatus } from "./data-surface";
1514
import { CodexAccountSwitchModal } from "./codex-account-switch-modal";
1615
import { CodexAccountResetModal } from "./codex-account-reset-modal";
1716
import { CodexAccountPoolLoadStates, CodexAccountPoolMainCard, CodexAccountPoolPageHead } from "./codex-account-pool-main-card";
@@ -57,10 +56,6 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
5756
const ownController = useCodexAccountPool(apiBase, !injectedController);
5857
const controller = injectedController ?? ownController;
5958
const { accounts, activeId, loadState, switchingId, pauseUpdatingId, pausingExhausted, load } = controller;
60-
// The controller owns the visible progress signal. A forced quota refresh keeps rows on screen
61-
// and can take ~1s (longer when the server has to refill per-account quota), so without this the
62-
// wait is invisible; `refreshingQuota` below stays local because it only guards its own button.
63-
const { refreshing } = controller;
6459
const [confirm, setConfirm] = useState<CodexAccountEntry | null>(null);
6560
const [showAdd, setShowAdd] = useState(false);
6661
const [reauthId, setReauthId] = useState<string | null>(null);
@@ -277,12 +272,6 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
277272
onRetry={() => { void load(); }}
278273
/>
279274

280-
{/* Revalidation over existing rows. The cold branch above already owns a live region, so
281-
this only renders once rows are on screen, keeping one announcement per transition. */}
282-
{refreshing && accounts.length > 0 && (
283-
<DataSurfaceStatus live={loadState !== "error"}>{t("common.loading")}</DataSurfaceStatus>
284-
)}
285-
286275
{!(loadState === "loading" && accounts.length === 0) && (
287276
<>
288277
<CodexAccountPoolMainCard

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ export interface ApiKeysWorkspaceProps {
3939
copied: boolean;
4040
filteredModels: ExternalModelRow[];
4141
modelsLoading: boolean;
42+
/** Quiet revalidation / retry over rows already on screen — not a skeleton. */
43+
modelsRefreshing?: boolean;
4244
modelsLoadFailed: boolean;
4345
modelCount: number;
4446
hasModelData: boolean;
@@ -76,6 +78,7 @@ export default function ApiKeysWorkspace({
7678
copied,
7779
filteredModels,
7880
modelsLoading,
81+
modelsRefreshing = false,
7982
modelsLoadFailed,
8083
modelCount,
8184
hasModelData,
@@ -420,6 +423,7 @@ export default function ApiKeysWorkspace({
420423
<ApiKeysModelsPanel
421424
filteredModels={filteredModels}
422425
modelsLoading={modelsLoading}
426+
modelsRefreshing={modelsRefreshing}
423427
modelsLoadFailed={modelsLoadFailed}
424428
modelCount={modelCount}
425429
hasModelData={hasModelData}

gui/src/components/section-tabs.tsx

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
* The active tab follows the scroll position, so the strip reports where you are rather
99
* than only where you last clicked.
1010
*/
11-
import { useEffect, useRef, useState } from "react";
12-
import { sectionAnchorId, sectionAnchorPrefix } from "../section-anchors";
11+
import { useCallback, useEffect, useRef, useState } from "react";
12+
import { SECTION_TAB_SCROLL_LOCK_MS, sectionAnchorId, sectionAnchorPrefix } from "../section-anchors";
1313

1414
export interface SectionTabItem {
1515
id: string;
@@ -28,7 +28,41 @@ export function SectionTabs({
2828
ariaLabel: string;
2929
}) {
3030
const [active, setActive] = useState(items[0]?.id ?? "");
31-
const stripRef = useRef<HTMLDivElement | null>(null);
31+
/** While set, scroll-spy ignores intermediate sections during smooth scroll-to-click. */
32+
const scrollLockRef = useRef<string | null>(null);
33+
const scrollLockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
34+
35+
const clearScrollLock = useCallback(() => {
36+
scrollLockRef.current = null;
37+
if (scrollLockTimerRef.current !== null) {
38+
clearTimeout(scrollLockTimerRef.current);
39+
scrollLockTimerRef.current = null;
40+
}
41+
}, []);
42+
43+
/** Timeout path: drop the click lock and re-read the visible section. */
44+
const expireScrollLock = useCallback(() => {
45+
clearScrollLock();
46+
// If the user interrupted smooth scroll, the target may never cross the observer threshold —
47+
// without a resync `active` would stay on the clicked tab while another section is on screen.
48+
// Prefer the heading nearest the sticky-strip reading line (not only those with top <= 120),
49+
// so a destination that stopped mid-viewport still wins over an off-screen prior heading.
50+
let bestId: string | null = null;
51+
let bestDistance = Number.POSITIVE_INFINITY;
52+
const readingLine = 72;
53+
for (const item of items) {
54+
const node = document.getElementById(sectionAnchorId(scope, item.id));
55+
if (!node) continue;
56+
const distance = Math.abs(node.getBoundingClientRect().top - readingLine);
57+
if (distance < bestDistance) {
58+
bestDistance = distance;
59+
bestId = item.id;
60+
}
61+
}
62+
if (bestId) setActive(bestId);
63+
}, [clearScrollLock, items, scope]);
64+
65+
useEffect(() => () => clearScrollLock(), [clearScrollLock]);
3266

3367
// Follow the scroll position. `rootMargin` biases the observer toward the top of the
3468
// viewport so the heading you are reading wins, not whatever is technically centred.
@@ -41,6 +75,16 @@ export function SectionTabs({
4175

4276
const observer = new IntersectionObserver(
4377
entries => {
78+
const locked = scrollLockRef.current;
79+
if (locked) {
80+
const lockedNode = document.getElementById(sectionAnchorId(scope, locked));
81+
const lockedVisible = entries.some(entry => entry.isIntersecting && entry.target === lockedNode);
82+
if (lockedVisible) {
83+
clearScrollLock();
84+
setActive(locked);
85+
}
86+
return;
87+
}
4488
const visible = entries
4589
.filter(entry => entry.isIntersecting)
4690
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0];
@@ -52,19 +96,21 @@ export function SectionTabs({
5296
);
5397
for (const node of nodes) observer.observe(node);
5498
return () => observer.disconnect();
55-
}, [items, scope]);
99+
}, [clearScrollLock, items, scope]);
56100

57101
const go = (id: string) => {
58102
const target = document.getElementById(sectionAnchorId(scope, id));
59103
if (!target) return;
104+
scrollLockRef.current = id;
105+
if (scrollLockTimerRef.current !== null) clearTimeout(scrollLockTimerRef.current);
106+
scrollLockTimerRef.current = setTimeout(expireScrollLock, SECTION_TAB_SCROLL_LOCK_MS);
60107
setActive(id);
61108
// `scroll-margin-top` on the target keeps the heading clear of the pinned strip.
62109
target.scrollIntoView({ behavior: "smooth", block: "start" });
63110
};
64111

65112
return (
66113
<div
67-
ref={stripRef}
68114
className="page-tabs section-tabs"
69115
role="tablist"
70116
aria-label={ariaLabel}

gui/src/components/storage-workspace/StorageWorkspace.tsx

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -169,18 +169,18 @@ export default function StorageWorkspace({ report, locale }: StorageWorkspacePro
169169
</div>
170170
) : (
171171
<div className="stw-overview">
172-
<div className="usage-cards">
173-
<div className="stat">
174-
<div className="muted">{t("storage.card.total")}</div>
175-
<div className="stat-value">{formatBytes(report.total.bytes, locale)}</div>
172+
<div className="stw-summary">
173+
<div className="stw-summary-card">
174+
<div className="stw-summary-label">{t("storage.card.total")}</div>
175+
<div className="stw-summary-value">{formatBytes(report.total.bytes, locale)}</div>
176176
</div>
177-
<div className="stat">
178-
<div className="muted">{t("storage.card.files")}</div>
179-
<div className="stat-value">{report.total.fileCount.toLocaleString(locale)}</div>
177+
<div className="stw-summary-card">
178+
<div className="stw-summary-label">{t("storage.card.files")}</div>
179+
<div className="stw-summary-value">{report.total.fileCount.toLocaleString(locale)}</div>
180180
</div>
181-
<div className="stat">
182-
<div className="muted">{t("storage.card.home")}</div>
183-
<div className="stat-value mono stw-home-path" title={report.codexHome}>{report.codexHome}</div>
181+
<div className="stw-summary-card">
182+
<div className="stw-summary-label">{t("storage.card.home")}</div>
183+
<div className="stw-summary-value mono stw-home-path" title={report.codexHome}>{report.codexHome}</div>
184184
</div>
185185
</div>
186186

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: 13 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
import { readSessionListCache, writeSessionListCache } from "../session-list-cache";
1212
import { createBoundedFetch } from "../bounded-fetch";
1313
import { useDataSurface } from "../data-surface";
14-
import { DataSurfaceSkeleton, DataSurfaceStatus } from "../components/data-surface";
14+
import { DataSurfaceSkeleton } from "../components/data-surface";
1515
import ApiKeysWorkspace from "../components/apikeys-workspace/ApiKeysWorkspace";
1616
import {
1717
DEFAULT_ENDPOINTS,
@@ -92,6 +92,8 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) {
9292
// turn stale client state into an apparently authoritative empty auth table.
9393
const keysCacheKey = `ocx.apikeys.list.v2:${apiBase}`;
9494
const modelsCacheKey = `ocx.apikeys.models.v1:${apiBase}`;
95+
const keysResourceKey = `api-keys:${apiBase}`;
96+
const modelsResourceKey = `api-models:${apiBase}`;
9597
// A cache entry is arbitrary parsed JSON. Trusting it would reintroduce exactly
9698
// what the network path refuses: an empty matrix rendering as an authoritative
9799
// "no rules" table, or a row without `usage` throwing on first render.
@@ -164,16 +166,16 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) {
164166
// Keys and models intentionally remain independent resources: a slow catalog must never
165167
// block endpoint/key management, and each cache key retains its own session seed.
166168
const keysResource = useDataSurface<CachedKeysShape>(
167-
`api-keys:${apiBase}`,
169+
keysResourceKey,
168170
[apiBase],
169171
fetchKeys,
170-
{ isEmpty: data => data.keys.length === 0 },
172+
{ isEmpty: data => data.keys.length === 0, initialData: cachedKeys ?? undefined },
171173
);
172174
const modelsResource = useDataSurface<ExternalModelRow[]>(
173-
`api-models:${apiBase}`,
175+
modelsResourceKey,
174176
[apiBase],
175177
fetchModels,
176-
{ isEmpty: models => models.length === 0 },
178+
{ isEmpty: models => models.length === 0, initialData: cachedModels ?? undefined },
177179
);
178180
const keysState = keysResource.state;
179181
const modelsState = modelsResource.state;
@@ -374,7 +376,10 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) {
374376
const subtitleParts = t("api.subtitle").split("{authHeader}");
375377

376378
return (
377-
<section className="api-page">
379+
<section
380+
className="api-page"
381+
aria-busy={keysState.refreshing || modelsState.refreshing || undefined}
382+
>
378383
<div className="page-head">
379384
<h2>{t("api.title")}</h2>
380385
</div>
@@ -399,20 +404,6 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) {
399404
</>
400405
) : (
401406
<>
402-
{/* Keys and models revalidate independently and can be in flight together. Only one
403-
region may announce per transition, so keys take precedence and models steps down to
404-
visual-only while keys is speaking. */}
405-
{keysState.refreshing && keysData && (
406-
<DataSurfaceStatus live={!keysState.showError}>{t("api.activeKeysLoading")}</DataSurfaceStatus>
407-
)}
408-
{/* `modelsState.data` alone misses the session-cached case: after a
409-
failure the rows on screen come from `cachedModels`, and a retry
410-
then ran with no visible progress at all. */}
411-
{modelsState.refreshing && (modelsState.data !== undefined || cachedModels !== null) && (
412-
<DataSurfaceStatus live={!modelsState.showError && !(keysState.refreshing && keysData)}>
413-
{t("api.modelsLoading")}
414-
</DataSurfaceStatus>
415-
)}
416407
<ApiKeysWorkspace
417408
keys={keys}
418409
attributionSince={attributionSince}
@@ -429,6 +420,8 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) {
429420
copied={copied}
430421
filteredModels={filteredModels}
431422
modelsLoading={modelsState.showSkeleton && !modelsState.data && !cachedModels}
423+
// Only announce progress on a retry after failure — quiet warm revisits stay silent.
424+
modelsRefreshing={modelsState.refreshing && modelsState.showError && (modelsState.data !== undefined || cachedModels !== null)}
432425
modelsLoadFailed={modelsState.showError}
433426
modelCount={models.length}
434427
// `readSessionListCache` answers `null`, not `undefined`, when it has

0 commit comments

Comments
 (0)