Skip to content
Closed
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
79 changes: 79 additions & 0 deletions devlog/gui-load-perf-hot-before-after.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# GUI hot-path TTI — before / after (PERF-1)

Date: 2026-07-28
Branch: `perf/gui-hot-path`

## Baseline (cold, local :10100)

Captured earlier on the same proxy:

| Endpoint | Cold latency |
|----------|----------------|
| `/api/usage?range=30d` | ~5.2s |
| `/api/codex-auth/accounts` | ~1.2s |
| `/api/models` | ~333ms |
| status / config / providers / settings | fast (sub-200ms typical) |

### Before (symptoms)

1. **Dashboard** — Shadow Call Intercept, web-search sidecar, and vision selects stayed disabled until `fetchDashboardCore` finished. That poll bundled `healthz` + providers + settings + sidecar + shadow **and then** sequentially awaited v2 / injection / effort-caps. Slow peers (or a long core round-trip) blocked toggles even when their own settings endpoints were already ready. Tokens-30d was already a separate poll (~5.2s) but did not need to gate controls; status widgets were fine to stream later.
2. **Models** — Shadow call settings were fetched only *after* the heavy models/caps/providers/selection `Promise.all`. The whole page early-returned on `loading`, so the shadow switch was invisible until the catalog finished.
3. **Providers** — Config-based ready/need-setup bins were correct on first paint, but `applyActiveAccountReauth` demoted rows into need-setup when live account discovery (~1.2s) returned, flashing a rail re-order. Add Provider waited on cold `/api/provider-presets` (+ usage rank) at modal open. Overview summary from config was fine; rate-limits / recently-used waited on usage (~5.2s) / quotas with no busy affordance.

## Changes

### Dashboard

- Split `fetchDashboardControls` (settings + sidecar-settings + shadow-call-settings) from `fetchDashboardCore` (health + providers + v2 + injection + effort-caps, all parallel).
- Separate `dashboard-controls` client-resource poll so control enablement commits as soon as own endpoints return.
- SessionStorage last-known controls for optimistic re-entry (stale-while-revalidate).
- `aria-busy` on slow status/usage stats and sidecar/shadow panels only (not the whole page).

### Models

- Mount-time parallel fetch for shadow-call + v2 (independent of models catalog).
- Shadow controls render during catalog `loading` so the switch is usable early.

### Providers

- `applyActiveAccountReauth` **tags** reauth without moving ready → need-setup (stable rail order from config).
- Attention list / overview reauth counts updated for tagged ready rows.
- Prefetch `add-provider-presets` + `add-provider-usage` on Providers page mount (shared client-resource keys with the modal).
- Parallelize oauth provider list with Codex accounts/active probes.
- Overview rate-limits / recently-used show `aria-busy` while usage/quotas load; catalog keeps label order until usage rank is present.

## Expected TTI improvement

| Surface | Expected |
|---------|----------|
| Dashboard sidecar / shadow toggles | Interactive after settings endpoints (~tens–low hundreds ms), not after full core poll; repeat visits instant from session cache |
| Dashboard tokens / version / uptime | Still stream late (~5.2s usage OK); no longer block controls |
| Models shadow switch | Usable as soon as `/api/shadow-call-settings` returns (~fast), while models list may still spin |
| Providers rail | Ready/need-setup order stable from first config paint; no ~1.2s reauth demotion flash |
| Add Provider catalog | Cache warm after page visit; open modal without cold presets wait |
| Providers overview | Summary immediate from config; usage/quota sections busy until probes finish |

## Remaining risks

- Session cache can briefly show stale sidecar/shadow until the controls poll confirms.
- Reauth providers stay in the Ready group (with warning status) instead of moving to Need setup — intentional for flash avoidance.
- Usage-ranked catalog order still applies once usage arrives if presets painted first without a warm usage cache.
- No measured after numbers in this pass (code-path reasoning + contracts tests only).

## Measured locally (2026-07-28, proxy `:10100`)

Client-pattern timings (same machine/proxy). These approximate TTI gates, not full browser paint.

| Pattern | ms |
|---------|-----|
| BEFORE Dashboard: core peers then controls (serial) | 5655 |
| AFTER Dashboard: controls only (serial) | 1323 |
| AFTER Dashboard: controls only (parallel) | 625 |
| BEFORE Models: catalog then shadow | 1439 |
| AFTER Models: shadow only | 16 |
| Usage cold | 725 |
| Usage warm (same query) | 9 |
| Usage other surface | 458 |
| Codex `/accounts` alone | 1020 |

**Read:** PERF-1 wins are mostly “don’t wait for the slow path before enabling controls” (5.6s → ~tens–low hundreds ms for shadow/sidecar). PERF-2 wins are mostly “don’t refetch held page data” (usage revisit ~9ms network; UI session seed is even cheaper). Full browser TTI not instrumented in this pass.
20 changes: 13 additions & 7 deletions gui/src/components/provider-catalog/ProviderCatalog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,19 @@ export default function ProviderCatalog({

const catalog = useMemo(() => presets.filter(p => p.id !== "custom"), [presets]);

/** Usage-ranked order: requests desc, then label (050a sortPresets is the no-usage fallback). */
const ranked = useMemo(() => catalog.toSorted((a, b) => {
const ra = usageRank[a.id] ?? 0;
const rb = usageRank[b.id] ?? 0;
if (rb !== ra) return rb - ra;
return a.label.localeCompare(b.label, undefined, { sensitivity: "base" }) || a.id.localeCompare(b.id);
}), [catalog, usageRank]);
/** Usage-ranked order only after usage arrives; until then keep stable label order
* so a slow /api/usage (~5s cold) cannot flash a catalog resort. */
const ranked = useMemo(() => {
const hasUsage = Object.keys(usageRank).length > 0;
return catalog.toSorted((a, b) => {
if (hasUsage) {
const ra = usageRank[a.id] ?? 0;
const rb = usageRank[b.id] ?? 0;
if (rb !== ra) return rb - ra;
}
return a.label.localeCompare(b.label, undefined, { sensitivity: "base" }) || a.id.localeCompare(b.id);
});
}, [catalog, usageRank]);

const buckets = useMemo(() => bucketPresets(ranked), [ranked]);
const tierList = buckets[tier];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,16 @@ export default function ProviderOverviewDashboard({
sections,
quotaReports,
usageTotals,
usageLoading = false,
quotasLoading = false,
onSelectProvider,
onEditConfig,
}: {
sections: WorkspaceSections;
quotaReports: Record<string, ProviderQuotaReportView>;
usageTotals: Record<string, ProviderUsageTotals>;
usageLoading?: boolean;
quotasLoading?: boolean;
onSelectProvider: (name: string) => void;
onEditConfig?: () => void;
}) {
Expand All @@ -48,7 +52,7 @@ export default function ProviderOverviewDashboard({
const attention = useMemo(() => buildAttentionItems(sections, {}), [sections]);
const attentionCount = attention.length;
const reauthCount = useMemo(
() => sections.needsSetup.filter(p => p.activeNeedsReauth).length,
() => [...sections.ready, ...sections.needsSetup].filter(p => p.activeNeedsReauth).length,
[sections],
);

Expand Down Expand Up @@ -132,9 +136,10 @@ export default function ProviderOverviewDashboard({
)}

<div className="pws-dashboard-columns">
{quotaProviders.length > 0 && (
<section className="pws-dashboard-section" aria-label={t("pws.dashboard.rateLimits")}>
{(quotaProviders.length > 0 || quotasLoading) && (
<section className="pws-dashboard-section" aria-label={t("pws.dashboard.rateLimits")} aria-busy={quotasLoading || undefined}>
<h3 className="pws-dashboard-section-title">{t("pws.dashboard.rateLimits")}</h3>
{quotaProviders.length > 0 ? (
<div className="pws-dashboard-rows">
{quotaProviders.map(({ item, report }) => (
<button
Expand Down Expand Up @@ -162,11 +167,14 @@ export default function ProviderOverviewDashboard({
</button>
))}
</div>
) : (
<p className="muted"><span className="spin" aria-hidden="true" /> {t("prov.loadingConfig")}</p>
)}
</section>
)}

{mostUsed.length > 0 ? (
<section className="pws-dashboard-section" aria-label={t("pws.dashboard.recentlyUsed")}>
<section className="pws-dashboard-section" aria-label={t("pws.dashboard.recentlyUsed")} aria-busy={usageLoading || undefined}>
<h3 className="pws-dashboard-section-title">{t("pws.dashboard.recentlyUsed")}</h3>
<div className="pws-dashboard-rows">
{mostUsed.map(provider => (
Expand All @@ -187,9 +195,9 @@ export default function ProviderOverviewDashboard({
</div>
</section>
) : (
<section className="pws-dashboard-section" aria-label={t("pws.dashboard.recentlyUsed")}>
<section className="pws-dashboard-section" aria-label={t("pws.dashboard.recentlyUsed")} aria-busy={usageLoading || undefined}>
<h3 className="pws-dashboard-section-title">{t("pws.dashboard.recentlyUsed")}</h3>
<p className="muted">{t("pws.dashboard.noUsage")}</p>
<p className="muted">{usageLoading ? <><span className="spin" aria-hidden="true" /> {t("prov.loadingConfig")}</> : t("pws.dashboard.noUsage")}</p>
</section>
)}
</div>
Expand Down
120 changes: 69 additions & 51 deletions gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ export default function ProviderWorkspaceShell({
const [usageTotals, setUsageTotals] = useState<Record<string, ProviderUsageTotals>>({});
const [usageModels, setUsageModels] = useState<Record<string, ProviderModelUsageRow[]>>({});
const [quotaReports, setQuotaReports] = useState<Record<string, ProviderQuotaReportView>>({});
const [usageLoading, setUsageLoading] = useState(true);
const [quotasLoading, setQuotasLoading] = useState(true);
const [modelsLoadEpoch, setModelsLoadEpoch] = useState(0);
const filterWrapRef = useRef<HTMLDivElement>(null);

Expand Down Expand Up @@ -152,61 +154,75 @@ export default function ProviderWorkspaceShell({

useEffect(() => {
let cancelled = false;
fetch(`${apiBase}/api/usage?range=30d`)
.then(r => readJsonIfOk<{
providers?: Array<{ provider: string; requests: number; totalTokens?: number }>;
models?: Array<{ provider: string; model: string; resolvedModel?: string; requests: number; totalTokens: number; inputTokens: number; outputTokens: number; shareRatio: number; estimatedCostUsd?: number }>;
}>(r))
.then((data) => {
if (cancelled || !data) return;
const byProvider: Record<string, ProviderUsageTotals> = {};
for (const p of data.providers ?? []) byProvider[p.provider] = { requests: p.requests, totalTokens: p.totalTokens };
setUsageTotals(byProvider);
// Group model rows by provider
const byProviderModels: Record<string, ProviderModelUsageRow[]> = {};
for (const m of data.models ?? []) {
const key = m.provider;
if (!byProviderModels[key]) byProviderModels[key] = [];
byProviderModels[key].push({
model: m.model,
...(m.resolvedModel ? { resolvedModel: m.resolvedModel } : {}),
requests: m.requests,
totalTokens: m.totalTokens,
inputTokens: m.inputTokens,
outputTokens: m.outputTokens,
shareRatio: m.shareRatio,
...(m.estimatedCostUsd !== undefined ? { estimatedCostUsd: m.estimatedCostUsd } : {}),
});
}
setUsageModels(byProviderModels);
})
.catch(() => {});
return () => { cancelled = true; };
const timeout = window.setTimeout(() => {
setUsageLoading(true);
void fetch(`${apiBase}/api/usage?range=30d`)
.then(r => readJsonIfOk<{
providers?: Array<{ provider: string; requests: number; totalTokens?: number }>;
models?: Array<{ provider: string; model: string; resolvedModel?: string; requests: number; totalTokens: number; inputTokens: number; outputTokens: number; shareRatio: number; estimatedCostUsd?: number }>;
}>(r))
.then((data) => {
if (cancelled || !data) return;
const byProvider: Record<string, ProviderUsageTotals> = {};
for (const p of data.providers ?? []) byProvider[p.provider] = { requests: p.requests, totalTokens: p.totalTokens };
setUsageTotals(byProvider);
// Group model rows by provider
const byProviderModels: Record<string, ProviderModelUsageRow[]> = {};
for (const m of data.models ?? []) {
const key = m.provider;
if (!byProviderModels[key]) byProviderModels[key] = [];
byProviderModels[key].push({
model: m.model,
...(m.resolvedModel ? { resolvedModel: m.resolvedModel } : {}),
requests: m.requests,
totalTokens: m.totalTokens,
inputTokens: m.inputTokens,
outputTokens: m.outputTokens,
shareRatio: m.shareRatio,
...(m.estimatedCostUsd !== undefined ? { estimatedCostUsd: m.estimatedCostUsd } : {}),
});
}
setUsageModels(byProviderModels);
})
.catch(() => {})
.finally(() => { if (!cancelled) setUsageLoading(false); });
}, 0);
return () => {
cancelled = true;
window.clearTimeout(timeout);
};
}, [apiBase]);

useEffect(() => {
let cancelled = false;
fetch(`${apiBase}/api/provider-quotas`)
.then(r => readJsonIfOk<{ reports?: Array<{ provider: string; label?: string; source?: string; updatedAt?: number; quota?: unknown }> }>(r))
.then((data) => {
if (cancelled || !data) return;
// Merge so a partial/failed probe cannot wipe a previously good provider row.
setQuotaReports(prev => {
const next = { ...prev };
for (const report of data.reports ?? []) {
if (!report?.provider) continue;
next[report.provider] = {
label: report.label,
source: report.source,
updatedAt: typeof report.updatedAt === "number" ? report.updatedAt : Date.now(),
quota: report.quota,
};
}
return next;
});
})
.catch(() => { /* keep last-good */ });
return () => { cancelled = true; };
const timeout = window.setTimeout(() => {
setQuotasLoading(true);
void fetch(`${apiBase}/api/provider-quotas`)
.then(r => readJsonIfOk<{ reports?: Array<{ provider: string; label?: string; source?: string; updatedAt?: number; quota?: unknown }> }>(r))
.then((data) => {
if (cancelled || !data) return;
// Merge so a partial/failed probe cannot wipe a previously good provider row.
setQuotaReports(prev => {
const next = { ...prev };
for (const report of data.reports ?? []) {
if (!report?.provider) continue;
next[report.provider] = {
label: report.label,
source: report.source,
updatedAt: typeof report.updatedAt === "number" ? report.updatedAt : Date.now(),
quota: report.quota,
};
}
return next;
});
})
.catch(() => { /* keep last-good */ })
.finally(() => { if (!cancelled) setQuotasLoading(false); });
}, 0);
return () => {
cancelled = true;
window.clearTimeout(timeout);
};
// Key on active-account identity (not the reauth boolean map) so switching between two
// healthy accounts still re-reads /api/provider-quotas for the Usage/overview bars.
}, [apiBase, quotaRefreshKey]);
Expand Down Expand Up @@ -514,6 +530,8 @@ export default function ProviderWorkspaceShell({
sections={sections}
quotaReports={quotaReports}
usageTotals={usageTotals}
usageLoading={usageLoading}
quotasLoading={quotasLoading}
onSelectProvider={(name) => onSelect(name)}
onEditConfig={onEditConfig}
/>
Expand Down
40 changes: 35 additions & 5 deletions gui/src/pages/Models.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,6 @@ export default function Models({ apiBase }: { apiBase: string }) {
throw new Error("models payload missing");
}
if (!shouldApplyLoadGeneration(generation, loadGenerationRef.current)) return false;
void loadV2(); // best-effort, independent of the models fetch
void loadShadowCall();
const nextGroups = buildProviderModelGroups(data, providerData);
setSelectedProvider(prev => (
prev !== null && !nextGroups.some(group => group.provider === prev)
Expand Down Expand Up @@ -204,7 +202,23 @@ export default function Models({ apiBase }: { apiBase: string }) {
setLoading(false);
}
}
}, [apiBase, loadShadowCall, loadV2, t]);
}, [apiBase, t]);

// Shadow/v2 controls must not wait on the models catalog (live discovery can be slow).
useEffect(() => {
const timeout = window.setTimeout(() => {
void loadShadowCall();
void loadV2();
}, 0);
const timer = window.setInterval(() => {
if (!v2BusyRef.current) void loadV2();
}, 10000);
return () => {
window.clearTimeout(timeout);
window.clearInterval(timer);
};
}, [loadShadowCall, loadV2]);

useEffect(() => {
const timeout = window.setTimeout(() => {
void load();
Expand Down Expand Up @@ -562,7 +576,23 @@ export default function Models({ apiBase }: { apiBase: string }) {
}
};

if (loading) return <div className="row muted"><span className="spin" /> {t("models.loading")}</div>;
if (loading) {
return (
<>
<div className="models-control-top-row">
<div className="models-shadow-row row muted text-control" aria-busy={!shadowCall || undefined}>
<span className="models-shadow-label">{t("models.shadowCallIntercept")} <Tooltip content={t("models.shadowCallInterceptHint")} side="top" maxWidth={320}><span style={{ cursor: "help" }} aria-label={t("models.shadowCallInterceptHint")}>ⓘ</span></Tooltip></span>
<code className="text-caption models-shadow-warning" style={{ opacity: 0.6 }}>{t("models.shadowCallOriginal")}</code>
<Switch on={shadowCall?.enabled ?? false} onClick={() => void saveShadowCall({ enabled: !shadowCall?.enabled })} disabled={!shadowCall || shadowCallSaving} label={t("models.shadowCallIntercept")} />
<div className="models-shadow-model-slot">
<Select value={shadowCall?.model ?? ""} options={[{ value: "", label: "\u2014" }, ...shadowModelOptions]} onChange={v => { setShadowCall(c => c ? { ...c, model: v } : c); void saveShadowCall({ model: v }); }} disabled={!shadowCall || shadowCallSaving || !shadowCall.enabled} label={t("models.shadowCallIntercept")} />
</div>
</div>
</div>
<div className="row muted"><span className="spin" /> {t("models.loading")}</div>
</>
);
}
if (!selectedModels) {
return <Notice tone="err">{t("models.loadFail")}</Notice>;
}
Expand Down Expand Up @@ -806,7 +836,7 @@ export default function Models({ apiBase }: { apiBase: string }) {
const controlsBlock = (
<>
<div className="models-control-top-row">
<div className="models-shadow-row row muted text-control">
<div className="models-shadow-row row muted text-control" aria-busy={!shadowCall || undefined}>
<span className="models-shadow-label">{t("models.shadowCallIntercept")} <Tooltip content={t("models.shadowCallInterceptHint")} side="top" maxWidth={320}><span style={{ cursor: "help" }} aria-label={t("models.shadowCallInterceptHint")}>ⓘ</span></Tooltip></span>
<code className="text-caption models-shadow-warning" style={{ opacity: 0.6 }}>{t("models.shadowCallOriginal")}</code>
<Switch on={shadowCall?.enabled ?? false} onClick={() => void saveShadowCall({ enabled: !shadowCall?.enabled })} disabled={!shadowCall || shadowCallSaving} label={t("models.shadowCallIntercept")} />
Expand Down
Loading
Loading