From c8a789c04a5ab1b05bc6d5f4e3950ffe5df549a4 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 17 Jul 2026 05:50:16 +0200 Subject: [PATCH 1/4] chore: add React Doctor for GUI (local + advisory CI) Wire doctor scripts and changed-scope pre-push when gui/ changes; keep CI advisory via react-doctor.yml scanning gui/. --- .github/workflows/react-doctor.yml | 54 +++++++++++++++++++++++++ gui/README.md | 24 +++++++++++ gui/doctor.config.json | 9 +++++ gui/package.json | 2 + package.json | 5 ++- scripts/doctor-gui-if-changed.ts | 65 ++++++++++++++++++++++++++++++ scripts/setup-hooks.ts | 6 +-- 7 files changed, 161 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/react-doctor.yml create mode 100644 gui/doctor.config.json create mode 100644 scripts/doctor-gui-if-changed.ts diff --git a/.github/workflows/react-doctor.yml b/.github/workflows/react-doctor.yml new file mode 100644 index 00000000..98086273 --- /dev/null +++ b/.github/workflows/react-doctor.yml @@ -0,0 +1,54 @@ +# React Doctor — finds security, performance, correctness, accessibility, +# bundle-size, and architecture issues in React codebases. +# +# Docs: https://www.react.doctor/ci +# Source: https://github.com/millionco/react-doctor + +name: React Doctor + +on: + # Scans the PR's changed files and posts a sticky summary comment listing only the new issues introduced relative to the merge base of the target branch. + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + # Scans `main` on every push to track the health-score trend and catch regressions that slipped past PR review. + push: + branches: ["main"] + +permissions: + contents: read + pull-requests: write + issues: write + statuses: write + +# Cancels any in-flight scan for the same PR (or branch, on push) the moment a new commit arrives, so reviewers only ever see the latest run. +concurrency: + group: react-doctor-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + react-doctor: + runs-on: ubuntu-latest + steps: + # fetch-depth: 0 gives React Doctor the full git history it needs to find the merge base with the target branch. Without it a shallow checkout has no merge base, so PR runs can't compare against the base and fall back to reporting every issue in the changed files (pre-existing ones included) instead of only the ones the PR introduced. + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - uses: millionco/react-doctor@v2 + with: + directory: gui + # Advisory by default: React Doctor reports findings on every PR — a + # sticky summary comment, inline review comments, and a commit status + # with the health score — but never fails the check, so it won't red-X + # a teammate's PR on day one. When your team trusts the signal, graduate + # the gate: uncomment the block below and set blocking to "error" (fail + # on new error-severity findings) or "warning" (fail on any finding). + # Full reference: https://www.react.doctor/ci + # with: + # blocking: error # Gate level: "none" (advisory, the default) | "warning" | "error" + # scope: full # On PRs, scan the whole project instead of just changed files + # comment: false # Disable the sticky PR summary comment + # review-comments: false # Disable inline review comments on changed lines + # commit-status: false # Disable the commit status (score + counts, links to the run) + # version: "0.4.0" # Pin to a specific react-doctor version instead of "latest" + # project: "web,admin" # In a monorepo, scan specific workspace project(s) diff --git a/gui/README.md b/gui/README.md index 3e2b6ced..667c170a 100644 --- a/gui/README.md +++ b/gui/README.md @@ -28,3 +28,27 @@ bun run build:gui That command installs/builds this dashboard and copies the production assets into the package layout used by `ocx gui`. + +## Lint and React Doctor + +```bash +cd gui +bun run lint # ESLint — hard local/CI gate (`GUI lint` in CI) +bun run doctor # React Doctor vs origin/main (changed-scope regressions) +bun run doctor:full # Full-project React Doctor scan +``` + +From the repo root: + +```bash +bun run doctor:gui # same as gui doctor +bun run doctor:gui:full +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`) | Extra React health check. Pre-push runs it only if `gui/` changed. CI workflow is advisory (does not fail the PR) | + +Fix ESLint errors first. Use `doctor` / `doctor:full` for deeper React triage. diff --git a/gui/doctor.config.json b/gui/doctor.config.json new file mode 100644 index 00000000..974a0078 --- /dev/null +++ b/gui/doctor.config.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://react.doctor/schema/config.json", + "scope": "changed", + "base": "origin/main", + "blocking": "error", + "ignore": { + "files": ["dist/**", "node_modules/**"] + } +} diff --git a/gui/package.json b/gui/package.json index f3df2de4..8920c7d3 100644 --- a/gui/package.json +++ b/gui/package.json @@ -8,6 +8,8 @@ "build": "tsc -b && vite build", "lint": "eslint .", "lint:i18n": "eslint src/pages src/components src/App.tsx src/ui.tsx", + "doctor": "npx --yes react-doctor@latest --verbose --scope changed --base origin/main --no-telemetry", + "doctor:full": "npx --yes react-doctor@latest --verbose --scope full --no-telemetry", "preview": "vite preview" }, "dependencies": { diff --git a/package.json b/package.json index 62fa0918..dd1fb201 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,10 @@ "prepublishOnly": "bun run typecheck && bun run build:gui", "release": "bun scripts/release.ts", "release:watch": "bun scripts/release.ts watch", - "prepush": "bun run typecheck && bun run test && bun run privacy:scan", + "prepush": "bun run typecheck && bun run test && bun run privacy:scan && bun run doctor:gui:if-changed", + "doctor:gui": "cd gui && bun run doctor", + "doctor:gui:full": "cd gui && bun run doctor:full", + "doctor:gui:if-changed": "bun scripts/doctor-gui-if-changed.ts", "setup:hooks": "bun scripts/setup-hooks.ts" }, "dependencies": { diff --git a/scripts/doctor-gui-if-changed.ts b/scripts/doctor-gui-if-changed.ts new file mode 100644 index 00000000..253a1169 --- /dev/null +++ b/scripts/doctor-gui-if-changed.ts @@ -0,0 +1,65 @@ +/** + * Run React Doctor in gui/ when this push includes gui/ changes. + * Used by `bun run prepush`. Skip with: git push --no-verify + */ +import { execFileSync } from "node:child_process"; +import { join, resolve } from "node:path"; + +const repoRoot = resolve(import.meta.dirname, ".."); +const guiDir = join(repoRoot, "gui"); + +function hasRef(ref: string): boolean { + try { + execFileSync("git", ["rev-parse", "--verify", ref], { + cwd: repoRoot, + stdio: "ignore", + }); + return true; + } catch { + return false; + } +} + +function diffNames(range: string): string[] { + try { + return execFileSync("git", ["diff", "--name-only", range], { + cwd: repoRoot, + encoding: "utf8", + }) + .split(/\r?\n/) + .map(line => line.trim()) + .filter(Boolean); + } catch { + return []; + } +} + +function guiPathsChanged(): boolean { + let range: string | null = null; + if (hasRef("@{u}")) { + range = "@{u}...HEAD"; + } else if (hasRef("origin/main")) { + range = "origin/main...HEAD"; + } else if (hasRef("main")) { + range = "main...HEAD"; + } + + const files = range ? diffNames(range) : []; + if (files.length === 0 && !range) { + // No useful base — run doctor so GUI pushes still get a check. + return true; + } + return files.some(f => f === "gui" || f.startsWith("gui/")); +} + +if (!guiPathsChanged()) { + console.log("doctor:gui: skip (no gui/ changes in push range)"); + process.exit(0); +} + +console.log("doctor:gui: gui/ changed — running React Doctor (scope=changed)"); +execFileSync("bun", ["run", "doctor"], { + cwd: guiDir, + stdio: "inherit", + env: { ...process.env, npm_config_yes: "true" }, +}); diff --git a/scripts/setup-hooks.ts b/scripts/setup-hooks.ts index 350881fa..a3e122f9 100644 --- a/scripts/setup-hooks.ts +++ b/scripts/setup-hooks.ts @@ -2,8 +2,8 @@ * Sets up the git pre-push hook for local development. * Run once after cloning: bun run setup:hooks * - * The hook runs `bun run prepush` (typecheck + tests + privacy scan) before every - * push — the typecheck/unit-test/privacy-scan portions of the CI gate. + * The hook runs `bun run prepush` (typecheck + tests + privacy scan + React Doctor + * when `gui/` changed) before every push — the local portion of the CI gate. * * To skip in an emergency: git push --no-verify */ @@ -58,5 +58,5 @@ try { // Windows: Git for Windows calls sh.exe directly, executable bit not required. } -console.log(`pre-push hook installed at ${dest}. Runs typecheck + tests + privacy scan before every push.`); +console.log(`pre-push hook installed at ${dest}. Runs typecheck + tests + privacy scan (+ React Doctor when gui/ changed) before every push.`); console.log("Skip in an emergency with: git push --no-verify"); From 1d946b5db6620568135f7473241f8c08eb2cc0a1 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 17 Jul 2026 06:30:07 +0200 Subject: [PATCH 2/4] fix: clear React Doctor findings in GUI and src Resolve GUI diagnostics (a11y, dialogs, TanStack Query, etc.) and src performance/security findings so full-repo doctor scans report clean. --- gui/bun.lock | 5 + gui/eslint.config.js | 3 +- gui/package.json | 1 + gui/src/App.tsx | 61 +- gui/src/api.ts | 15 +- gui/src/components/AddCodexAccountModal.tsx | 166 +- gui/src/components/AddProviderModal.tsx | 778 +++++---- gui/src/components/QuotaBars.tsx | 14 +- gui/src/i18n/provider.tsx | 10 +- gui/src/main.tsx | 19 +- gui/src/model-display.ts | 4 - gui/src/pages/ApiKeys.tsx | 22 +- gui/src/pages/ClaudeCode.tsx | 424 +++-- gui/src/pages/CodexAuth.tsx | 286 ++-- gui/src/pages/Dashboard.tsx | 1607 ++++++++++++------- gui/src/pages/Debug.tsx | 446 ++--- gui/src/pages/Logs.tsx | 121 +- gui/src/pages/Models.tsx | 456 ++++-- gui/src/pages/Providers.tsx | 892 ++++++---- gui/src/pages/Subagents.tsx | 34 +- gui/src/pages/Usage.tsx | 603 ++++--- gui/src/styles.css | 78 + src/adapters/anthropic-image-normalize.ts | 15 +- src/adapters/anthropic.ts | 16 +- src/adapters/cursor/discovery.ts | 6 +- src/adapters/cursor/mcp-config.ts | 12 +- src/adapters/cursor/mcp-manager.ts | 17 +- src/adapters/cursor/native-exec-fs.ts | 6 +- src/adapters/cursor/tool-definitions.ts | 11 +- src/adapters/cursor/transport-retry.ts | 6 +- src/adapters/kiro-tool-fallback.ts | 11 +- src/adapters/kiro.ts | 39 +- src/adapters/openai-chat.ts | 11 +- src/adapters/openai-responses.ts | 14 +- src/adapters/tool-catalog-nudge.ts | 6 +- src/claude/gateway-cache.ts | 9 +- src/claude/inbound.ts | 19 +- src/cli/claude.ts | 21 +- src/cli/proxy-bootstrap.ts | 22 + src/codex/auth-api.ts | 32 +- src/codex/catalog.ts | 86 +- src/codex/project-config-warnings.ts | 2 +- src/codex/routing.ts | 12 +- src/config.ts | 8 +- src/oauth/google-antigravity.ts | 16 +- src/oauth/index.ts | 4 +- src/oauth/token-guardian.ts | 14 +- src/providers/derive.ts | 14 +- src/server/management-api.ts | 34 +- src/types.ts | 2 +- src/update/index.ts | 23 + src/update/job.ts | 5 +- src/web-search/loop.ts | 2 +- tests/claude-inbound.test.ts | 2 +- tests/desktop-3p.test.ts | 8 +- 55 files changed, 4054 insertions(+), 2496 deletions(-) create mode 100644 src/cli/proxy-bootstrap.ts diff --git a/gui/bun.lock b/gui/bun.lock index 11c7b846..abd679cf 100644 --- a/gui/bun.lock +++ b/gui/bun.lock @@ -5,6 +5,7 @@ "": { "name": "gui", "dependencies": { + "@tanstack/react-query": "^5.101.2", "@tanstack/react-virtual": "^3.14.5", "react": "^19.2.7", "react-dom": "^19.2.7", @@ -136,6 +137,10 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "@tanstack/query-core": ["@tanstack/query-core@5.101.2", "", {}, "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw=="], + + "@tanstack/react-query": ["@tanstack/react-query@5.101.2", "", { "dependencies": { "@tanstack/query-core": "5.101.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.5", "", { "dependencies": { "@tanstack/virtual-core": "3.17.3" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4EKRXh7zBLkbKbFmG3AUVkircuHd+7OdT1pocJSepxtfBd3qnrJgJ5rtPkRYyo9fmyVb2+pI2xPy5oYvMLQy6A=="], "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.3", "", {}, "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw=="], diff --git a/gui/eslint.config.js b/gui/eslint.config.js index 4d405488..a8d0cf95 100644 --- a/gui/eslint.config.js +++ b/gui/eslint.config.js @@ -5,8 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh' import tseslint from 'typescript-eslint' import { defineConfig, globalIgnores } from 'eslint/config' import { I18N_DATA_FILES, I18N_UI_FILES } from './.eslint/i18n-file-groups.ts' - -const localI18nPlugin = (await import(new URL('./.eslint/local-i18n-plugin.ts', import.meta.url).href)).default +import localI18nPlugin from './.eslint/local-i18n-plugin.ts' export default defineConfig([ globalIgnores([ diff --git a/gui/package.json b/gui/package.json index 8920c7d3..9f2b474c 100644 --- a/gui/package.json +++ b/gui/package.json @@ -13,6 +13,7 @@ "preview": "vite preview" }, "dependencies": { + "@tanstack/react-query": "^5.101.2", "@tanstack/react-virtual": "^3.14.5", "react": "^19.2.7", "react-dom": "^19.2.7" diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 5562fc93..d25f46e6 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -1,3 +1,4 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useRef, useState } from "react"; import Dashboard from "./pages/Dashboard"; import Providers from "./pages/Providers"; @@ -10,7 +11,7 @@ import CodexAuth from "./pages/CodexAuth"; import ApiKeys from "./pages/ApiKeys"; import ClaudeCode from "./pages/ClaudeCode"; import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconTerminal, IconActivity, IconKey, IconGithub, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconSparkle, IconX } from "./icons"; -import { useI18n, useT, LOCALES, type Locale, type TKey } from "./i18n"; +import { useI18n, useT, LOCALES, type Locale, type TKey } from "./i18n/shared"; import { Select } from "./ui"; import { installApiAuthFetch } from "./api"; @@ -59,7 +60,7 @@ function readStoredTheme(): Theme { export default function App() { const [page, setPageState] = useState(readPageFromHash); const [theme, setTheme] = useState(readStoredTheme); - const [runtimeVersion, setRuntimeVersion] = useState(null); + const queryClient = useQueryClient(); const { locale, setLocale } = useI18n(); const t = useT(); @@ -89,30 +90,23 @@ export default function App() { else { el.setAttribute("data-theme", theme); localStorage.setItem(THEME_KEY, theme); } }, [theme]); - useEffect(() => { - let cancelled = false; - const fetchRuntimeVersion = async () => { - try { - const res = await fetch(`${API_BASE}/healthz`); - if (!res.ok) return; - const version = readRuntimeVersion(await res.json()); - if (!cancelled && version) setRuntimeVersion(version); - } catch { - // Keep the build-time fallback when the proxy is unavailable. - } - }; - fetchRuntimeVersion(); - const interval = setInterval(fetchRuntimeVersion, 30000); - return () => { cancelled = true; clearInterval(interval); }; - }, []); + const { data: healthData } = useQuery({ + queryKey: ["healthz", API_BASE], + queryFn: async () => { + const res = await fetch(`${API_BASE}/healthz`); + if (!res.ok) throw new Error("health check failed"); + return res.json(); + }, + refetchInterval: 30_000, + retry: false, + }); const cycleTheme = () => setTheme(t => (t === "light" ? "dark" : t === "dark" ? "system" : "light")); const ThemeIcon = THEME_ICON[theme]; - const displayedVersion = runtimeVersion ?? __APP_VERSION__; + const displayedVersion = readRuntimeVersion(healthData) ?? __APP_VERSION__; const [stopping, setStopping] = useState(false); // Sidebar "Claude ON" toggle — literal label in every locale (product name). - const [claudeEnabled, setClaudeEnabled] = useState(null); useEffect(() => { if (!navOpen) return; @@ -142,28 +136,31 @@ export default function App() { return () => mq.removeEventListener("change", onChange); }, []); - useEffect(() => { - let cancelled = false; - fetch(`${API_BASE}/api/claude-code`) - .then(res => res.json()) - .then(d => { if (!cancelled && typeof d.enabled === "boolean") setClaudeEnabled(d.enabled); }) - .catch(() => { /* toggle stays hidden until the API answers */ }); - return () => { cancelled = true; }; - }, []); + const { data: claudeData } = useQuery({ + queryKey: ["claude-code", API_BASE], + queryFn: async () => { + const res = await fetch(`${API_BASE}/api/claude-code`); + if (!res.ok) throw new Error("claude-code fetch failed"); + return res.json() as Promise<{ enabled?: boolean }>; + }, + retry: false, + }); + const claudeEnabled = + claudeData !== undefined && typeof claudeData.enabled === "boolean" ? claudeData.enabled : null; const toggleClaude = async () => { if (claudeEnabled === null) return; const next = !claudeEnabled; - setClaudeEnabled(next); // optimistic + queryClient.setQueryData(["claude-code", API_BASE], { enabled: next }); try { const res = await fetch(`${API_BASE}/api/claude-code`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ enabled: next }), }); - if (!res.ok) setClaudeEnabled(!next); + if (!res.ok) queryClient.setQueryData(["claude-code", API_BASE], { enabled: !next }); } catch { - setClaudeEnabled(!next); + queryClient.setQueryData(["claude-code", API_BASE], { enabled: !next }); } }; const handleStop = async () => { @@ -206,7 +203,7 @@ export default function App() {