Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .github/workflows/react-doctor.yml
Original file line number Diff line number Diff line change
@@ -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)
24 changes: 24 additions & 0 deletions gui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 5 additions & 0 deletions gui/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions gui/doctor.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"$schema": "https://react.doctor/schema/config.json",
"scope": "changed",
"base": "origin/main",
"blocking": "error",
"ignore": {
"files": ["dist/**", "node_modules/**"]
}
}
3 changes: 1 addition & 2 deletions gui/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
3 changes: 3 additions & 0 deletions gui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@
"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": {
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-virtual": "^3.14.5",
"react": "^19.2.7",
"react-dom": "^19.2.7"
Expand Down
61 changes: 29 additions & 32 deletions gui/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";

Expand Down Expand Up @@ -59,7 +60,7 @@ function readStoredTheme(): Theme {
export default function App() {
const [page, setPageState] = useState<Page>(readPageFromHash);
const [theme, setTheme] = useState<Theme>(readStoredTheme);
const [runtimeVersion, setRuntimeVersion] = useState<string | null>(null);
const queryClient = useQueryClient();
const { locale, setLocale } = useI18n();
const t = useT();

Expand Down Expand Up @@ -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<boolean | null>(null);

useEffect(() => {
if (!navOpen) return;
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -206,7 +203,7 @@ export default function App() {
</div>
<nav>
{NAV.map(({ id, tkey, Icon }) => (
<button key={id} className={`nav-item${page === id ? " active" : ""}`} data-page={id}
<button type="button" key={id} className={`nav-item${page === id ? " active" : ""}`} data-page={id}
onClick={() => { setPageState(id); setNavOpen(false); }}
aria-current={page === id ? "page" : undefined}>
<Icon /> {t(tkey)}
Expand Down
15 changes: 5 additions & 10 deletions gui/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
const TOKEN_KEY = "opencodex-api-token";

let installed = false;
let promptInFlight: Promise<string | null> | null = null;
/** In-memory only — avoids persisting auth tokens in web storage (XSS-readable). */
let memoryToken: string | null = null;

function apiPath(input: RequestInfo | URL): string | null {
try {
Expand All @@ -18,20 +18,15 @@ function needsApiAuth(input: RequestInfo | URL): boolean {
}

function readToken(): string | null {
try {
const token = sessionStorage.getItem(TOKEN_KEY)?.trim();
return token || null;
} catch {
return null;
}
return memoryToken;
}

function storeToken(token: string): void {
try { sessionStorage.setItem(TOKEN_KEY, token); } catch { /* session storage may be disabled */ }
memoryToken = token.trim() || null;
}

function clearToken(): void {
try { sessionStorage.removeItem(TOKEN_KEY); } catch { /* session storage may be disabled */ }
memoryToken = null;
}

function withToken(input: RequestInfo | URL, init: RequestInit | undefined, token: string): [RequestInfo | URL, RequestInit | undefined] {
Expand Down
Loading
Loading