Skip to content
11 changes: 0 additions & 11 deletions gui/src/components/CodexAccountPool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import CodexPoolStrategySetting from "./CodexPoolStrategySetting";
import { useCodexAutoSwitch } from "../hooks/useCodexAutoSwitch";
import { readJsonIfOk } from "../fetch-json";
import { CodexAccountPoolCards, CodexAccountPoolReauthBanner } from "./codex-account-pool-cards";
import { DataSurfaceStatus } from "./data-surface";
import { CodexAccountSwitchModal } from "./codex-account-switch-modal";
import { CodexAccountResetModal } from "./codex-account-reset-modal";
import { CodexAccountPoolLoadStates, CodexAccountPoolMainCard, CodexAccountPoolPageHead } from "./codex-account-pool-main-card";
Expand Down Expand Up @@ -57,10 +56,6 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
const ownController = useCodexAccountPool(apiBase, !injectedController);
const controller = injectedController ?? ownController;
const { accounts, activeId, loadState, switchingId, pauseUpdatingId, pausingExhausted, load } = controller;
// The controller owns the visible progress signal. A forced quota refresh keeps rows on screen
// and can take ~1s (longer when the server has to refill per-account quota), so without this the
// wait is invisible; `refreshingQuota` below stays local because it only guards its own button.
const { refreshing } = controller;
const [confirm, setConfirm] = useState<CodexAccountEntry | null>(null);
const [showAdd, setShowAdd] = useState(false);
const [reauthId, setReauthId] = useState<string | null>(null);
Expand Down Expand Up @@ -277,12 +272,6 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
onRetry={() => { void load(); }}
/>

{/* Revalidation over existing rows. The cold branch above already owns a live region, so
this only renders once rows are on screen, keeping one announcement per transition. */}
{refreshing && accounts.length > 0 && (
<DataSurfaceStatus live={loadState !== "error"}>{t("common.loading")}</DataSurfaceStatus>
)}

{!(loadState === "loading" && accounts.length === 0) && (
<>
<CodexAccountPoolMainCard
Expand Down
32 changes: 29 additions & 3 deletions gui/src/components/section-tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
* The active tab follows the scroll position, so the strip reports where you are rather
* than only where you last clicked.
*/
import { useEffect, useRef, useState } from "react";
import { sectionAnchorId, sectionAnchorPrefix } from "../section-anchors";
import { useCallback, useEffect, useRef, useState } from "react";
import { SECTION_TAB_SCROLL_LOCK_MS, sectionAnchorId, sectionAnchorPrefix } from "../section-anchors";

export interface SectionTabItem {
id: string;
Expand All @@ -29,6 +29,19 @@ export function SectionTabs({
}) {
const [active, setActive] = useState(items[0]?.id ?? "");
const stripRef = useRef<HTMLDivElement | null>(null);
/** While set, scroll-spy ignores intermediate sections during smooth scroll-to-click. */
const scrollLockRef = useRef<string | null>(null);
const scrollLockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const clearScrollLock = useCallback(() => {
scrollLockRef.current = null;
if (scrollLockTimerRef.current !== null) {
clearTimeout(scrollLockTimerRef.current);
scrollLockTimerRef.current = null;
}
}, []);

useEffect(() => () => clearScrollLock(), [clearScrollLock]);

// Follow the scroll position. `rootMargin` biases the observer toward the top of the
// viewport so the heading you are reading wins, not whatever is technically centred.
Expand All @@ -41,6 +54,16 @@ export function SectionTabs({

const observer = new IntersectionObserver(
entries => {
const locked = scrollLockRef.current;
if (locked) {
const lockedNode = document.getElementById(sectionAnchorId(scope, locked));
const lockedVisible = entries.some(entry => entry.isIntersecting && entry.target === lockedNode);
if (lockedVisible) {
clearScrollLock();
setActive(locked);
}
return;
}
const visible = entries
.filter(entry => entry.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0];
Expand All @@ -52,11 +75,14 @@ export function SectionTabs({
);
for (const node of nodes) observer.observe(node);
return () => observer.disconnect();
}, [items, scope]);
}, [clearScrollLock, items, scope]);

const go = (id: string) => {
const target = document.getElementById(sectionAnchorId(scope, id));
if (!target) return;
scrollLockRef.current = id;
if (scrollLockTimerRef.current !== null) clearTimeout(scrollLockTimerRef.current);
scrollLockTimerRef.current = setTimeout(clearScrollLock, SECTION_TAB_SCROLL_LOCK_MS);
Comment thread
Wibias marked this conversation as resolved.
Outdated
setActive(id);
// `scroll-margin-top` on the target keeps the heading clear of the pinned strip.
target.scrollIntoView({ behavior: "smooth", block: "start" });
Expand Down
20 changes: 10 additions & 10 deletions gui/src/components/storage-workspace/StorageWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,18 +169,18 @@ export default function StorageWorkspace({ report, locale }: StorageWorkspacePro
</div>
) : (
<div className="stw-overview">
<div className="usage-cards">
<div className="stat">
<div className="muted">{t("storage.card.total")}</div>
<div className="stat-value">{formatBytes(report.total.bytes, locale)}</div>
<div className="stw-summary">
<div className="stw-summary-card">
<div className="stw-summary-label">{t("storage.card.total")}</div>
<div className="stw-summary-value">{formatBytes(report.total.bytes, locale)}</div>
</div>
<div className="stat">
<div className="muted">{t("storage.card.files")}</div>
<div className="stat-value">{report.total.fileCount.toLocaleString(locale)}</div>
<div className="stw-summary-card">
<div className="stw-summary-label">{t("storage.card.files")}</div>
<div className="stw-summary-value">{report.total.fileCount.toLocaleString(locale)}</div>
</div>
<div className="stat">
<div className="muted">{t("storage.card.home")}</div>
<div className="stat-value mono stw-home-path" title={report.codexHome}>{report.codexHome}</div>
<div className="stw-summary-card">
<div className="stw-summary-label">{t("storage.card.home")}</div>
<div className="stw-summary-value mono stw-home-path" title={report.codexHome}>{report.codexHome}</div>
</div>
</div>

Expand Down
39 changes: 21 additions & 18 deletions gui/src/pages/ApiKeys.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ import {
type ExternalModelRow,
type GatewayInboundProtocol,
} from "../api-access-models";
import { setClientResourceData } from "../client-resource";
import { readSessionListCache, writeSessionListCache } from "../session-list-cache";
import { createBoundedFetch } from "../bounded-fetch";
import { useDataSurface } from "../data-surface";
import { DataSurfaceSkeleton, DataSurfaceStatus } from "../components/data-surface";
import { DataSurfaceSkeleton } from "../components/data-surface";
import ApiKeysWorkspace from "../components/apikeys-workspace/ApiKeysWorkspace";
import {
DEFAULT_ENDPOINTS,
Expand Down Expand Up @@ -90,11 +91,24 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) {
// turn stale client state into an apparently authoritative empty auth table.
const keysCacheKey = `ocx.apikeys.list.v2:${apiBase}`;
const modelsCacheKey = `ocx.apikeys.models.v1:${apiBase}`;
const keysResourceKey = `api-keys:${apiBase}`;
const modelsResourceKey = `api-models:${apiBase}`;
// A cache entry is arbitrary parsed JSON. Trusting it would reintroduce exactly
// what the network path refuses: an empty matrix rendering as an authoritative
// "no rules" table, or a row without `usage` throwing on first render.
const cachedKeys = validCachedKeys(readSessionListCache<CachedKeysShape>(keysCacheKey));
const cachedModels = readSessionListCache<ExternalModelRow[]>(modelsCacheKey);
// Seed before subscribe so a revisit does not flash loading status under the page title.
const seededKeysRef = useRef<string | null>(null);
if (seededKeysRef.current !== keysResourceKey) {
if (cachedKeys) setClientResourceData(keysResourceKey, cachedKeys);
seededKeysRef.current = keysResourceKey;
}
const seededModelsRef = useRef<string | null>(null);
if (seededModelsRef.current !== modelsResourceKey) {
if (cachedModels) setClientResourceData(modelsResourceKey, cachedModels);
seededModelsRef.current = modelsResourceKey;
}
Comment thread
Wibias marked this conversation as resolved.
Outdated
const [actionError, setActionError] = useState<string | null>(null);
const [modelQuery, setModelQuery] = useState("");
const [copiedModelId, setCopiedModelId] = useState<string | null>(null);
Expand Down Expand Up @@ -161,13 +175,13 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) {
// Keys and models intentionally remain independent resources: a slow catalog must never
// block endpoint/key management, and each cache key retains its own session seed.
const keysResource = useDataSurface<CachedKeysShape>(
`api-keys:${apiBase}`,
keysResourceKey,
[apiBase],
fetchKeys,
{ isEmpty: data => data.keys.length === 0 },
);
const modelsResource = useDataSurface<ExternalModelRow[]>(
`api-models:${apiBase}`,
modelsResourceKey,
[apiBase],
fetchModels,
{ isEmpty: models => models.length === 0 },
Expand Down Expand Up @@ -370,7 +384,10 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) {
const subtitleParts = t("api.subtitle").split("{authHeader}");

return (
<section className="api-page">
<section
className="api-page"
aria-busy={keysState.refreshing || modelsState.refreshing || undefined}
>
<div className="page-head">
<h2>{t("api.title")}</h2>
</div>
Expand All @@ -395,20 +412,6 @@ export default function ApiKeys({ apiBase }: { apiBase: string }) {
</>
) : (
<>
{/* Keys and models revalidate independently and can be in flight together. Only one
region may announce per transition, so keys take precedence and models steps down to
visual-only while keys is speaking. */}
{keysState.refreshing && keysData && (
<DataSurfaceStatus live={!keysState.showError}>{t("api.activeKeysLoading")}</DataSurfaceStatus>
)}
{/* `modelsState.data` alone misses the session-cached case: after a
failure the rows on screen come from `cachedModels`, and a retry
then ran with no visible progress at all. */}
{modelsState.refreshing && (modelsState.data !== undefined || cachedModels !== null) && (
<DataSurfaceStatus live={!modelsState.showError && !(keysState.refreshing && keysData)}>
{t("api.modelsLoading")}
</DataSurfaceStatus>
)}
<ApiKeysWorkspace
keys={keys}
attributionSince={attributionSince}
Expand Down
36 changes: 34 additions & 2 deletions gui/src/pages/Claude.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useRef, useState, type KeyboardEvent } from "react";
import ClaudeCode from "./ClaudeCode";
import ClaudeDesktop from "./ClaudeDesktop";
import { useT } from "../i18n/shared";
import { readSessionListCache } from "../session-list-cache";
Comment thread
Wibias marked this conversation as resolved.

type ClaudeTab = "code" | "desktop";

Expand All @@ -10,10 +11,20 @@ export default function Claude({ apiBase }: { apiBase: string }) {
const t = useT();
const codeTabRef = useRef<HTMLButtonElement>(null);
const desktopTabRef = useRef<HTMLButtonElement>(null);
// Seed Desktop's port subtitle from session cache so the intro above the Code/Desktop
// strip does not wait on the first status paint after a tab hop.
const [desktopPort, setDesktopPort] = useState<number | null>(() => {
const cached = readSessionListCache<{ data?: { port?: number } }>(`ocx.claude-desktop.v1:${apiBase}`);
return typeof cached?.data?.port === "number" ? cached.data.port : null;
});

const selectTab = (next: ClaudeTab) => {
setTab(next);
window.requestAnimationFrame(() => (next === "code" ? codeTabRef : desktopTabRef).current?.focus());
// preventScroll: focusing the tab must not scroll the page — otherwise the
// Code/Desktop panels' different header heights make the tab strip jump.
window.requestAnimationFrame(() => {
(next === "code" ? codeTabRef : desktopTabRef).current?.focus({ preventScroll: true });
});
};

const handleTabKey = (event: KeyboardEvent<HTMLButtonElement>) => {
Expand All @@ -31,6 +42,22 @@ export default function Claude({ apiBase }: { apiBase: string }) {

return (
<section className="claude-page">
{/* Title/subtitle sit above the Code/Desktop strip so the page reads title → selector → body. */}
<div className="claude-page-intro">
<div className="page-head">
<h2>{tab === "code" ? t("claude.pageTitle") : t("claudeDesktop.title")}</h2>
</div>
{tab === "code" ? (
<p className="page-sub">{t("claude.subtitle")}</p>
) : (
<p className="page-sub">
{desktopPort != null
? t("claudeDesktop.subtitle", { port: desktopPort })
: t("claudeDesktop.loading")}
Comment thread
Wibias marked this conversation as resolved.
Outdated
</p>
)}
</div>

<div className="claude-tabs" role="tablist" aria-label={t("claude.tabsLabel")}>
<button
type="button"
Expand Down Expand Up @@ -79,7 +106,12 @@ export default function Claude({ apiBase }: { apiBase: string }) {
aria-labelledby="claude-desktop-tab"
hidden={tab !== "desktop"}
>
<ClaudeDesktop key={apiBase} apiBase={apiBase} active={tab === "desktop"} />
<ClaudeDesktop
key={apiBase}
apiBase={apiBase}
active={tab === "desktop"}
onPortChange={setDesktopPort}
/>
</div>
</section>
);
Expand Down
54 changes: 36 additions & 18 deletions gui/src/pages/ClaudeCode.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { useCallback, useMemo, useState, type ReactNode } from "react";
import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
import { setClientResourceData } from "../client-resource";
import { Notice } from "../ui";
import { useI18n, useT, LOCALES } from "../i18n/shared";
import { readJsonOrThrow } from "../fetch-json";
import { readSessionListCache, writeSessionListCache } from "../session-list-cache";
import { useDataSurface } from "../data-surface";
import { DataSurfaceSkeleton, DataSurfaceStatus } from "../components/data-surface";
import { DataSurfaceSkeleton } from "../components/data-surface";
import { backgroundHelperOptions } from "./claude-code-helper-options";
import { reconcileAutoConnectState } from "./claude-autoconnect";
import { buildManualEnv } from "./claude-manual-env";
Expand All @@ -31,7 +32,14 @@ export default function ClaudeCode({ apiBase, active = true }: { apiBase: string
const { locale } = useI18n();
const localeTag = LOCALES.find(l => l.code === locale)?.htmlLang ?? "en";
const cacheKey = `ocx.claude-code.v1:${apiBase}`;
const resourceKey = `claude-code:${apiBase}`;
const cached = useMemo(() => seedClaudeCode(cacheKey), [cacheKey]);
// Seed before subscribe so Code↔Desktop hops do not flash "Loading…" under the title.
const seededKeyRef = useRef<string | null>(null);
if (seededKeyRef.current !== resourceKey) {
if (cached) setClientResourceData(resourceKey, cached);
seededKeyRef.current = resourceKey;
}
const [draftState, setState] = useState<ClaudeCodeState | null>(() => cached?.state ?? null);
const [draftRows, setRows] = useState<MapRow[]>(() => cached?.rows ?? []);
const [hasDraftRows, setHasDraftRows] = useState(Boolean(cached));
Expand Down Expand Up @@ -72,7 +80,7 @@ export default function ClaudeCode({ apiBase, active = true }: { apiBase: string
}, [apiBase, cacheKey, t]);

const codeResource = useDataSurface<CachedClaudeCode>(
`claude-code:${apiBase}`,
resourceKey,
[apiBase],
fetchCode,
{ isEmpty: () => false, enabled: active },
Expand Down Expand Up @@ -152,7 +160,7 @@ export default function ClaudeCode({ apiBase, active = true }: { apiBase: string
}
if (!state) return null;

const sections: Array<{ id: string; label: string; body: ReactNode }> = [
const sections: Array<{ id: string; label: string; meta?: string; body: ReactNode }> = [
{
id: "settings",
label: t("claude.workspace.settings"),
Expand Down Expand Up @@ -185,6 +193,7 @@ export default function ClaudeCode({ apiBase, active = true }: { apiBase: string
{
id: "modelMap",
label: t("claude.modelMap"),
meta: String(rows.length),
body: <ClaudeCodeModelMapSection rows={rows} onRowsChange={(nextRows) => {
setHasDraftRows(true);
setRows(nextRows);
Expand All @@ -193,6 +202,7 @@ export default function ClaudeCode({ apiBase, active = true }: { apiBase: string
{
id: "aliases",
label: t("claude.aliases"),
meta: String(state.aliases.length),
body: <ClaudeCodeAliasesSection aliases={state.aliases} />,
},
];
Expand All @@ -203,22 +213,9 @@ export default function ClaudeCode({ apiBase, active = true }: { apiBase: string

return (
<div className="claudecode-workspace-shell">
<div className="page-head">
<h2>{t("claude.pageTitle")}</h2>
{sectionEditable && (
<div className="claudecode-workspace-save">
<button type="button" className="btn btn-primary btn-sm" onClick={() => { void save(); }}>
{t("common.save")}
</button>
</div>
)}
</div>
<p className="page-sub">{t("claude.subtitle")}</p>
{/* Page title/subtitle live on Claude.tsx above the Code/Desktop strip. */}
{status && <Notice tone={ok ? "ok" : "err"}>{status}</Notice>}
{loadState.showError && <Notice tone="err">{t("claude.loadFail")}</Notice>}
{loadState.refreshing && (
<DataSurfaceStatus live={!loadState.showError}>{t("claude.loading")}</DataSurfaceStatus>
)}
<div className="claudecode-workspace-root">
<aside className="claudecode-workspace-rail" aria-label={t("claude.pageTitle")}>
<div className="claudecode-workspace-rail-list">
Expand All @@ -236,6 +233,27 @@ export default function ClaudeCode({ apiBase, active = true }: { apiBase: string
</div>
</aside>
<section className="claudecode-workspace-main" aria-label={selected.label}>
<div className="ccw-main-head">
<h3 className="ccw-main-title">
{selected.label}
{selected.meta != null ? <span className="count">{selected.meta}</span> : null}
</h3>
<div
className="claudecode-workspace-save"
data-visible={sectionEditable ? "true" : "false"}
>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={!sectionEditable}
tabIndex={sectionEditable ? 0 : -1}
aria-hidden={!sectionEditable}
onClick={() => { void save(); }}
>
{t("common.save")}
</button>
</div>
</div>
<div className="ccw-body">{selected.body}</div>
</section>
</div>
Expand Down
Loading
Loading