Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
66a5b99
gui: add Dashboard workspace view (section rail + main pane)
Wibias Jul 22, 2026
9da77c1
Merge branch 'dev' of https://github.com/lidge-jun/opencodex into cod…
Wibias Jul 22, 2026
26cf5c1
fix(gui): dashboard workspace visual polish (rail header, sidecar gri…
Wibias Jul 22, 2026
7ce5f34
fix(gui): uniform 16px vertical rhythm across dashboard overview boxes
Wibias Jul 22, 2026
d2bbad8
fix(gui): cap dashboard workspace tables with internal scroll + stick…
Wibias Jul 22, 2026
7729325
gui: add global Workspace/Classic toggle to sidebar
Wibias Jul 22, 2026
bfdd97a
gui: drop per-tab dashboard toggle (sidebar owns it) + models summary…
Wibias Jul 22, 2026
f33de38
fix(gui): unify dashboard help popups as centered blurred modals
Wibias Jul 22, 2026
b4bf06b
fix(gui): let modal backdrop blur show through (lighter overlay tint)
Wibias Jul 22, 2026
079503b
fix(gui): keep Select dropdown in viewport (flip up + right-align sha…
Wibias Jul 22, 2026
c463384
feat(gui): dashboard models as searchable per-provider accordion
Wibias Jul 22, 2026
a5bde42
fix(gui): fix rail row border clipping (outline -> inset shadow)
Wibias Jul 22, 2026
3499f3d
fix(gui): port upstream dashboard health and guidance into workspace …
Wibias Jul 24, 2026
bca3842
fix(gui): keep dashboard hooks stable when connection errors
Wibias Jul 24, 2026
769c9ff
merge(gui): sync dashboard workspace with upstream/dev
Wibias Jul 24, 2026
255fb7e
fix(gui): drop Providers classic toggle and harden dashboard selects
Wibias Jul 25, 2026
e23ba72
merge(gui): sync dashboard workspace with upstream/dev
Wibias Jul 25, 2026
174268c
fix(gui): address CodeRabbit and Codex review on workspace toggle
Wibias Jul 25, 2026
2161fbc
fix(gui): remove unused writeProvidersViewPreference
Wibias Jul 25, 2026
75e237c
fix(gui): finish PR #428 review — viewMode props, Select a11y, poll o…
Wibias Jul 25, 2026
5ff127f
fix(gui): clear App/Select lint for viewMode deep-link and option ids
Wibias Jul 25, 2026
527be82
merge(dev): sync gui-dashboard-workspace with upstream/dev
Wibias Jul 25, 2026
e691b76
fix(gui): replace Providers hash passively; make Select a combobox (#…
Wibias Jul 25, 2026
f8e6e8a
fix(gui): guard shadow-call parse failures and close Select review gaps
Wibias Jul 25, 2026
9e66467
fix(gui): treat Classic/Workspace as preference, not history navigation
Wibias Jul 25, 2026
051ee80
fix(gui): retarget Providers workspace subroute contract after route …
Wibias Jul 25, 2026
421eb44
fix(gui): show Select keyboard focus and keep session viewMode withou…
Wibias Jul 25, 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
6 changes: 6 additions & 0 deletions docs-site/src/content/docs/guides/web-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ bun run dev:gui
| **Usage / Debug** | Inspect token-usage coverage and trends, or enable opt-in provider transport and usage-extraction diagnostics. |
| **Stop** | Gracefully stop the proxy and installed background service, restore native Codex, and exit (`POST /api/stop`). |

### Classic vs Workspace

The sidebar **Workspace / Classic** control switches the Dashboard and Providers layouts. The
preference is stored in the browser and kept consistent across those pages. On Providers, the URL
also reflects the mode (`#providers` vs `#providers/workspace`).

Cost values in **Logs** and **Usage** are API list-price equivalents calculated from reported tokens.
They are not billing receipts or evidence of an actual charge; subscription usage or provider credits
may apply instead.
Expand Down
4 changes: 4 additions & 0 deletions docs-site/src/content/docs/ja/guides/web-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ bun run dev:gui
| **使用量 / デバッグ** | トークン使用量の測定範囲と推移を見るか、オプションのプロバイダートランスポート/使用量抽出診断をオンにします。 |
| **停止** | プロキシとインストールされたバックグラウンドサービスを正常終了しネイティブ Codex を復元した後終了します(`POST /api/stop`)。 |

### Classic / Workspace

サイドバーの **Workspace / Classic** コントロールで Dashboard と Providers のレイアウトを切り替えます。設定はブラウザに保存され、これらのページ間で揃います。Providers では URL もモードを反映します(`#providers` と `#providers/workspace`)。

**ログ**と**使用量**のコスト値は報告されたトークンで計算した API 定価換算値です。請求明細や
実際の請求証拠ではなく、サブスクリプション使用量またはプロバイダークレジットが代わりに適用される場合があります。

Expand Down
4 changes: 4 additions & 0 deletions docs-site/src/content/docs/ko/guides/web-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ bun run dev:gui
| **Usage / Debug** | 토큰 사용량의 측정 범위와 추이를 보거나, 선택적 프로바이더 전송/사용량 추출 진단을 켭니다. |
| **Stop** | 프록시와 설치된 백그라운드 서비스를 정상 종료하고 네이티브 Codex를 복원한 뒤 끝냅니다(`POST /api/stop`). |

### Classic / Workspace

사이드바의 **Workspace / Classic** 컨트롤로 Dashboard와 Providers 레이아웃을 전환합니다. 설정은 브라우저에 저장되며 이 페이지들 사이에서 맞춰집니다. Providers에서는 URL도 모드를 반영합니다(`#providers` vs `#providers/workspace`).

**Logs**와 **Usage**의 비용 값은 보고된 토큰으로 계산한 API 정가 환산치입니다. 결제 영수증이나
실제 청구 증거가 아니며, 구독 사용량 또는 프로바이더 크레딧이 대신 적용될 수 있습니다.

Expand Down
4 changes: 4 additions & 0 deletions docs-site/src/content/docs/ru/guides/web-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ bun run dev:gui
| **Usage / Debug** | Просмотр покрытия и трендов расхода токенов либо включение опциональной диагностики транспорта провайдеров и извлечения данных об использовании. |
| **Stop** | Корректная остановка прокси и установленного фонового сервиса, восстановление нативного Codex и выход (`POST /api/stop`). |

### Classic / Workspace

Элемент боковой панели **Workspace / Classic** переключает макеты Dashboard и Providers. Предпочтение хранится в браузере и согласуется между этими страницами. На Providers URL также отражает режим (`#providers` и `#providers/workspace`).

Значения стоимости в **Logs** и **Usage** — это эквиваленты стоимости по прайс-листу API,
рассчитанные на основе сообщённых токенов. Они не являются счётом и не подтверждают фактическое
списание; расходы могут покрываться подпиской или кредитами провайдера.
Expand Down
4 changes: 4 additions & 0 deletions docs-site/src/content/docs/zh-cn/guides/web-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ bun run dev:gui
| **Usage / Debug** | 查看 token usage 覆盖率与趋势,或启用可选的 provider transport 和 usage 提取诊断。 |
| **Stop** | 优雅地停止代理和已安装的后台服务,恢复原生 Codex 并退出(`POST /api/stop`)。 |

### Classic / Workspace

侧边栏的 **Workspace / Classic** 控件切换 Dashboard 与 Providers 的布局。偏好保存在浏览器中,并在这些页面间保持一致。Providers 页面的 URL 也会反映模式(`#providers` 与 `#providers/workspace`)。

**Logs** 和 **Usage** 中的费用是根据已报告 token 计算的 API 标价折算值,不是账单,也不能证明
实际发生了扣费;实际可能计入订阅用量或消耗服务商额度。

Expand Down
120 changes: 21 additions & 99 deletions gui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,17 @@ import ApiKeys from "./pages/ApiKeys";
import ClaudeCode from "./pages/ClaudeCode";
import Startup from "./pages/Startup";
import ErrorBoundary from "./components/ErrorBoundary";
import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconHardDrive, IconKey, IconGithub, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconSparkle, IconX } from "./icons";
import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconHardDrive, IconKey, IconGithub, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconSparkle, IconX, IconLayoutSidebar } from "./icons";
import { useI18n, useT, LOCALES, type Locale, type TKey } from "./i18n";
import { Select, Switch } from "./ui";
import { installApiAuthFetch } from "./api";
import { type Page } from "./app-routing";
import { useAppRouteState } from "./use-app-route-state";

installApiAuthFetch();

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

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

const PAGE_TKEY: Record<Page, TKey> = {
dashboard: "nav.dashboard",
startup: "nav.startup",
Expand All @@ -39,44 +38,8 @@ const PAGE_TKEY: Record<Page, TKey> = {
claude: "nav.claude",
};

function readPageFromHash(): Page {
const raw = location.hash.replace(/^#\/?/, "");
// Sub-views use a "/" suffix (e.g. #providers/workspace); the first segment is the page id.
const pageId = raw.split("/")[0] as Page;
// Legacy: Debug used to be a standalone page; it now lives as a tab on Logs.
if (pageId === ("debug" as Page)) return "logs";
return VALID_PAGES.has(pageId) ? pageId : "dashboard";
}

function hashBelongsToPage(rawHash: string, page: Page): boolean {
return rawHash === page
|| (page === "providers" && rawHash === "providers/workspace")
|| (page === "logs" && rawHash === "logs/debug");
}

const API_BASE = import.meta.env.VITE_API_BASE || "";
const THEME_KEY = "ocx-theme";
const PROVIDERS_VIEW_KEY = "ocx-providers-view";

function readProvidersViewPreference(): "classic" | "workspace" {
try {
return localStorage.getItem(PROVIDERS_VIEW_KEY) === "workspace" ? "workspace" : "classic";
} catch {
return "classic";
}
}

function writeProvidersViewPreference(view: "classic" | "workspace"): void {
try {
localStorage.setItem(PROVIDERS_VIEW_KEY, view);
} catch {
/* ignore quota / private-mode failures */
}
}

function providersHashForPage(): string {
return readProvidersViewPreference() === "workspace" ? "providers/workspace" : "providers";
}

const NAV: { id: Page; tkey: TKey; Icon: typeof IconGrid }[] = [
{ id: "dashboard", tkey: "nav.dashboard", Icon: IconGrid },
Expand Down Expand Up @@ -106,7 +69,7 @@ function readStoredTheme(): Theme {
}

export default function App() {
const [page, setPageState] = useState<Page>(readPageFromHash);
const { page, viewMode, toggleGlobalWorkspace, navigateToPage } = useAppRouteState();
const [theme, setTheme] = useState<Theme>(readStoredTheme);
const [runtimeVersion, setRuntimeVersion] = useState<string | null>(null);
const { locale, setLocale } = useI18n();
Expand All @@ -120,62 +83,15 @@ export default function App() {

useEffect(() => {
// External navigation (hash edit, back/forward) also dismisses the mobile drawer.
const onHash = () => {
const nextPage = readPageFromHash();
const rawHash = window.location.hash.replace(/^#\/?/, "");
setNavOpen(false);
// Legacy #debug deep links → the Debug tab on Logs.
if (rawHash === "debug" || rawHash.startsWith("debug/")) {
window.location.hash = "logs/debug";
return;
}
if (!hashBelongsToPage(rawHash, nextPage)) {
window.location.hash = nextPage === "providers" ? providersHashForPage() : nextPage;
return;
}
// Preference is source of truth for Classic/Workspace. Bare #providers must not
// wipe a saved workspace choice (that regressed when leaving Providers and returning).
if (nextPage === "providers") {
const preferred = readProvidersViewPreference();
if (rawHash === "providers/workspace") {
writeProvidersViewPreference("workspace");
} else if (rawHash === "providers" && preferred === "workspace") {
window.location.hash = "providers/workspace";
return;
} else if (rawHash === "providers") {
writeProvidersViewPreference("classic");
}
}
setPageState(nextPage);
const dismissNav = () => setNavOpen(false);
window.addEventListener("hashchange", dismissNav);
window.addEventListener("popstate", dismissNav);
return () => {
window.removeEventListener("hashchange", dismissNav);
window.removeEventListener("popstate", dismissNav);
};
window.addEventListener("hashchange", onHash);
return () => window.removeEventListener("hashchange", onHash);
}, []);

useEffect(() => {
const rawHash = window.location.hash.replace(/^#\/?/, "");
// Legacy #debug deep links must resolve before generic normalization
// (otherwise the hash collapses to bare #logs and the tab choice is lost).
if (rawHash === "debug" || rawHash.startsWith("debug/")) {
window.location.hash = "logs/debug";
return;
}
if (page === "providers") {
// Honor an explicit workspace deep link on first load before normalizing
// to the saved preference (bookmarks/shared links must not open Classic).
if (rawHash === "providers/workspace") {
writeProvidersViewPreference("workspace");
return;
}
const wanted = providersHashForPage();
if (rawHash !== wanted) window.location.hash = wanted;
return;
}
if (!hashBelongsToPage(rawHash, page)) {
window.location.hash = page;
}
}, [page]);

useEffect(() => {
const el = document.documentElement;
if (theme === "system") { el.removeAttribute("data-theme"); localStorage.removeItem(THEME_KEY); }
Expand Down Expand Up @@ -302,9 +218,8 @@ export default function App() {
<div key={id} className={`nav-entry${id === "claude" ? ` nav-entry-claude${page === id ? " active" : ""}` : ""}`}>
<button className={`nav-item${page === id ? " active" : ""}`} data-page={id}
onClick={() => {
// Always sync the hash on nav click so Providers restores Classic/Workspace preference.
window.location.hash = id === "providers" ? providersHashForPage() : id;
setPageState(id);
// Deliberate sidebar navigation — push a history entry.
navigateToPage(id);
setNavOpen(false);
}}
aria-current={page === id ? "page" : undefined}>
Expand All @@ -317,6 +232,12 @@ export default function App() {
))}
</nav>
<div className="sidebar-foot">
<button type="button" className="theme-toggle" onClick={toggleGlobalWorkspace}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document the new global view-mode control

The new sidebar control changes persistent behavior across Dashboard and Providers and removes the former Providers-local toggle, but none of the docs-site web-dashboard guides explains where the control moved or how Classic and Workspace preferences behave. Update the English dashboard guide and keep its translated counterparts consistent so users can discover and understand the replacement control.

AGENTS.md reference: AGENTS.md:L78-L79

Useful? React with 👍 / 👎.

aria-pressed={viewMode === "workspace"}
aria-label={`${t("app.viewMode")}: ${t(viewMode === "workspace" ? "pws.workspaceToggle" : "pws.classicToggle")}`}
title={`${t("app.viewMode")}: ${t(viewMode === "workspace" ? "pws.workspaceToggle" : "pws.classicToggle")}`}>
<IconLayoutSidebar /> <span className="mode">{t(viewMode === "workspace" ? "pws.workspaceToggle" : "pws.classicToggle")}</span>
</button>
<div className="lang-toggle">
<IconGlobe aria-hidden />
<Select
Expand All @@ -325,6 +246,7 @@ export default function App() {
onChange={v => setLocale(v as Locale)}
label={t("lang.label")}
placement="right"
portal={false}
style={{ flex: 1, minWidth: 0, width: "100%" }}
/>
</div>
Expand Down Expand Up @@ -352,9 +274,9 @@ export default function App() {
detailsLabel={t("errorBoundary.details")}
reloadLabel={t("errorBoundary.reload")}
>
{page === "dashboard" && <Dashboard apiBase={API_BASE} />}
{page === "dashboard" && <Dashboard apiBase={API_BASE} viewMode={viewMode} />}
{page === "startup" && <Startup apiBase={API_BASE} />}
{page === "providers" && <Providers apiBase={API_BASE} />}
{page === "providers" && <Providers apiBase={API_BASE} viewMode={viewMode} />}
{page === "models" && <Models apiBase={API_BASE} />}
{page === "combos" && <Combos apiBase={API_BASE} />}
{page === "subagents" && <Subagents apiBase={API_BASE} />}
Expand Down
120 changes: 120 additions & 0 deletions gui/src/app-routing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/** Pure hash → page/viewMode resolution used by App route state. */

import { normalizeHashPath, resolveProvidersHashChange } from "./hash-routing";
import { providersHashForViewMode, type ViewMode } from "./view-mode";

export type Page =
| "dashboard"
| "startup"
| "providers"
| "models"
| "combos"
| "subagents"
| "logs"
| "usage"
| "storage"
| "codex-auth"
| "api"
| "claude";

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

export function readPageFromHash(hash?: string): Page {
const raw = normalizeHashPath(
hash ?? (typeof window !== "undefined" ? window.location.hash : ""),
);
// Sub-views use a "/" suffix (e.g. #providers/workspace); the first segment is the page id.
const pageId = raw.split("/")[0] as Page;
// Legacy: Debug used to be a standalone page; it now lives as a tab on Logs.
if (pageId === ("debug" as Page)) return "logs";
return VALID_PAGES.has(pageId) ? pageId : "dashboard";
}

export function hashBelongsToPage(rawHash: string, page: Page): boolean {
return rawHash === page
|| (page === "providers" && rawHash === "providers/workspace")
|| (page === "logs" && rawHash === "logs/debug");
}

export function providersHashForPreferredMode(preferred: ViewMode = "classic"): string {
return providersHashForViewMode(preferred);
}

/**
* Result of applying an incoming hash against the canonical view preference.
* Callers must apply `page` and `viewMode` together (no deferred transitions).
*/
export type AppHashChangeAction = {
page: Page;
/** When non-null, set React viewMode in the same turn as `page`. */
viewMode: ViewMode | null;
/** When non-null, persist to the canonical preference store. */
persistViewMode: ViewMode | null;
/** When non-null, passively replace the current history entry (no push). */
replaceTo: string | null;
};

/**
* Resolve what App should do for the current location hash.
* Classic/Workspace is a persistent preference: bare `#providers` with a stored
* workspace preference is rewritten via replaceState, never pushed.
*/
export function resolveAppHashChange(rawHash: string, preferred: ViewMode): AppHashChangeAction {
const nextPage = readPageFromHash(rawHash);

if (rawHash === "debug" || rawHash.startsWith("debug/")) {
return {
page: "logs",
viewMode: null,
persistViewMode: null,
replaceTo: "logs/debug",
};
}

if (!hashBelongsToPage(rawHash, nextPage)) {
return {
page: nextPage === "providers" ? "providers" : nextPage,
viewMode: nextPage === "providers" ? preferred : null,
persistViewMode: null,
replaceTo: nextPage === "providers" ? providersHashForPreferredMode(preferred) : nextPage,
};
}

if (nextPage === "providers") {
const resolved = resolveProvidersHashChange(rawHash, preferred);
if (resolved.replaceTo) {
return {
page: "providers",
viewMode: resolved.viewMode,
persistViewMode: resolved.viewMode,
replaceTo: resolved.replaceTo,
};
}
return {
page: "providers",
viewMode: resolved.viewMode,
persistViewMode: resolved.viewMode,
replaceTo: null,
};
}

return {
page: nextPage,
viewMode: null,
persistViewMode: null,
replaceTo: null,
};
}
Loading
Loading