diff --git a/.github/workflows/react-doctor.yml b/.github/workflows/react-doctor.yml
index 23e0a4777a..39ef252336 100644
--- a/.github/workflows/react-doctor.yml
+++ b/.github/workflows/react-doctor.yml
@@ -45,12 +45,12 @@ jobs:
with:
fetch-depth: 0
- - uses: millionco/react-doctor@938008119a288f2fb47c66a69cd9279a21f31784 # v2.2.7
+ - uses: millionco/react-doctor@01820bb4fd4d0a4aebcd8df2b2a143a098649cb2 # v2.2.8
with:
directory: gui
# Pin the npm engine — the action wrapper would otherwise fetch
# react-doctor@latest, silently skewing CI from the local pinned runs.
- version: "0.9.3"
+ version: "0.9.11"
# Fail the job on any finding (errors or warnings).
blocking: warning
comment: false
diff --git a/gui/README.md b/gui/README.md
index d46ca34a5e..10b2209168 100644
--- a/gui/README.md
+++ b/gui/README.md
@@ -49,6 +49,6 @@ bun run setup:hooks # pre-push runs doctor when gui/ changed
| Tool | Role |
|------|------|
| **ESLint** (`bun run lint`) | Hard gate in CI and expected before merge |
-| **React Doctor** (`bun run doctor`) | Gating React health check pinned to react-doctor 0.9.3 (`blocking: warning`). Pre-push runs it only if `gui/` changed and fails the push on findings. The CI workflow fails the job on any finding |
+| **React Doctor** (`bun run doctor`) | Gating React health check pinned to react-doctor 0.9.11 (`blocking: warning`). Pre-push runs it only if `gui/` changed and fails the push on findings. The CI workflow fails the job on any finding |
Fix ESLint errors first. Use `doctor` / `doctor:full` for deeper React triage.
diff --git a/gui/package.json b/gui/package.json
index bc6b9624ef..78876e88e4 100644
--- a/gui/package.json
+++ b/gui/package.json
@@ -9,8 +9,8 @@
"lint": "eslint .",
"test": "bun test tests",
"lint:i18n": "eslint src/pages src/components src/App.tsx src/ui.tsx",
- "doctor": "npx --yes react-doctor@0.9.3 --verbose --scope changed --base origin/main --no-telemetry",
- "doctor:full": "npx --yes react-doctor@0.9.3 --verbose --scope full --no-telemetry",
+ "doctor": "npx --yes react-doctor@0.9.11 --verbose --scope changed --base origin/main --no-telemetry",
+ "doctor:full": "npx --yes react-doctor@0.9.11 --verbose --scope full --no-telemetry",
"preview": "vite preview"
},
"dependencies": {
diff --git a/gui/src/components/provider-workspace/ProviderRail.tsx b/gui/src/components/provider-workspace/ProviderRail.tsx
index af12944219..db6964f8de 100644
--- a/gui/src/components/provider-workspace/ProviderRail.tsx
+++ b/gui/src/components/provider-workspace/ProviderRail.tsx
@@ -66,7 +66,7 @@ export function ProviderIcon({ name, adapter, baseUrl, cls }: {
* presets and custom providers). Hue is derived deterministically from the
* provider id so the same provider always gets the same color.
*/
-export function ProviderFallbackMark({ name, label }: { name: string; label: string }) {
+function ProviderFallbackMark({ name, label }: { name: string; label: string }) {
const hue = [...name].reduce((acc, ch) => acc + ch.charCodeAt(0), 0) % 360;
const initial = (label.trim()[0] ?? name[0] ?? "?").toUpperCase();
return (
diff --git a/gui/src/icons.tsx b/gui/src/icons.tsx
index 2705f679f0..bed8492599 100644
--- a/gui/src/icons.tsx
+++ b/gui/src/icons.tsx
@@ -38,7 +38,6 @@ export const IconKey = (p: P) => ();
export const IconTicket = (p: P) => ();
-export const IconRoute = (p: P) => ();
export const IconLink = (p: P) => ();
export const IconSun = (p: P) => ();
export const IconMoon = (p: P) => ();
diff --git a/gui/src/pages/CodexAuth.tsx b/gui/src/pages/CodexAuth.tsx
index ea71df89b4..b1999f31f4 100644
--- a/gui/src/pages/CodexAuth.tsx
+++ b/gui/src/pages/CodexAuth.tsx
@@ -3,6 +3,7 @@ import { useT } from "../i18n/shared";
import CodexAccountPool from "../components/CodexAccountPool";
import DefaultModeRequestUserInputSetting from "../components/DefaultModeRequestUserInputSetting";
import { codexAccountModeState, type CodexAccountModeState } from "../codex-multi-state";
+import { navigateHash } from "../hash-routing";
import { ensureOpenAiProvider, openAiAccountProviderState, OpenAiEnableError } from "../provider-payload";
import { readSessionListCache, writeSessionListCache } from "../session-list-cache";
@@ -46,7 +47,10 @@ export function OpenAiAccountModeBanner({
)}
{state === "direct" && (
- {t("codexAuth.accountModeDirectDesc")} {t("codexAuth.openProviders")}
+ {t("codexAuth.accountModeDirectDesc")}{" "}
+
)}
{(state === "absent" || state === "disabled") && (
@@ -59,7 +63,10 @@ export function OpenAiAccountModeBanner({
)}
{state === "invalid" && (
- {t("codexAuth.openaiMissing")} {t("codexAuth.openProviders")}
+ {t("codexAuth.openaiMissing")}{" "}
+
)}
diff --git a/gui/src/pages/Startup.tsx b/gui/src/pages/Startup.tsx
index 95b18d3848..8bb03166c0 100644
--- a/gui/src/pages/Startup.tsx
+++ b/gui/src/pages/Startup.tsx
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { IconRefresh } from "../icons";
import { type TFn, useI18n } from "../i18n/shared";
+import { navigateHash } from "../hash-routing";
import { readSessionListCache, writeSessionListCache } from "../session-list-cache";
import { Notice } from "../ui";
import { useDataSurface } from "../data-surface";
@@ -288,7 +289,9 @@ export default function Startup({ apiBase }: { apiBase: string }) {
{t("startup.subtitle")}
-
{t("startup.backToDashboard")}
+
diff --git a/gui/src/pages/dashboard-overview-head.tsx b/gui/src/pages/dashboard-overview-head.tsx
index 31e85eb1a1..05a8530474 100644
--- a/gui/src/pages/dashboard-overview-head.tsx
+++ b/gui/src/pages/dashboard-overview-head.tsx
@@ -2,6 +2,7 @@ import { IconAlert, IconInfo } from "../icons";
import { type TKey, useT } from "../i18n/shared";
import { formatTokens } from "../format-tokens";
import { formatUptime } from "../formatUptime";
+import { navigateHash } from "../hash-routing";
import type { useDashboardData } from "./use-dashboard-data";
type Dash = ReturnType
;
@@ -85,7 +86,7 @@ export function DashboardOverviewHead({
{startupHealth ? (
-
+
+
) : (
diff --git a/gui/src/pages/models-provider-hints.tsx b/gui/src/pages/models-provider-hints.tsx
index 366d25ac2e..4280664359 100644
--- a/gui/src/pages/models-provider-hints.tsx
+++ b/gui/src/pages/models-provider-hints.tsx
@@ -1,5 +1,6 @@
import { IconInfo } from "../icons";
import { useT } from "../i18n/shared";
+import { navigateHash } from "../hash-routing";
import type { ProviderDiscoverySummary } from "../models-groups";
import { discoveryFailureLabel } from "./models-shared";
@@ -22,7 +23,9 @@ export function EmptyProviderHint({
{failed
? `${discoveryFailureLabel(t, failed)} `
: `${t(liveModels ? "models.emptyDiscovery" : "models.emptyDiscoveryDisabled")} `}
-
{t("models.openProviderSettings")}
+
);
diff --git a/gui/src/styles.css b/gui/src/styles.css
index b222f914f4..09b012b970 100644
--- a/gui/src/styles.css
+++ b/gui/src/styles.css
@@ -804,6 +804,8 @@ a.btn, a.btn:hover { text-decoration: none; }
.stat .value.mono { font-family: var(--font-code); font-size: var(--text-subtitle); }
.startup-health-bar {
+ box-sizing: border-box;
+ width: 100%;
min-height: var(--control-lg);
margin: calc(-1 * var(--space-3)) 0 var(--space-6);
padding: 0 var(--space-3);
@@ -813,10 +815,14 @@ a.btn, a.btn:hover { text-decoration: none; }
min-width: 0;
color: var(--muted);
background: var(--hover);
+ border: none;
border-block: 1px solid var(--border-soft);
text-decoration: none;
+ font: inherit;
font-size: var(--text-control);
line-height: var(--leading-ui);
+ text-align: start;
+ cursor: pointer;
transition: background var(--motion-fast), color var(--motion-fast);
}
.startup-health-bar:hover { background: var(--raised); color: var(--text); }
diff --git a/gui/tests/codex-auth-provider-enable.test.tsx b/gui/tests/codex-auth-provider-enable.test.tsx
index bf482c0b6a..216b21edf6 100644
--- a/gui/tests/codex-auth-provider-enable.test.tsx
+++ b/gui/tests/codex-auth-provider-enable.test.tsx
@@ -59,7 +59,8 @@ test("noncanonical disabled OpenAI rows do not offer built-in recovery", () => {
);
expect(html).toContain("The built-in OpenAI provider is not configured.");
- expect(html).toContain('href="#providers"');
+ expect(html).toContain('class="link-btn"');
+ expect(html).toContain("Open Providers");
expect(html).not.toContain("Enable OpenAI");
expect(html).not.toContain("Your OpenAI accounts are still available");
});
diff --git a/gui/tests/models-empty-provider.test.tsx b/gui/tests/models-empty-provider.test.tsx
index bee7f6ef7b..442e96bf17 100644
--- a/gui/tests/models-empty-provider.test.tsx
+++ b/gui/tests/models-empty-provider.test.tsx
@@ -534,7 +534,7 @@ async function withCursorDiscoveryServer
(
test("empty live-discovery provider renders endpoint guidance and a settings link", () => {
const html = renderHint(true, { status: "ok" });
expect(html).toContain("No models were discovered");
- expect(html).toContain('href="#providers"');
+ expect(html).toContain('class="link-btn"');
expect(html).toContain("Open provider settings");
expect(html).not.toContain("Discovery failed");
});
@@ -545,7 +545,7 @@ test("failed HTTP discovery renders an amber status badge and reason", () => {
expect(html).toContain("HTTP 401");
expect(html).toContain('class="badge badge-amber"');
expect(html).toContain('role="status"');
- expect(html).toContain('href="#providers"');
+ expect(html).toContain('class="link-btn"');
});
test("failed discovery renders each server-owned reason without provider detail", () => {
@@ -584,7 +584,7 @@ test("HTTP 401 discovery exposes HTTP status and badge", async () => {
const html = renderHint(true, discovery);
expect(html).toContain("Discovery failed");
expect(html).toContain("HTTP 401");
- expect(html).toContain('href="#providers"');
+ expect(html).toContain('class="link-btn"');
});
test("destination-blocked discovery exposes blocked status and badge", async () => {
@@ -614,7 +614,7 @@ test("destination-blocked discovery exposes blocked status and badge", async ()
const html = renderHint(true, discovery);
expect(html).toContain("Discovery failed");
expect(html).toContain("blocked by the destination policy");
- expect(html).toContain('href="#providers"');
+ expect(html).toContain('class="link-btn"');
});
test("invalid JSON or malformed model data exposes invalid-response status and badge", async () => {
diff --git a/gui/tests/startup-usage-loading-race.test.tsx b/gui/tests/startup-usage-loading-race.test.tsx
index f465af1280..bf90d2487a 100644
--- a/gui/tests/startup-usage-loading-race.test.tsx
+++ b/gui/tests/startup-usage-loading-race.test.tsx
@@ -133,15 +133,17 @@ test("an aborted Startup fetch must not clear loading while its replacement is i
await settle();
expect(container.textContent).toContain("Checking startup protection");
- const refresh = container.querySelector("button.btn");
- expect(refresh?.disabled).toBe(true);
+ const refresh = Array.from(container.querySelectorAll("button.btn"))
+ .find(button => (button.textContent ?? "").includes("Refresh"));
+ expect(refresh).toBeTruthy();
+ expect(refresh!.disabled).toBe(true);
await act(async () => {
gates[1]!.resolve(FRESH);
await Promise.resolve();
});
await waitFor(() => !(container.textContent ?? "").includes("Checking startup protection"));
- expect(refresh?.disabled).toBe(false);
+ expect(refresh!.disabled).toBe(false);
await act(async () => { root.unmount(); });
container.remove();
diff --git a/tests/ci-workflows.test.ts b/tests/ci-workflows.test.ts
index fa6ca5cc76..3fc90d89c7 100644
--- a/tests/ci-workflows.test.ts
+++ b/tests/ci-workflows.test.ts
@@ -4629,13 +4629,13 @@ describe("GitHub Actions hardening", () => {
const workflow = await readText(".github/workflows/react-doctor.yml");
expect(workflow).toContain("actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8");
- expect(workflow).toContain("millionco/react-doctor@938008119a288f2fb47c66a69cd9279a21f31784");
+ expect(workflow).toContain("millionco/react-doctor@01820bb4fd4d0a4aebcd8df2b2a143a098649cb2");
expect(workflow).not.toMatch(
/^\s*-\s+uses:\s+\S+@(?![0-9a-f]{40}(?=[ \t]*(?:#.*)?$))\S+/m,
);
// Engine pin: the action wrapper would fetch react-doctor@latest without it.
- expect(workflow).toContain('version: "0.9.3"');
+ expect(workflow).toContain('version: "0.9.11"');
// Action pin must accept CLI JSON schemaVersion 3 (baseline reports from 0.9.x).
// v2.1.0's ensure-json-report only knew schemas 1–2 and failed every PR scan.
@@ -4657,7 +4657,7 @@ describe("GitHub Actions hardening", () => {
const rootPkg = await readText("package.json");
const doctorConfig = await readText("gui/doctor.config.json");
- expect(guiPkg).toContain("react-doctor@0.9.3");
+ expect(guiPkg).toContain("react-doctor@0.9.11");
expect(guiPkg).not.toContain("react-doctor@latest");
expect(rootPkg).not.toContain("react-doctor@latest");
expect(doctorConfig).toContain('"blocking": "warning"');
diff --git a/tests/codex-retained-root-serialization.test.ts b/tests/codex-retained-root-serialization.test.ts
index b7bebef5d8..a934ce3651 100644
--- a/tests/codex-retained-root-serialization.test.ts
+++ b/tests/codex-retained-root-serialization.test.ts
@@ -479,21 +479,47 @@ test("two processes at the post-approval management seam serialize instead of in
console.log(JSON.stringify({ status: response.status, catalogRefresh: body.catalogRefresh }));
`;
- const children = (["a", "b"] as const).map(marker => Bun.spawn(
- [process.execPath, "--eval", routeScript(marker)],
- { cwd: repoRoot, env: sandbox.env, stdout: "pipe", stderr: "pipe" },
- ));
-
- const results = await Promise.all(children.map(async child => {
- const [exitCode, stdout, stderr] = await Promise.all([
- child.exited,
- new Response(child.stdout).text(),
- new Response(child.stderr).text(),
- ]);
- return { exitCode, stdout, stderr };
- }));
-
- for (const result of results) {
+ const isPreApprovalLoss = (stderr: string): boolean =>
+ stderr.includes("CONFIG_MUTATION_LOCK_UNAVAILABLE")
+ || (stderr.includes("EEXIST") && stderr.includes("createOwnership"))
+ || /database (?:is|table is) locked/i.test(stderr)
+ || stderr.includes("SQLITE_BUSY");
+
+ // On macOS CI both children can still lose the config lock before approval even
+ // after the warm-up — that proves nothing about catalog serialization. Retry
+ // vacuous runs until at least one process reaches the post-approval seam.
+ const attemptDeadline = Date.now() + 20_000;
+ let results: Array<{ exitCode: number; stdout: string; stderr: string }> | undefined;
+ while (Date.now() < attemptDeadline) {
+ for (const marker of ["a", "b"] as const) {
+ rmSync(`${barrier}-${marker}`, { force: true });
+ }
+ writeFileSync(catalogPath, seeded);
+
+ const children = (["a", "b"] as const).map(marker => Bun.spawn(
+ [process.execPath, "--eval", routeScript(marker)],
+ { cwd: repoRoot, env: sandbox.env, stdout: "pipe", stderr: "pipe" },
+ ));
+
+ results = await Promise.all(children.map(async child => {
+ const [exitCode, stdout, stderr] = await Promise.all([
+ child.exited,
+ new Response(child.stdout).text(),
+ new Response(child.stderr).text(),
+ ]);
+ return { exitCode, stdout, stderr };
+ }));
+
+ if (results.some(result => result.exitCode === 0)) break;
+
+ for (const result of results) {
+ expect({ preApproval: isPreApprovalLoss(result.stderr), stderr: result.stderr })
+ .toMatchObject({ preApproval: true });
+ }
+ }
+
+ expect(results).toBeDefined();
+ for (const result of results!) {
// A process can lose a race BEFORE approval and never reach the seam at all.
// The known cases come from `saveConfigPreservingClaudeCode`: the config mutation
// lock is already held, two cold processes create the ownership file at once, or
@@ -508,11 +534,8 @@ test("two processes at the post-approval management seam serialize instead of in
// message as "busy" rather than a database fault, so treating it as a seam failure
// here contradicted the product code and turned ordinary contention into a red build.
if (result.exitCode !== 0) {
- const preApproval = result.stderr.includes("CONFIG_MUTATION_LOCK_UNAVAILABLE")
- || (result.stderr.includes("EEXIST") && result.stderr.includes("createOwnership"))
- || /database (?:is|table is) locked/i.test(result.stderr)
- || result.stderr.includes("SQLITE_BUSY");
- expect({ preApproval, stderr: result.stderr }).toMatchObject({ preApproval: true });
+ expect({ preApproval: isPreApprovalLoss(result.stderr), stderr: result.stderr })
+ .toMatchObject({ preApproval: true });
continue;
}
const parsed = JSON.parse(result.stdout.trim()) as {
@@ -530,12 +553,12 @@ test("two processes at the post-approval management seam serialize instead of in
// At least one process must have gotten through to the seam, or this test would
// be vacuous — two config-lock losers prove nothing about catalog serialization.
- expect(results.some(r => r.exitCode === 0)).toBe(true);
+ expect(results!.some(r => r.exitCode === 0)).toBe(true);
// At least one process must reach a real commit, or the race proves nothing:
// the adapter is total, so a seam that only ever failed would still answer 2xx
// with a typed disposition and satisfy every assertion above.
- const dispositions = results
+ const dispositions = results!
.filter(r => r.exitCode === 0)
.map(r => (JSON.parse(r.stdout.trim()) as { catalogRefresh: { status: string } }).catalogRefresh.status);
expect(dispositions).toContain("committed");