Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/react-doctor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ jobs:
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.2"
version: "0.9.3"
# Fail the job on any finding (errors or warnings).
blocking: warning
comment: false
Expand Down
2 changes: 1 addition & 1 deletion gui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.2 (`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.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 |

Fix ESLint errors first. Use `doctor` / `doctor:full` for deeper React triage.
4 changes: 2 additions & 2 deletions gui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.2 --verbose --scope changed --base origin/main --no-telemetry",
"doctor:full": "npx --yes react-doctor@0.9.2 --verbose --scope full --no-telemetry",
"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",
"preview": "vite preview"
},
"dependencies": {
Expand Down
2 changes: 1 addition & 1 deletion gui/src/components/data-surface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type { CSSProperties, ReactNode } from "react";
* Lets a page mirror its ready geometry without exposing placeholder values to assistive
* technology. The surrounding skeleton owns the single announced sentence.
*/
export function DataSurfaceSkeletonBlock({
function DataSurfaceSkeletonBlock({
className,
style,
}: {
Expand Down
20 changes: 11 additions & 9 deletions gui/src/hooks/useCodexAccountPool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,8 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou
// Pause leases live in a ref: pausing must not re-render, and the effect below reads
// the live set rather than a captured snapshot.
const [pauseCount, setPauseCount] = useState(0);
const pauseTokensRef = useRef<Set<PauseToken>>(new Set());
const pauseTokensRef = useRef<Set<PauseToken> | null>(null);
if (pauseTokensRef.current === null) pauseTokensRef.current = new Set();
// Which apiBase this instance has already kicked its initial load for. StrictMode double-invokes
// the mount effect, and the deferred load is deliberately uncancellable, so the guard has to live
// here rather than in the effect's cleanup.
Expand All @@ -119,7 +120,8 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou
// Set by switchAccount so a background load already in flight cannot roll the active
// id back to a value the server had not yet committed when that request was issued.
const pendingActiveIdRef = useRef<{ id: string | null } | null>(null);
const observersRef = useRef<Set<CodexAccountLoadObserver>>(new Set());
const observersRef = useRef<Set<CodexAccountLoadObserver> | null>(null);
if (observersRef.current === null) observersRef.current = new Set();
// Last /active payload an actual read returned. Surfaces that mount after a
// load already finished read it to seed their UI instead of waiting a poll interval.
const lastActiveRef = useRef<{ value: unknown } | null>(null);
Expand All @@ -131,13 +133,13 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou
const pauseMutationRef = useRef<"bulk" | { accountId: string } | null>(null);

const subscribeLoadObserver = useCallback((observer: CodexAccountLoadObserver) => {
observersRef.current.add(observer);
observersRef.current!.add(observer);
// Subscribing stays silent. `acceptActiveRead` means "a read that started at this
// revision came back", and useCodexAutoSwitch / CodexPoolStrategySetting decide their
// editing and saving disposition from that. Synthesising one on subscribe can overwrite
// an in-flight draft or arm a spurious post-save refresh. Late surfaces seed themselves
// from readLastThreshold()/readLastActive(), which apply only while uninitialized.
return () => { observersRef.current.delete(observer); };
return () => { observersRef.current!.delete(observer); };
}, []);

/** Last threshold an actual read returned, or undefined when none has succeeded yet. */
Expand All @@ -156,7 +158,7 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou
// observer snapshot below cannot leave the counter stuck above zero.
try {
// Snapshot subscribers so an unsubscribe mid-flight cannot desync begin/accept pairs.
const observers = [...observersRef.current];
const observers = [...observersRef.current!];
const revisions = new Map<CodexAccountLoadObserver, number>();
for (const observer of observers) revisions.set(observer, observer.beginActiveRead());
// Soft refresh when boxes are already on screen — avoid full-page loading flash.
Expand Down Expand Up @@ -282,14 +284,14 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou

const pauseRefresh = useCallback((): PauseToken => {
const token = {} as PauseToken;
pauseTokensRef.current.add(token);
setPauseCount(pauseTokensRef.current.size);
pauseTokensRef.current!.add(token);
setPauseCount(pauseTokensRef.current!.size);
return token;
}, []);

const resumeRefresh = useCallback((token: PauseToken) => {
if (!pauseTokensRef.current.delete(token)) return;
setPauseCount(pauseTokensRef.current.size);
if (!pauseTokensRef.current!.delete(token)) return;
setPauseCount(pauseTokensRef.current!.size);
}, []);

const switchAccount = useCallback(async (id: string | null) => {
Expand Down
1 change: 0 additions & 1 deletion gui/src/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ export const IconSun = (p: P) => (<svg {...S(p)}><circle cx="12" cy="12" r="4"/>
export const IconMoon = (p: P) => (<svg {...S(p)}><path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8Z"/></svg>);
export const IconMonitor = (p: P) => (<svg {...S(p)}><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>);
export const IconGlobe = (p: P) => (<svg {...S(p)}><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/></svg>);
export const IconSparkle = (p: P) => (<svg {...S(p)}><path d="M12 3v18M5.6 5.6l12.8 12.8M3 12h18M5.6 18.4 18.4 5.6"/></svg>);
/** Crossed arrows — Combos workspace nav / rail marker (load-balance / hop). */
export const IconShuffle = (p: P) => (
<svg {...S(p)}>
Expand Down
9 changes: 5 additions & 4 deletions gui/src/pages/Integrations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ export default function Integrations({ apiBase }: { apiBase: string }) {
const [mounted, setMounted] = useState<ReadonlySet<IntegrationTab>>(
() => new Set([readIntegrationTab()]),
);
const tabRefs = useRef(new Map<IntegrationTab, HTMLButtonElement>());
const tabRefs = useRef<Map<IntegrationTab, HTMLButtonElement> | null>(null);
if (tabRefs.current === null) tabRefs.current = new Map();

/*
* Every tab change goes through here, whether it came from a click or from
Expand Down Expand Up @@ -102,7 +103,7 @@ export default function Integrations({ apiBase }: { apiBase: string }) {
activateTab(next);
if (moveFocus) {
window.requestAnimationFrame(() => {
tabRefs.current.get(next)?.focus({ preventScroll: true });
tabRefs.current!.get(next)?.focus({ preventScroll: true });
});
}
};
Expand Down Expand Up @@ -131,8 +132,8 @@ export default function Integrations({ apiBase }: { apiBase: string }) {
<button
key={definition.id}
ref={node => {
if (node) tabRefs.current.set(definition.id, node);
else tabRefs.current.delete(definition.id);
if (node) tabRefs.current!.set(definition.id, node);
else tabRefs.current!.delete(definition.id);
}}
type="button"
role="tab"
Expand Down
3 changes: 1 addition & 2 deletions gui/src/pages/Providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ export default function Providers({ apiBase }: { apiBase: string }) {
// effect and its deferred load is deliberately uncancellable, so the guard lives here.
const bootstrapKeyRef = useRef<string | null>(null);
const removeBusyRef = useRef(false);
const oauthLoginGenerationRef = useRef<Map<string, number>>(new Map());

const notify = useCallback((msg: string, ok: boolean = true) => {
setStatus(msg);
Expand Down Expand Up @@ -174,7 +173,7 @@ export default function Providers({ apiBase }: { apiBase: string }) {
const bumpModelsRefresh = () => setModelsRefreshToken(n => n + 1);

const { cancelLoginOAuth, loginOAuth, logoutOAuth } = useProvidersOAuth({
apiBase, t, aliveRef, oauthLoginGenerationRef, accountSets,
apiBase, t, aliveRef, accountSets,
setBusy, setStatus, setLoginInfo, setOauthStatus, notify,
fetchConfig, fetchOauth, fetchAccountSets, fetchProviderQuotas, bumpModelsRefresh,
});
Expand Down
31 changes: 16 additions & 15 deletions gui/src/pages/use-providers-oauth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback } from "react";
import { useCallback, useRef } from "react";
import type { TFn } from "../i18n/shared";
import { readJsonIfOk } from "../fetch-json";
import type { OAuthAccount, OAuthStatus } from "./providers-shared";
Expand All @@ -8,7 +8,6 @@ export function useProvidersOAuth({
apiBase,
t,
aliveRef,
oauthLoginGenerationRef,
accountSets,
setBusy,
setStatus,
Expand All @@ -24,7 +23,6 @@ export function useProvidersOAuth({
apiBase: string;
t: TFn;
aliveRef: React.MutableRefObject<boolean>;
oauthLoginGenerationRef: React.MutableRefObject<Map<string, number>>;
accountSets: Record<string, { accounts: OAuthAccount[] }>;
setBusy: React.Dispatch<React.SetStateAction<string | null>>;
setStatus: React.Dispatch<React.SetStateAction<string>>;
Expand All @@ -37,9 +35,12 @@ export function useProvidersOAuth({
fetchProviderQuotas: (refresh?: boolean) => Promise<void>;
bumpModelsRefresh: () => void;
}) {
const oauthLoginGenerationRef = useRef<Map<string, number> | null>(null);
if (oauthLoginGenerationRef.current === null) oauthLoginGenerationRef.current = new Map();

const cancelLoginOAuth = useCallback(async (provider: string) => {
const gen = (oauthLoginGenerationRef.current.get(provider) ?? 0) + 1;
oauthLoginGenerationRef.current.set(provider, gen);
const gen = (oauthLoginGenerationRef.current!.get(provider) ?? 0) + 1;
oauthLoginGenerationRef.current!.set(provider, gen);
try {
await fetch(`${apiBase}/api/oauth/login/cancel`, {
method: "POST",
Expand All @@ -48,16 +49,16 @@ export function useProvidersOAuth({
});
} catch { /* ignore */ }
if (!aliveRef.current) return;
if (oauthLoginGenerationRef.current.get(provider) === gen) {
if (oauthLoginGenerationRef.current!.get(provider) === gen) {
setBusy(current => current === provider ? null : current);
setLoginInfo(current => current?.provider === provider ? null : current);
}
notify(t("prov.loginCancelled", { provider: oauthLabel(provider) }), false);
}, [aliveRef, apiBase, notify, oauthLoginGenerationRef, setBusy, setLoginInfo, t]);
}, [aliveRef, apiBase, notify, setBusy, setLoginInfo, t]);

const loginOAuth = async (provider: string, addAccount = false, accountId?: string) => {
const nextGen = (oauthLoginGenerationRef.current.get(provider) ?? 0) + 1;
oauthLoginGenerationRef.current.set(provider, nextGen);
const nextGen = (oauthLoginGenerationRef.current!.get(provider) ?? 0) + 1;
oauthLoginGenerationRef.current!.set(provider, nextGen);
const generation = nextGen;
const reauthTargetId = accountId?.trim() || undefined;
setBusy(provider);
Expand All @@ -73,7 +74,7 @@ export function useProvidersOAuth({
...(reauthTargetId ? { accountId: reauthTargetId, reauth: true } : {}),
}),
});
if (oauthLoginGenerationRef.current.get(provider) !== generation || !aliveRef.current) return;
if (oauthLoginGenerationRef.current!.get(provider) !== generation || !aliveRef.current) return;
if (!res.ok) {
const data = await res.json().catch(() => ({})) as { error?: string };
notify(data.error || t("prov.loginFailStart", { provider: oauthLabel(provider) }), false);
Expand All @@ -85,9 +86,9 @@ export function useProvidersOAuth({
}
const baselineCount = accountSets[provider]?.accounts.length ?? 0;
let finished = false;
for (let i = 0; i < 150 && aliveRef.current && oauthLoginGenerationRef.current.get(provider) === generation; i++) {
for (let i = 0; i < 150 && aliveRef.current && oauthLoginGenerationRef.current!.get(provider) === generation; i++) {
await new Promise(r => setTimeout(r, 2000));
if (oauthLoginGenerationRef.current.get(provider) !== generation || !aliveRef.current) return;
if (oauthLoginGenerationRef.current!.get(provider) !== generation || !aliveRef.current) return;
const sRes = await fetch(`${apiBase}/api/oauth/status?provider=${provider}`).catch(() => null);
const s: (OAuthStatus & { accounts?: OAuthAccount[] }) | null = sRes
? ((await readJsonIfOk<OAuthStatus & { accounts?: OAuthAccount[] }>(sRes)) ?? null)
Expand Down Expand Up @@ -138,7 +139,7 @@ export function useProvidersOAuth({
break;
}
}
if (!finished && oauthLoginGenerationRef.current.get(provider) === generation && aliveRef.current) {
if (!finished && oauthLoginGenerationRef.current!.get(provider) === generation && aliveRef.current) {
await fetch(`${apiBase}/api/oauth/login/cancel`, {
method: "POST",
headers: { "Content-Type": "application/json" },
Expand All @@ -148,11 +149,11 @@ export function useProvidersOAuth({
setLoginInfo(null);
}
} catch {
if (oauthLoginGenerationRef.current.get(provider) === generation) {
if (oauthLoginGenerationRef.current!.get(provider) === generation) {
notify(t("prov.loginRequestFail", { provider: oauthLabel(provider) }), false);
}
} finally {
if (aliveRef.current && oauthLoginGenerationRef.current.get(provider) === generation) setBusy(null);
if (aliveRef.current && oauthLoginGenerationRef.current!.get(provider) === generation) setBusy(null);
}
};

Expand Down
4 changes: 2 additions & 2 deletions tests/ci-workflows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2349,7 +2349,7 @@ describe("GitHub Actions hardening", () => {
);

// Engine pin: the action wrapper would fetch react-doctor@latest without it.
expect(workflow).toContain('version: "0.9.2"');
expect(workflow).toContain('version: "0.9.3"');

// 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.
Expand All @@ -2371,7 +2371,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.2");
expect(guiPkg).toContain("react-doctor@0.9.3");
expect(guiPkg).not.toContain("react-doctor@latest");
expect(rootPkg).not.toContain("react-doctor@latest");
expect(doctorConfig).toContain('"blocking": "warning"');
Expand Down
Loading