diff --git a/devlog/gui-load-perf-hot-before-after.md b/devlog/gui-load-perf-hot-before-after.md new file mode 100644 index 000000000..116879525 --- /dev/null +++ b/devlog/gui-load-perf-hot-before-after.md @@ -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. diff --git a/gui/src/components/provider-catalog/ProviderCatalog.tsx b/gui/src/components/provider-catalog/ProviderCatalog.tsx index 1e8a659e8..dc093bf6a 100644 --- a/gui/src/components/provider-catalog/ProviderCatalog.tsx +++ b/gui/src/components/provider-catalog/ProviderCatalog.tsx @@ -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]; diff --git a/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx b/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx index 393d43abe..7986740af 100644 --- a/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx +++ b/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx @@ -26,12 +26,16 @@ export default function ProviderOverviewDashboard({ sections, quotaReports, usageTotals, + usageLoading = false, + quotasLoading = false, onSelectProvider, onEditConfig, }: { sections: WorkspaceSections; quotaReports: Record; usageTotals: Record; + usageLoading?: boolean; + quotasLoading?: boolean; onSelectProvider: (name: string) => void; onEditConfig?: () => void; }) { @@ -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], ); @@ -132,9 +136,10 @@ export default function ProviderOverviewDashboard({ )}
- {quotaProviders.length > 0 && ( -
+ {(quotaProviders.length > 0 || quotasLoading) && ( +

{t("pws.dashboard.rateLimits")}

+ {quotaProviders.length > 0 ? (
{quotaProviders.map(({ item, report }) => (
+ ) : ( +

+ )}
)} {mostUsed.length > 0 ? ( -
+

{t("pws.dashboard.recentlyUsed")}

{mostUsed.map(provider => ( @@ -187,9 +195,9 @@ export default function ProviderOverviewDashboard({
) : ( -
+

{t("pws.dashboard.recentlyUsed")}

-

{t("pws.dashboard.noUsage")}

+

{usageLoading ? <>

)}
diff --git a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx index 869a867fe..c4375c6a7 100644 --- a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx +++ b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx @@ -108,6 +108,8 @@ export default function ProviderWorkspaceShell({ const [usageTotals, setUsageTotals] = useState>({}); const [usageModels, setUsageModels] = useState>({}); const [quotaReports, setQuotaReports] = useState>({}); + const [usageLoading, setUsageLoading] = useState(true); + const [quotasLoading, setQuotasLoading] = useState(true); const [modelsLoadEpoch, setModelsLoadEpoch] = useState(0); const filterWrapRef = useRef(null); @@ -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 = {}; - 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 = {}; - 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 = {}; + 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 = {}; + 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]); @@ -514,6 +530,8 @@ export default function ProviderWorkspaceShell({ sections={sections} quotaReports={quotaReports} usageTotals={usageTotals} + usageLoading={usageLoading} + quotasLoading={quotasLoading} onSelectProvider={(name) => onSelect(name)} onEditConfig={onEditConfig} /> diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 3b4ab9db5..db36e7403 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -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) @@ -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(); @@ -562,7 +576,23 @@ export default function Models({ apiBase }: { apiBase: string }) { } }; - if (loading) return
{t("models.loading")}
; + if (loading) { + return ( + <> +
+
+ {t("models.shadowCallIntercept")} + {t("models.shadowCallOriginal")} + void saveShadowCall({ enabled: !shadowCall?.enabled })} disabled={!shadowCall || shadowCallSaving} label={t("models.shadowCallIntercept")} /> +
+ {t("dash.webSearchSidecarHint")}
-
+
{t("dash.visionSidecar")}