Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
d2f543e
feat(gui): tighten providers workspace layout and model data
Wibias Jul 15, 2026
18024a7
feat(gui): enforce no hardcoded UI strings via ESLint i18n rules
Wibias Jul 15, 2026
2b5f0db
gui: dynamic i18n locale hint and hardcoded snippets in lint
Wibias Jul 15, 2026
a0fbed2
Improvements
Wibias Jul 15, 2026
35e0482
Improvements
Wibias Jul 15, 2026
c8fe921
Improvements
Wibias Jul 15, 2026
4f543d5
merge: integrate upstream/main into providers-workspace-redesign
Wibias Jul 15, 2026
3ebd64c
merge: integrate codex/gui-i18n into providers-workspace-redesign
Wibias Jul 15, 2026
b65614e
gui: i18n provider workspace redesign copy under pws.* keys
Wibias Jul 15, 2026
16a16bc
gui: dedupe provider create CTAs — rail menu, overview content-only
Wibias Jul 15, 2026
b1d3af0
gui: single Add opens catalog; custom stays a modal preset only
Wibias Jul 15, 2026
430e8d1
gui: keep Add and Custom as separate rail actions
Wibias Jul 15, 2026
43162f5
gui: redesign Add provider catalog to match free/paid mock
Wibias Jul 15, 2026
5c5518c
gui: rail Add Provider only, bold Browse card, Accounts tab
Wibias Jul 15, 2026
451c9f1
gui: put Edit JSON and Recently used side by side on overview
Wibias Jul 15, 2026
ebeba37
gui: match Edit JSON and Recently used cards; braces icon
Wibias Jul 15, 2026
5a76676
gui: drop unused IconList import
Wibias Jul 15, 2026
29081b1
gui: equal overview panels, coding stats, equal Free/Paid/Accounts tabs
Wibias Jul 15, 2026
7f996b8
gui: simplify Edit JSON card, neutral Browse border, fixed tab body h…
Wibias Jul 15, 2026
a9cb9dd
gui: remove overview coding stats row (placeholder for later)
Wibias Jul 15, 2026
c0f9125
gui: force equal Free/Paid/Accounts tab widths and fixed body height
Wibias Jul 15, 2026
2e35650
gui: drop browse overflow subtitle; force equal Free/Paid/Accounts ta…
Wibias Jul 15, 2026
b1fce47
gui: remove browse +N more subtitle; equalize Accounts catalog height
Wibias Jul 15, 2026
0849c64
gui: cancel OAuth Waiting for browser on Accounts and close
Wibias Jul 15, 2026
7cac2a5
gui: remove duplicate Quick actions from provider detail overview
Wibias Jul 15, 2026
16d2afd
chore: tighten experimental Cursor bridge provider note
Wibias Jul 15, 2026
98f7689
gui: rename provider detail back button to Provider Overview
Wibias Jul 15, 2026
e07f8c3
gui: empty CTAs, Logins tab, rail badges/brand polish
Wibias Jul 16, 2026
d8036f4
gui: rail model count left of status dot
Wibias Jul 16, 2026
abf9619
gui: flatten provider detail nesting (fewer bordered boxes)
Wibias Jul 16, 2026
dc165a8
gui: drop connection cell box borders (spacing only)
Wibias Jul 16, 2026
6b95eb5
gui: integrate provider detail into page with dividers only
Wibias Jul 16, 2026
45935d5
gui: integrate providers overview with dividers (no cards)
Wibias Jul 16, 2026
5c4f3c1
feat(gui): polish providers workspace and free-tier pricing
Wibias Jul 16, 2026
1615bd7
fix(types): allow local authMode and provider note on OcxProviderConfig
Wibias Jul 16, 2026
dc3beb8
test: align registry parity with NVIDIA freeTier and Cursor note
Wibias Jul 16, 2026
b352c1d
feat(gui): finish providers workspace polish pass
Wibias Jul 16, 2026
323e6bd
feat(gui): polish providers workspace chrome and usage UX
Wibias Jul 16, 2026
82fae6d
merge: integrate upstream/main into providers-workspace-redesign
Wibias Jul 16, 2026
767c9a8
fix(gui): keep 5h quota bars and polish Edit JSON chrome
Wibias Jul 16, 2026
5b1100f
test: stabilize xAI stale-lock replacement race
Wibias Jul 16, 2026
c35f2d4
feat(gui): polish providers workspace copy and auth UX
Wibias Jul 17, 2026
cadcc6b
fix: keep ChatGPT seed from overwriting custom chatgpt providers
Wibias Jul 17, 2026
266a82c
Update registry.ts
Wibias Jul 17, 2026
62cddd8
chore: drop local Playwright output from the PR
Wibias Jul 17, 2026
1201493
test: align Anthropic API-key label with registry rename
Wibias Jul 17, 2026
22d8d01
chore: remove workspace modal screenshot script from the PR
Wibias Jul 17, 2026
3fb6122
chore: remove providers workspace design-qa notes from the PR
Wibias Jul 17, 2026
e0eace7
fix(gui): clear eslint errors in providers workspace
Wibias Jul 17, 2026
e6fc6d5
fix(gui): avoid redeclaring chatgpt when hiding redundant forward row
Wibias Jul 17, 2026
0e81cfa
docs(pr): add providers workspace screenshots for #139
Wibias Jul 17, 2026
593882a
Revert "docs(pr): add providers workspace screenshots for #139"
Wibias Jul 17, 2026
d209dfd
fix(gui): make Providers Overview pane scrollable
Wibias Jul 17, 2026
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,6 @@ devlog/
# Test-generated artifacts
tests/.tmp-*/
.claude/

# Local Playwright / screenshot artifacts
output/
2 changes: 1 addition & 1 deletion gui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"lint:i18n": "eslint src/pages src/components src/App.tsx src/ui.tsx",
"lint:i18n": "eslint src/pages src/components src/App.tsx src/ui.tsx src/provider-workspace-data.ts",
"preview": "vite preview"
},
"dependencies": {
Expand Down
53 changes: 39 additions & 14 deletions gui/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,31 @@
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import Dashboard from "./pages/Dashboard";
import Providers from "./pages/Providers";
import Models from "./pages/Models";
import Subagents from "./pages/Subagents";
import Logs from "./pages/Logs";
import Debug from "./pages/Debug";
import Usage from "./pages/Usage";
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 { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconTerminal, IconActivity, IconGithub, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconSparkle, IconX } from "./icons";
import { useI18n, useT, LOCALES, type Locale, type TKey } from "./i18n";
import { Select } from "./ui";
import { installApiAuthFetch } from "./api";

installApiAuthFetch();

type Page = "dashboard" | "providers" | "models" | "subagents" | "logs" | "debug" | "usage" | "codex-auth" | "api" | "claude";
type Page = "dashboard" | "providers" | "models" | "subagents" | "logs" | "debug" | "usage" | "api" | "claude";
type Theme = "light" | "dark" | "system";

const VALID_PAGES = new Set<Page>(["dashboard", "providers", "models", "subagents", "logs", "debug", "usage", "codex-auth", "api", "claude"]);
const VALID_PAGES = new Set<Page>(["dashboard", "providers", "models", "subagents", "logs", "debug", "usage", "api", "claude"]);

function readPageFromHash(): Page {
function readHashNavigation(): { page: Page; focusChatGptAuth: boolean } {
const raw = location.hash.replace(/^#\/?/, "");
return VALID_PAGES.has(raw as Page) ? (raw as Page) : "dashboard";
// Legacy Codex Auth sidebar route → Providers → ChatGPT Authentication.
if (raw === "codex-auth") return { page: "providers", focusChatGptAuth: true };
if (VALID_PAGES.has(raw as Page)) return { page: raw as Page, focusChatGptAuth: false };
return { page: "dashboard", focusChatGptAuth: false };
}

const API_BASE = import.meta.env.VITE_API_BASE || "";
Expand All @@ -37,7 +39,6 @@ const NAV: { id: Page; tkey: TKey; Icon: typeof IconGrid }[] = [
{ id: "logs", tkey: "nav.logs", Icon: IconList },
{ id: "debug", tkey: "nav.debug", Icon: IconTerminal },
{ id: "usage", tkey: "nav.usage", Icon: IconActivity },
{ id: "codex-auth", tkey: "nav.codexAuth", Icon: IconKey },
{ id: "api", tkey: "nav.api", Icon: IconGlobe },
{ id: "claude", tkey: "nav.claude", Icon: IconSparkle },
];
Expand All @@ -57,7 +58,9 @@ function readStoredTheme(): Theme {
}

export default function App() {
const [page, setPageState] = useState<Page>(readPageFromHash);
const initialNav = readHashNavigation();
const [page, setPageState] = useState<Page>(initialNav.page);
const [focusChatGptAuth, setFocusChatGptAuth] = useState(initialNav.focusChatGptAuth);
const [theme, setTheme] = useState<Theme>(readStoredTheme);
const [runtimeVersion, setRuntimeVersion] = useState<string | null>(null);
const { locale, setLocale } = useI18n();
Expand All @@ -71,14 +74,29 @@ export default function App() {

useEffect(() => {
// External navigation (hash edit, back/forward) also dismisses the mobile drawer.
const onHash = () => { setPageState(readPageFromHash()); setNavOpen(false); };
const onHash = () => {
const next = readHashNavigation();
setPageState(next.page);
if (next.focusChatGptAuth) setFocusChatGptAuth(true);
setNavOpen(false);
if (next.focusChatGptAuth && window.location.hash !== "#providers") {
history.replaceState(null, "", "#providers");
}
};
window.addEventListener("hashchange", onHash);
return () => window.removeEventListener("hashchange", onHash);
}, []);

// Rewrite legacy #codex-auth on first paint so the Providers nav item stays active.
useEffect(() => {
if (focusChatGptAuth && window.location.hash === "#codex-auth") {
history.replaceState(null, "", "#providers");
}
}, [focusChatGptAuth]);

useEffect(() => {
const nextHash = `#${page}`;
if (window.location.hash !== nextHash) {
if (window.location.hash !== nextHash && window.location.hash !== "#codex-auth") {
window.location.hash = page;
}
}, [page]);
Expand Down Expand Up @@ -106,6 +124,8 @@ export default function App() {
return () => { cancelled = true; clearInterval(interval); };
}, []);

const clearChatGptAuthFocus = useCallback(() => setFocusChatGptAuth(false), []);

const cycleTheme = () => setTheme(t => (t === "light" ? "dark" : t === "dark" ? "system" : "light"));
const ThemeIcon = THEME_ICON[theme];
const displayedVersion = runtimeVersion ?? __APP_VERSION__;
Expand Down Expand Up @@ -247,15 +267,20 @@ export default function App() {
</aside>

<main className="main" inert={navOpen}>
<div className="main-inner">
<div className={`main-inner${page === "providers" ? " main-inner--providers" : ""}`}>
{page === "dashboard" && <Dashboard apiBase={API_BASE} />}
{page === "providers" && <Providers apiBase={API_BASE} />}
{page === "providers" && (
<Providers
apiBase={API_BASE}
focusChatGptAuth={focusChatGptAuth}
onChatGptAuthFocused={clearChatGptAuthFocus}
/>
)}
{page === "models" && <Models apiBase={API_BASE} />}
{page === "subagents" && <Subagents apiBase={API_BASE} />}
{page === "logs" && <Logs apiBase={API_BASE} />}
{page === "debug" && <Debug apiBase={API_BASE} />}
{page === "usage" && <Usage apiBase={API_BASE} />}
{page === "codex-auth" && <CodexAuth apiBase={API_BASE} />}
{page === "api" && <ApiKeys apiBase={API_BASE} />}
{page === "claude" && <ClaudeCode apiBase={API_BASE} />}
</div>
Expand Down
2 changes: 2 additions & 0 deletions gui/src/codex-quota-utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
export interface AccountQuota {
weeklyPercent?: number;
fiveHourPercent?: number;
monthlyPercent?: number;
weeklyResetAt?: number;
fiveHourResetAt?: number;
monthlyResetAt?: number;
customWindows?: { label: string; percent: number; resetAt?: number }[];
resetCredits?: number;
Expand Down
189 changes: 119 additions & 70 deletions gui/src/components/AddCodexAccountModal.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { IconGlobe, IconLink } from "../icons";
import { IconLink } from "../icons";
import { useT } from "../i18n";

export default function AddCodexAccountModal({
Expand All @@ -25,6 +25,21 @@ export default function AddCodexAccountModal({
const [error, setError] = useState("");
const [authUrl, setAuthUrl] = useState("");
const [copied, setCopied] = useState(false);
const [existingIds, setExistingIds] = useState<string[]>([]);
const [starting, setStarting] = useState(false);

useEffect(() => {
let cancelled = false;
fetch(`${apiBase}/api/codex-auth/accounts`)
.then(r => r.json())
.then((data: { accounts?: Array<{ id: string }> }) => {
if (!cancelled) {
setExistingIds((data.accounts ?? []).map(a => a.id.toLowerCase()));
}
})
.catch(() => {});
return () => { cancelled = true; };
}, [apiBase]);

const stopPolling = () => {
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
Expand Down Expand Up @@ -77,102 +92,136 @@ export default function AddCodexAccountModal({
return () => window.removeEventListener("keydown", onKey);
});

const slug = id.trim();
const slugKey = slug.toLowerCase();
const slugDuplicate = slugKey !== "" && existingIds.includes(slugKey);
const canStart = !starting && !slugDuplicate;

const startOAuth = async () => {
if (!canStart) return;
setError("");
if (slugDuplicate) {
setError(t("codexAuth.slugDuplicate", { id: slug }));
return;
}
setStarting(true);
try {
const requestedId = slug;
const resp = await fetch(`${apiBase}/api/codex-auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(requestedId ? { id: requestedId } : {}),
});
const data = await resp.json() as { url?: string; flowId?: string; error?: string; status?: string };
if (!aliveRef.current) return;
if (resp.status === 409) {
setError(t("codexAuth.oauthAlreadyInProgress"));
return;
}
if (data.url) {
flowRef.current = data.flowId ?? null;
setAuthUrl(data.url);
setStep("oauth-waiting");
stopPolling();
const fid = data.flowId ?? "";
const statusUrl = fid
? `${apiBase}/api/codex-auth/login-status?flowId=${encodeURIComponent(fid)}${requestedId ? `&accountId=${encodeURIComponent(requestedId)}` : ""}`
: `${apiBase}/api/codex-auth/login-status`;
pollRef.current = setInterval(async () => {
try {
const st = await fetch(statusUrl).then(r => r.json()) as { status: string; error?: string };
if (st.status === "done") {
stopPolling();
flowRef.current = null;
onAdded();
onClose();
} else if (st.status === "error" || st.status === "expired") {
stopPolling();
flowRef.current = null;
if (aliveRef.current) { setStep("pick"); setError(st.error ?? "Login failed"); }
}
} catch { /* ignore network errors during polling */ }
}, 2000);
timeoutRef.current = setTimeout(() => {
if (pollRef.current) {
void cancelLogin();
if (aliveRef.current) { setStep("pick"); setError(t("modal.loginTimeout")); }
}
}, 300_000);
}
if (data.error && !data.url) setError(data.error);
} catch (e) {
setError(String(e));
} finally {
if (aliveRef.current) setStarting(false);
}
};

return (
<div className="modal-overlay" onClick={closeModal}>
<div className="modal-card" onClick={e => e.stopPropagation()} style={{ maxWidth: 440 }}>
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label={t("codexAuth.addTitle")} onClick={closeModal}>
<div className="modal-card codex-add-account-modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 440 }}>
{step === "pick" && (
<>
<h3 style={{ marginBottom: 4 }}>{t("codexAuth.addTitle")}</h3>
<p className="modal-desc">{t("codexAuth.addPickDesc")}</p>

<label className="field-label">{t("codexAuth.addIdLabel")}</label>
<label className="field-label" htmlFor="codex-account-slug">{t("codexAuth.addIdLabel")}</label>
<input
id="codex-account-slug"
className="input"
placeholder={t("codexAuth.addIdPlaceholder")}
value={id}
onChange={e => setId(e.target.value)}
style={{ marginBottom: 12 }}
onChange={e => { setId(e.target.value); setError(""); }}
aria-describedby="codex-account-slug-hint"
aria-invalid={slugDuplicate}
/>

<button className="list-row" onClick={async () => {
setError("");
try {
const requestedId = id.trim();
const resp = await fetch(`${apiBase}/api/codex-auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(requestedId ? { id: requestedId } : {}),
});
const data = await resp.json() as { url?: string; flowId?: string; error?: string; status?: string };
if (resp.status === 409) {
setError(t("codexAuth.oauthAlreadyInProgress"));
return;
}
if (data.url) {
flowRef.current = data.flowId ?? null;
setAuthUrl(data.url);
setStep("oauth-waiting");
stopPolling();
const fid = data.flowId ?? "";
const statusUrl = fid
? `${apiBase}/api/codex-auth/login-status?flowId=${encodeURIComponent(fid)}${requestedId ? `&accountId=${encodeURIComponent(requestedId)}` : ""}`
: `${apiBase}/api/codex-auth/login-status`;
pollRef.current = setInterval(async () => {
try {
const st = await fetch(statusUrl).then(r => r.json()) as { status: string; error?: string };
if (st.status === "done") {
stopPolling();
flowRef.current = null;
onAdded();
onClose();
} else if (st.status === "error" || st.status === "expired") {
stopPolling();
flowRef.current = null;
if (aliveRef.current) { setStep("pick"); setError(st.error ?? "Login failed"); }
}
} catch { /* ignore network errors during polling */ }
}, 2000);
timeoutRef.current = setTimeout(() => {
if (pollRef.current) {
void cancelLogin();
if (aliveRef.current) { setStep("pick"); setError(t("modal.loginTimeout")); }
}
}, 300_000);
}
if (data.error && !data.url) setError(data.error);
} catch (e) { setError(String(e)); }
}} style={{ marginBottom: 8 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<IconGlobe width={20} />
<div>
<div className="title">{t("codexAuth.oauthLogin")}</div>
<div className="sub">{t("codexAuth.oauthDesc")}</div>
</div>
<p id="codex-account-slug-hint" className="muted text-caption" style={{ margin: "6px 0 14px" }}>
{t("codexAuth.addIdHint")}
</p>
{slugDuplicate && (
<div className="notice notice-err" style={{ marginBottom: 12 }} role="alert">
{t("codexAuth.slugDuplicate", { id: slug })}
</div>
)}

<button
type="button"
className="btn btn-primary"
onClick={() => { void startOAuth(); }}
disabled={!canStart}
style={{ width: "100%", padding: "12px 16px" }}
>
{starting ? t("modal.waitingBrowser") : t("codexAuth.signInWithChatGpt")}
</button>

{error && <div className="notice notice-err" style={{ marginTop: 8 }}>{error}</div>}
{error && !slugDuplicate && (
<div className="notice notice-err" style={{ marginTop: 12 }} role="alert">{error}</div>
)}

<button className="btn btn-ghost" onClick={closeModal} style={{ width: "100%" }}>
{t("codexAuth.cancel")}
</button>
<div className="codex-add-account-actions">
<button type="button" className="btn btn-ghost" onClick={closeModal}>
{t("codexAuth.cancel")}
</button>
</div>
</>
)}

{step === "oauth-waiting" && (
<>
<h3 style={{ marginBottom: 4 }}>{t("codexAuth.oauthLogin")}</h3>
<h3 style={{ marginBottom: 4 }}>{t("codexAuth.signInWithChatGpt")}</h3>
<p className="modal-desc">{t("codexAuth.oauthWaiting")}</p>
<button className="btn btn-ghost" onClick={copyLoginLink} disabled={!authUrl} style={{ width: "100%", justifyContent: "center", marginTop: 12 }}>
<button type="button" className="btn btn-ghost" onClick={copyLoginLink} disabled={!authUrl} style={{ width: "100%", justifyContent: "center", marginTop: 12 }}>
<IconLink width={14} /> {copied ? t("codexAuth.loginLinkCopied") : t("codexAuth.copyLoginLink")}
</button>
{error && <div className="notice notice-err" style={{ marginTop: 12 }}>{error}</div>}
<div style={{ textAlign: "center", padding: "24px 0" }}>
<span className="spin" style={{ width: 24, height: 24 }} />
</div>
<button className="btn btn-ghost" onClick={closeModal} style={{ width: "100%" }}>
{t("codexAuth.cancel")}
</button>
<div className="codex-add-account-actions">
<button type="button" className="btn btn-ghost" onClick={closeModal}>
{t("codexAuth.cancel")}
</button>
</div>
</>
)}
</div>
Expand Down
Loading