Skip to content

Commit 7481715

Browse files
committed
fix(gui): address review findings on dashboard/startup stack
1 parent ee3eaa2 commit 7481715

12 files changed

Lines changed: 108 additions & 74 deletions

File tree

docs-site/src/content/docs/reference/cli.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,13 +429,15 @@ Windows **Task Scheduler**) that auto-starts on login and auto-restarts on crash
429429
| `start` | Start an installed service. |
430430
| `stop` | Stop the service and restore native Codex. |
431431
| `status` | Report whether the service is running. |
432+
| `repair` | Refresh installed service assets without re-registering (no Task Scheduler UAC). |
432433
| `uninstall` | Remove the service and restore native Codex. |
433434
| `remove` | Alias of `uninstall`. |
434435

435436
```bash
436437
ocx service
437438
ocx service install
438439
ocx service status
440+
ocx service repair
439441
ocx service uninstall
440442
```
441443

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,12 @@ export default function ProviderOverviewDashboard({
5151

5252
const attention = useMemo(() => buildAttentionItems(sections, {}), [sections]);
5353
const attentionCount = attention.length;
54-
const reauthCount = useMemo(
55-
() => [...sections.ready, ...sections.needsSetup].filter(p => p.activeNeedsReauth).length,
54+
const readyReauthCount = useMemo(
55+
() => sections.ready.filter(p => p.activeNeedsReauth).length,
5656
[sections],
5757
);
58+
const readyCount = sections.ready.length - readyReauthCount;
59+
const needsAttentionCount = sections.needsSetup.length + readyReauthCount;
5860

5961
/* Rate-limit rows: urgency first (highest utilisation), then name */
6062
const quotaProviders = useMemo(() => {
@@ -100,10 +102,10 @@ export default function ProviderOverviewDashboard({
100102
</div>
101103

102104
<div className="pws-dashboard-summary">
103-
<SummaryCard count={sections.ready.length} label={t("pws.status.ready")} tone="ok" />
105+
<SummaryCard count={readyCount} label={t("pws.status.ready")} tone="ok" />
104106
<SummaryCard
105-
count={sections.needsSetup.length}
106-
label={reauthCount > 0 ? t("pws.status.needsAttention") : t("pws.status.needsSetup")}
107+
count={needsAttentionCount}
108+
label={readyReauthCount > 0 ? t("pws.status.needsAttention") : t("pws.status.needsSetup")}
107109
tone="warn"
108110
/>
109111
<SummaryCard count={sections.disabled.length} label={t("prov.disabledBadge")} tone="muted" />

gui/src/pages/Startup.tsx

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -132,10 +132,6 @@ export default function Startup({ apiBase }: { apiBase: string }) {
132132
const [settings, trayResult] = await Promise.all([settingsPromise, trayPromise]);
133133
if (signal?.aborted || generation !== loadGenerationRef.current) return;
134134

135-
const notice = deriveCodexRuntimeNotice(settings?.codexRuntime, t, next.platform);
136-
setCodexRuntimeWarning(notice.warning);
137-
setCodexRuntimeFix(notice.fix);
138-
setRuntimeNoticePending(false);
139135
const nextTray = next.platform === "win32" ? trayResult.tray : null;
140136
if (next.platform === "win32") {
141137
setTray(nextTray);
@@ -145,13 +141,28 @@ export default function Startup({ apiBase }: { apiBase: string }) {
145141
setTrayError(false);
146142
}
147143
setTrayLoading(false);
144+
setRuntimeNoticePending(false);
148145

149-
writeSessionListCache(cacheKey, {
150-
data: next,
151-
warning: notice.warning,
152-
fix: notice.fix,
153-
tray: nextTray,
154-
} satisfies StartupPageCache);
146+
if (settings) {
147+
const notice = deriveCodexRuntimeNotice(settings.codexRuntime, t, next.platform);
148+
setCodexRuntimeWarning(notice.warning);
149+
setCodexRuntimeFix(notice.fix);
150+
writeSessionListCache(cacheKey, {
151+
data: next,
152+
warning: notice.warning,
153+
fix: notice.fix,
154+
tray: nextTray,
155+
} satisfies StartupPageCache);
156+
} else {
157+
// Settings fetch failure: keep the last-good runtime notice in UI + cache.
158+
const prev = readSessionListCache<StartupPageCache>(cacheKey);
159+
writeSessionListCache(cacheKey, {
160+
data: next,
161+
warning: prev?.warning ?? null,
162+
fix: prev?.fix ?? null,
163+
tray: nextTray,
164+
} satisfies StartupPageCache);
165+
}
155166
} catch {
156167
if (signal?.aborted || generation !== loadGenerationRef.current) return;
157168
setFailed(true);
@@ -289,6 +300,7 @@ export default function Startup({ apiBase }: { apiBase: string }) {
289300
<StartupDetailsSection
290301
data={data}
291302
failed={failed}
303+
loading={loading}
292304
installBusy={installBusy}
293305
installResult={installResult}
294306
onInstall={(action, opts) => { void runInstallAction(action, opts); }}

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

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,16 @@ export type DashboardMultiAgentPoll = {
6262
/** Sidecar + shadow only — must not wait on /api/settings (startup-health). */
6363
export type DashboardSidecarPoll = {
6464
sidecar: SidecarData;
65-
shadowCall: ShadowCallData | null;
65+
/**
66+
* `null` = authoritative endpoint failure (clear UI).
67+
* `undefined` = lost poll authority (epoch gate) — do not commit.
68+
*/
69+
shadowCall: ShadowCallData | null | undefined;
6670
};
6771

6872
export type DashboardSettingsPoll = {
69-
settings: SettingsData | null;
73+
/** Absent when the poll lost authority — callers must keep prior settings/cache. */
74+
settings: SettingsData | undefined;
7075
startupHealthSeed: SettingsData["startupHealth"] | null | undefined;
7176
};
7277

@@ -149,7 +154,7 @@ export async function fetchDashboardSidecars(
149154
]);
150155

151156
const sidecar = await requireJson<SidecarData>(scRes);
152-
let shadowCall: ShadowCallData | null = null;
157+
let shadowCall: ShadowCallData | null | undefined = undefined;
153158
try {
154159
if (shRes.ok) {
155160
const nextShadow = await shRes.json() as ShadowCallData;
@@ -163,6 +168,15 @@ export async function fetchDashboardSidecars(
163168
)) {
164169
shadowCall = nextShadow;
165170
}
171+
} else if (settingsPollMayCommit(
172+
{ request: shadowRequestEpoch, mutation: shadowMutationEpoch },
173+
{
174+
request: epochs.shadowCallRequestEpochRef.current,
175+
mutation: epochs.shadowCallMutationEpochRef.current,
176+
mutationInFlight: epochs.shadowCallMutationInFlightRef.current,
177+
},
178+
)) {
179+
shadowCall = null;
166180
}
167181
} catch {
168182
if (settingsPollMayCommit(
@@ -191,7 +205,7 @@ export async function fetchDashboardSettings(
191205

192206
const sRes = await fetch(`${apiBase}/api/settings`, { signal });
193207
const nextSettings = await requireJson<SettingsData>(sRes);
194-
let settings: SettingsData | null = null;
208+
let settings: SettingsData | undefined = undefined;
195209
let startupHealthSeed: SettingsData["startupHealth"] | null | undefined = undefined;
196210
if (settingsPollMayCommit(
197211
{ request: settingsRequestEpoch, mutation: settingsMutationEpoch },

gui/src/pages/startup-sections.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,19 +77,23 @@ export function StartupHeroSection({
7777
export function StartupDetailsSection({
7878
data,
7979
failed,
80+
loading = false,
8081
installBusy,
8182
installResult,
8283
onInstall,
8384
}: {
8485
data: StartupHealthData;
8586
failed: boolean;
87+
loading?: boolean;
8688
installBusy: StartupInstallAction | null;
8789
installResult: { kind: "success" | "error"; action: StartupInstallAction; repair?: boolean; detail?: string } | null;
8890
onInstall: (action: StartupInstallAction, opts?: { repair?: boolean }) => void;
8991
}) {
9092
const { t } = useI18n();
91-
const serviceNeedsRepair = data.serviceSupported && data.serviceInstalled && !data.serviceViable;
93+
// Repair only rewrites stale assets — conflict/disabled need uninstall/reinstall, not repair.
94+
const serviceNeedsRepair = data.serviceSupported && data.serviceInstalled && data.serviceStale && !data.serviceConflict;
9295
const shimNeedsRepair = data.shimInstalled && !data.shimHealthy;
96+
const actionsDisabled = installBusy !== null || failed || loading;
9397

9498
return (
9599
<section className="panel startup-details">
@@ -106,12 +110,12 @@ export function StartupDetailsSection({
106110
no={t(data.serviceConflict ? "startup.conflict" : data.serviceStale ? "startup.stale" : data.serviceInstalled ? "startup.unhealthy" : data.serviceSupported ? "startup.notInstalled" : "startup.unsupported")}
107111
/>
108112
{data.serviceSupported && !data.serviceInstalled && (
109-
<button type="button" className="btn btn-primary btn-sm" aria-label={`${t("startup.service")} - ${t("startup.install")}`} disabled={installBusy !== null || failed} onClick={() => onInstall("install-service")}>
113+
<button type="button" className="btn btn-primary btn-sm" aria-label={`${t("startup.service")} - ${t("startup.install")}`} disabled={actionsDisabled} onClick={() => onInstall("install-service")}>
110114
{t(installBusy === "install-service" ? "startup.installing" : "startup.install")}
111115
</button>
112116
)}
113117
{serviceNeedsRepair && (
114-
<button type="button" className="btn btn-primary btn-sm" aria-label={`${t("startup.service")} - ${t("startup.repair")}`} disabled={installBusy !== null || failed} onClick={() => onInstall("install-service", { repair: true })}>
118+
<button type="button" className="btn btn-primary btn-sm" aria-label={`${t("startup.service")} - ${t("startup.repair")}`} disabled={actionsDisabled} onClick={() => onInstall("install-service", { repair: true })}>
115119
{t(installBusy === "install-service" ? "startup.repairing" : "startup.repair")}
116120
</button>
117121
)}
@@ -128,12 +132,12 @@ export function StartupDetailsSection({
128132
: "startup.notInstalled")}
129133
/>
130134
{!data.shimInstalled && (
131-
<button type="button" className="btn btn-primary btn-sm" aria-label={`${t("startup.shim")} - ${t("startup.install")}`} disabled={installBusy !== null || failed} onClick={() => onInstall("install-shim")}>
135+
<button type="button" className="btn btn-primary btn-sm" aria-label={`${t("startup.shim")} - ${t("startup.install")}`} disabled={actionsDisabled} onClick={() => onInstall("install-shim")}>
132136
{t(installBusy === "install-shim" ? "startup.installing" : "startup.install")}
133137
</button>
134138
)}
135139
{shimNeedsRepair && (
136-
<button type="button" className="btn btn-primary btn-sm" aria-label={`${t("startup.shim")} - ${t("startup.repair")}`} disabled={installBusy !== null || failed} onClick={() => onInstall("install-shim", { repair: true })}>
140+
<button type="button" className="btn btn-primary btn-sm" aria-label={`${t("startup.shim")} - ${t("startup.repair")}`} disabled={actionsDisabled} onClick={() => onInstall("install-shim", { repair: true })}>
137141
{t(installBusy === "install-shim" ? "startup.repairing" : "startup.repair")}
138142
</button>
139143
)}

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

Lines changed: 21 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -64,20 +64,8 @@ type CachedOverview = {
6464

6565
type MaMode = "v1" | "default" | "v2";
6666

67-
function readCachedControls(apiBase: string): CachedControls | null {
68-
try {
69-
const raw = sessionStorage.getItem(`${CONTROLS_CACHE_PREFIX}${apiBase}`);
70-
if (!raw) return null;
71-
return JSON.parse(raw) as CachedControls;
72-
} catch {
73-
return null;
74-
}
75-
}
76-
77-
function writeCachedControls(apiBase: string, value: CachedControls) {
78-
try {
79-
sessionStorage.setItem(`${CONTROLS_CACHE_PREFIX}${apiBase}`, JSON.stringify(value));
80-
} catch { /* private mode / quota */ }
67+
function controlsCacheKey(apiBase: string): string {
68+
return `${CONTROLS_CACHE_PREFIX}${apiBase}`;
8169
}
8270

8371
export function useDashboardData(apiBase: string) {
@@ -86,7 +74,10 @@ export function useDashboardData(apiBase: string) {
8674
const [selectedSection, setSelectedSection] = useState<DashboardSection>(readDashboardSectionFromHash);
8775
const [modelQuery, setModelQuery] = useState("");
8876
const [expandedProviders, setExpandedProviders] = useState<Set<string>>(new Set());
89-
const cachedControls = useMemo(() => readCachedControls(apiBase), [apiBase]);
77+
const cachedControls = useMemo(
78+
() => readSessionListCache<CachedControls>(controlsCacheKey(apiBase)),
79+
[apiBase],
80+
);
9081
const cachedOverview = useMemo(
9182
() => readSessionListCache<CachedOverview>(`${OVERVIEW_CACHE_PREFIX}${apiBase}`),
9283
[apiBase],
@@ -281,12 +272,12 @@ export function useDashboardData(apiBase: string) {
281272
if (!data) return;
282273
if (data.health) {
283274
setHealth(data.health);
275+
setProviders(data.providers);
284276
writeSessionListCache(`${OVERVIEW_CACHE_PREFIX}${apiBase}`, {
285277
health: data.health,
286278
providers: data.providers,
287279
});
288280
}
289-
setProviders(data.providers);
290281
setError(data.error);
291282
}, [overviewPoll.data, apiBase]);
292283

@@ -318,19 +309,19 @@ export function useDashboardData(apiBase: string) {
318309
const data = sidecarPoll.data;
319310
if (!data) return;
320311
setSidecar(data.sidecar);
321-
setShadowCall(data.shadowCall);
322-
const prev = readCachedControls(apiBase) ?? {};
323-
writeCachedControls(apiBase, {
312+
if (data.shadowCall !== undefined) setShadowCall(data.shadowCall);
313+
const prev = readSessionListCache<CachedControls>(controlsCacheKey(apiBase)) ?? {};
314+
writeSessionListCache(controlsCacheKey(apiBase), {
324315
...prev,
325316
sidecar: data.sidecar,
326-
shadowCall: data.shadowCall,
317+
...(data.shadowCall !== undefined ? { shadowCall: data.shadowCall } : {}),
327318
});
328319
}, [sidecarPoll.data, apiBase]);
329320

330321
useEffect(() => {
331322
const data = settingsPoll.data;
332323
if (!data) return;
333-
if (data.settings) setSettings(data.settings);
324+
if (data.settings !== undefined) setSettings(data.settings);
334325
// Latest-wins: only seed from settings when no newer dedicated probe has committed
335326
// while this settings poll was in flight. Always merge against the live ref.
336327
if (
@@ -342,11 +333,13 @@ export function useDashboardData(apiBase: string) {
342333
startupHealthRef.current = merged;
343334
if (merged) writeSessionListCache(`${STARTUP_CACHE_PREFIX}${apiBase}`, merged);
344335
}
345-
const prev = readCachedControls(apiBase) ?? {};
346-
writeCachedControls(apiBase, {
347-
...prev,
348-
settings: data.settings ?? undefined,
349-
});
336+
if (data.settings !== undefined) {
337+
const prev = readSessionListCache<CachedControls>(controlsCacheKey(apiBase)) ?? {};
338+
writeSessionListCache(controlsCacheKey(apiBase), {
339+
...prev,
340+
settings: data.settings,
341+
});
342+
}
350343
}, [settingsPoll.data, apiBase]);
351344

352345
useEffect(() => {
@@ -455,8 +448,8 @@ export function useDashboardData(apiBase: string) {
455448
});
456449
const data = await requireJson<SidecarData>(res, "save failed");
457450
setSidecar({ webSearch: data.webSearch, vision: data.vision });
458-
const prev = readCachedControls(apiBase) ?? {};
459-
writeCachedControls(apiBase, {
451+
const prev = readSessionListCache<CachedControls>(controlsCacheKey(apiBase)) ?? {};
452+
writeSessionListCache(controlsCacheKey(apiBase), {
460453
...prev,
461454
sidecar: { webSearch: data.webSearch, vision: data.vision },
462455
});

gui/src/pages/use-providers-fetch.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export function useProvidersFetch({
4343
const provs: string[] = provData?.providers ?? [];
4444
setOauthProviders(provs);
4545
const oauthEntries = await Promise.all(provs.map(async p => {
46-
const sRes = await fetch(`${apiBase}/api/oauth/status?provider=${p}`).catch(() => null);
46+
const sRes = await fetch(`${apiBase}/api/oauth/status?provider=${encodeURIComponent(p)}`).catch(() => null);
4747
const s = sRes ? (await readJsonIfOk<OAuthStatus>(sRes) ?? { loggedIn: false }) : { loggedIn: false };
4848
return [p, s] as const;
4949
}));

gui/src/styles.css

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,6 @@
1616
@import "./styles-combos-workspace.css";
1717
@import "./styles-models-workspace.css";
1818
@import "./styles-dashboard-workspace.css";
19-
@import "./styles-storage-workspace.css";
20-
@import "./styles-subagents-workspace.css";
21-
@import "./styles-usage-workspace.css";
22-
@import "./styles-claudecode-workspace.css";
23-
@import "./styles-apikeys-workspace.css";
2419

2520
:root {
2621
/* default: follow the OS; [data-theme] below pins color-scheme so light-dark() obeys it */
@@ -1092,10 +1087,7 @@ dialog.modal-overlay::backdrop {
10921087
.quota-reset-time { font-size: var(--text-caption); color: var(--muted); white-space: nowrap; }
10931088
.quota-reset-time { font-family: var(--font-code); }
10941089
.bar { height: 5px; background: var(--raised); border-radius: var(--radius-2xs); overflow: hidden; min-width: 0; }
1095-
<<<<<<< HEAD
1096-
=======
10971090
/* Full-width fill scaled on X — avoids layout thrash from animating width. */
1098-
>>>>>>> 6cbe980c (perf(gui): progressive dashboard paint and Startup safety CLS)
10991091
.bar-fill {
11001092
height: 100%;
11011093
width: 100%;
@@ -1386,7 +1378,6 @@ dialog.modal-overlay::backdrop {
13861378
flex: 1 1 auto;
13871379
min-width: 0;
13881380
overflow-wrap: anywhere;
1389-
word-break: break-word;
13901381
}
13911382
.startup-runtime-notice__fix .btn {
13921383
flex: 0 0 auto;

gui/tests/collapse-store.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,11 +75,11 @@ test("toggleInSet adds and removes without mutating its input", () => {
7575
expect(original.has("opus")).toBe(true);
7676
});
7777

78-
test("every family folds by default", () => {
78+
test("empty families fold by default", () => {
7979
expect([...defaultCollapsedFamilies({ opus: 23, fable: 0, sonnet: 0, haiku: 0 })].toSorted())
80-
.toEqual(["fable", "haiku", "opus", "sonnet"]);
80+
.toEqual(["fable", "haiku", "sonnet"]);
8181
expect([...defaultCollapsedFamilies({ opus: 12, fable: 0, sonnet: 4, haiku: 0 })].toSorted())
82-
.toEqual(["fable", "haiku", "opus", "sonnet"]);
82+
.toEqual(["fable", "haiku"]);
8383
expect([...defaultCollapsedFamilies({ opus: 1, fable: 1, sonnet: 1, haiku: 1 })].toSorted())
84-
.toEqual(["fable", "haiku", "opus", "sonnet"]);
84+
.toEqual([]);
8585
});

src/server/management/config-routes.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -229,11 +229,11 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
229229

230230
if (url.pathname === "/api/sync" && req.method === "POST") {
231231
const { syncModelsToCodex } = await import("../../codex/sync");
232+
const { attachStaleAppServerHint } = await import("../../codex/app-server-processes");
232233
const result = await syncModelsToCodex(undefined, config, null);
233234
return jsonResponse({
234-
...result,
235+
...attachStaleAppServerHint(result),
235236
...(result.ok ? {} : { error: result.message }),
236-
staleAppServerHint: "If Codex App still shows an older model list, restart its long-lived app-server process after sync.",
237237
}, result.ok ? 200 : 500);
238238
}
239239

0 commit comments

Comments
 (0)