Skip to content

Commit a2072d5

Browse files
committed
fix(gui): clear react-doctor warnings on dashboard stack
1 parent 7481715 commit a2072d5

7 files changed

Lines changed: 61 additions & 32 deletions

File tree

gui/src/components/provider-workspace/ProviderAuthPanel.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,11 @@ import type { AccountLoadState, OAuthAccountRow, ApiKeyRow, LoginHint, ProviderA
2828

2929
const DOCTOR_CMD = "ocx doctor";
3030
const QUOTA_ENRICH_RESERVE_MS = 4_000;
31+
const EMPTY_OAUTH_ACCOUNTS: OAuthAccountRow[] = [];
32+
const EMPTY_API_KEYS: ApiKeyRow[] = [];
3133

3234
export default function ProviderAuthPanel({
33-
item, apiBase, oauth, accounts = [], keys = [], accountLoadState = "ready",
35+
item, apiBase, oauth, accounts = EMPTY_OAUTH_ACCOUNTS, keys = EMPTY_API_KEYS, accountLoadState = "ready",
3436
switchingAccountId = null, busy = false, loginHint, authHandlers, onCodexActiveNeedsReauthChange,
3537
codexController,
3638
}: {

gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -108,16 +108,17 @@ export default function ProviderWorkspaceShell({
108108
const [modelsLoadFailed, setModelsLoadFailed] = useState(false);
109109
const quotasCacheKey = `ocx.providers.quotas.v1:${apiBase}`;
110110
const usageCacheKey = `ocx.providers.usage.v1:${apiBase}`;
111-
const cachedQuotas = readSessionListCache<Record<string, ProviderQuotaReportView>>(quotasCacheKey);
112-
const cachedUsage = readSessionListCache<{
113-
totals: Record<string, ProviderUsageTotals>;
114-
models: Record<string, ProviderModelUsageRow[]>;
115-
}>(usageCacheKey);
116-
const [usageTotals, setUsageTotals] = useState<Record<string, ProviderUsageTotals>>(() => cachedUsage?.totals ?? {});
117-
const [usageModels, setUsageModels] = useState<Record<string, ProviderModelUsageRow[]>>(() => cachedUsage?.models ?? {});
118-
const [quotaReports, setQuotaReports] = useState<Record<string, ProviderQuotaReportView>>(() => cachedQuotas ?? {});
119-
const [usageLoading, setUsageLoading] = useState(() => !cachedUsage);
120-
const [quotasLoading, setQuotasLoading] = useState(() => !cachedQuotas);
111+
const [usageTotals, setUsageTotals] = useState<Record<string, ProviderUsageTotals>>(() => (
112+
readSessionListCache<{ totals: Record<string, ProviderUsageTotals> }>(usageCacheKey)?.totals ?? {}
113+
));
114+
const [usageModels, setUsageModels] = useState<Record<string, ProviderModelUsageRow[]>>(() => (
115+
readSessionListCache<{ models: Record<string, ProviderModelUsageRow[]> }>(usageCacheKey)?.models ?? {}
116+
));
117+
const [quotaReports, setQuotaReports] = useState<Record<string, ProviderQuotaReportView>>(() => (
118+
readSessionListCache<Record<string, ProviderQuotaReportView>>(quotasCacheKey) ?? {}
119+
));
120+
const [usageLoading, setUsageLoading] = useState(() => !readSessionListCache(usageCacheKey));
121+
const [quotasLoading, setQuotasLoading] = useState(() => !readSessionListCache(quotasCacheKey));
121122
const [modelsLoadEpoch, setModelsLoadEpoch] = useState(0);
122123
const filterWrapRef = useRef<HTMLDivElement>(null);
123124

@@ -164,7 +165,9 @@ export default function ProviderWorkspaceShell({
164165
let cancelled = false;
165166
const timeout = window.setTimeout(() => {
166167
// Keep last-good paint when sessionStorage already seeded — don't flash loading skeletons.
167-
if (!cachedUsage) setUsageLoading(true);
168+
// Read inside the effect (keyed by usageCacheKey) so the seed check stays correct without
169+
// closing over an unstable cachedUsage render value.
170+
if (!readSessionListCache(usageCacheKey)) setUsageLoading(true);
168171
void fetch(`${apiBase}/api/usage?range=30d`)
169172
.then(r => readJsonIfOk<{
170173
providers?: Array<{ provider: string; requests: number; totalTokens?: number }>;
@@ -206,7 +209,7 @@ export default function ProviderWorkspaceShell({
206209
useEffect(() => {
207210
let cancelled = false;
208211
const timeout = window.setTimeout(() => {
209-
if (!cachedQuotas) setQuotasLoading(true);
212+
if (!readSessionListCache(quotasCacheKey)) setQuotasLoading(true);
210213
void fetch(`${apiBase}/api/provider-quotas`)
211214
.then(r => readJsonIfOk<{ reports?: Array<{ provider: string; label?: string; source?: string; updatedAt?: number; quota?: unknown }> }>(r))
212215
.then((data) => {

gui/src/pages/dashboard-core-poll.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { readJsonIfOk } from "../fetch-json";
22
import {
3+
beginPollEpoch,
34
settingsPollMayCommit,
45
mapStartupHealthProbe,
56
type StartupHealthStatus,
@@ -145,8 +146,10 @@ export async function fetchDashboardSidecars(
145146
epochs: DashboardEpochRefs,
146147
): Promise<DashboardSidecarPoll> {
147148
// Only bump shadow epochs — settings has its own poll and must not be invalidated here.
148-
const shadowRequestEpoch = ++epochs.shadowCallRequestEpochRef.current;
149-
const shadowMutationEpoch = epochs.shadowCallMutationEpochRef.current;
149+
const { request: shadowRequestEpoch, mutation: shadowMutationEpoch } = beginPollEpoch(
150+
epochs.shadowCallRequestEpochRef,
151+
epochs.shadowCallMutationEpochRef,
152+
);
150153

151154
const [scRes, shRes] = await Promise.all([
152155
fetch(`${apiBase}/api/sidecar-settings`, { signal }),
@@ -200,8 +203,10 @@ export async function fetchDashboardSettings(
200203
signal: AbortSignal,
201204
epochs: DashboardEpochRefs,
202205
): Promise<DashboardSettingsPoll> {
203-
const settingsRequestEpoch = ++epochs.settingsRequestEpochRef.current;
204-
const settingsMutationEpoch = epochs.settingsMutationEpochRef.current;
206+
const { request: settingsRequestEpoch, mutation: settingsMutationEpoch } = beginPollEpoch(
207+
epochs.settingsRequestEpochRef,
208+
epochs.settingsMutationEpochRef,
209+
);
205210

206211
const sRes = await fetch(`${apiBase}/api/settings`, { signal });
207212
const nextSettings = await requireJson<SettingsData>(sRes);

gui/src/pages/use-dashboard-data.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,6 @@ export function useDashboardData(apiBase: string) {
108108
const [settingsSaving, setSettingsSaving] = useState(false);
109109
const [syncing, setSyncing] = useState(false);
110110
const [maMode, setMaMode] = useState<MaMode>(() => cachedMaMode ?? "default");
111-
const [maModeResolved, setMaModeResolved] = useState(() => cachedMaMode !== null);
112111
const [maBusy, setMaBusy] = useState(false);
113112
const [maHelpOpen, setMaHelpOpen] = useState(false);
114113
const [effortCapHelpOpen, setEffortCapHelpOpen] = useState(false);
@@ -284,10 +283,13 @@ export function useDashboardData(apiBase: string) {
284283
useEffect(() => {
285284
if (maModePoll.data === undefined) return;
286285
setMaMode(maModePoll.data.maMode);
287-
setMaModeResolved(true);
288286
writeSessionListCache(`${MA_MODE_CACHE_PREFIX}${apiBase}`, maModePoll.data.maMode);
289287
}, [maModePoll.data, apiBase]);
290288

289+
// Derived — avoids setState-on-prop-change for the resolved flag. Cache / poll / optimistic
290+
// save (which writes the same cache key) all count as resolved for MA UI.
291+
const maModeResolved = maModePoll.data !== undefined || cachedMaMode !== null;
292+
291293
useEffect(() => {
292294
const data = multiAgentPoll.data;
293295
if (!data) return;
@@ -494,7 +496,6 @@ export function useDashboardData(apiBase: string) {
494496
});
495497
if (r.ok) {
496498
setMaMode(mode);
497-
setMaModeResolved(true);
498499
writeSessionListCache(`${MA_MODE_CACHE_PREFIX}${apiBase}`, mode);
499500
}
500501
} catch { /* ignore */ }

gui/src/startup-health-ui.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,18 @@ export function settingsPollMayCommit(
2525
&& started.mutation === current.mutation;
2626
}
2727

28-
/** Snapshot + bump request epochs before issuing any poll fetches. */
28+
/** Snapshot + bump one request epoch before issuing a poll fetch. */
29+
export function beginPollEpoch(
30+
request: { current: number },
31+
mutation: { current: number },
32+
): SettingsPollEpoch {
33+
return {
34+
request: ++request.current,
35+
mutation: mutation.current,
36+
};
37+
}
38+
39+
/** Snapshot + bump request epochs before issuing paired settings/shadow poll fetches. */
2940
export function beginPollEpochs(refs: {
3041
settingsRequest: { current: number };
3142
settingsMutation: { current: number };
@@ -36,14 +47,8 @@ export function beginPollEpochs(refs: {
3647
shadow: SettingsPollEpoch;
3748
} {
3849
return {
39-
settings: {
40-
request: ++refs.settingsRequest.current,
41-
mutation: refs.settingsMutation.current,
42-
},
43-
shadow: {
44-
request: ++refs.shadowRequest.current,
45-
mutation: refs.shadowMutation.current,
46-
},
50+
settings: beginPollEpoch(refs.settingsRequest, refs.settingsMutation),
51+
shadow: beginPollEpoch(refs.shadowRequest, refs.shadowMutation),
4752
};
4853
}
4954

gui/src/styles-dashboard-workspace.css

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@
2828
.dashboard-workspace-main .tbl-wrap {
2929
max-height: 520px;
3030
overflow-y: auto;
31-
/* Inset table from border+surface so cells aren't flush to the frame. */
32-
padding: var(--space-2);
31+
/* Side/bottom inset only — top padding on a sticky scrollport leaks rows above thead. */
32+
padding: 0 var(--space-2) var(--space-2);
3333
}
3434

3535
.dashboard-workspace-main .tbl thead th {

gui/tests/dashboard-contracts.test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,25 @@
11
import { expect, test } from "bun:test";
22
import { en } from "../src/i18n/en";
33
import { normalizeInjectionSelection } from "../src/pages/dashboard-core-poll";
4-
import { PROJECT_CONFIG_DIAGNOSTICS_POLL_MS } from "../src/startup-health-ui";
4+
import { PROJECT_CONFIG_DIAGNOSTICS_POLL_MS, beginPollEpoch, beginPollEpochs } from "../src/startup-health-ui";
55

66
test("project-config diagnostics poll cadence is owned by the shared constant", () => {
77
expect(PROJECT_CONFIG_DIAGNOSTICS_POLL_MS).toBe(30_000);
88
});
99

10+
test("dashboard poll epochs share beginPollEpoch", () => {
11+
const refs = {
12+
settingsRequest: { current: 0 },
13+
settingsMutation: { current: 2 },
14+
shadowRequest: { current: 0 },
15+
shadowMutation: { current: 4 },
16+
};
17+
const paired = beginPollEpochs(refs);
18+
expect(paired.settings).toEqual({ request: 1, mutation: 2 });
19+
expect(paired.shadow).toEqual({ request: 1, mutation: 4 });
20+
expect(beginPollEpoch(refs.settingsRequest, refs.settingsMutation)).toEqual({ request: 2, mutation: 2 });
21+
});
22+
1023
test("Dashboard wires a single project-config diagnostics owner outside the settings poll", async () => {
1124
const core = await Bun.file(new URL("../src/pages/dashboard-core-poll.ts", import.meta.url)).text();
1225
const hook = await Bun.file(new URL("../src/pages/use-dashboard-data.ts", import.meta.url)).text();

0 commit comments

Comments
 (0)