Skip to content

Commit 2852f6c

Browse files
perf(ui): cache dashboard stats between loads
Reuse the last dashboard snapshot so the home view renders immediately while a fresh stats scan runs in the background, and show a fuller loading scaffold for cold starts. Co-Authored-By: chatgpt-codex-connector[bot] <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com>
1 parent 94d515f commit 2852f6c

3 files changed

Lines changed: 193 additions & 8 deletions

File tree

packages/ui-components/src/sessions/DashboardStats.tsx

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const STATS_ROW_CLASSES = "grid gap-[12px]";
99
const STATS_ROW_3_CLASSES = "grid-cols-3";
1010
const STATS_ROW_4_CLASSES = "grid-cols-4";
1111
const STAT_CARD_CLASSES = "border border-border-muted bg-surface-muted px-[18px] py-[14px]";
12-
const STAT_CARD_SKELETON_CLASSES = "h-[64px] opacity-40";
12+
const STAT_CARD_SKELETON_CLASSES = "animate-pulse";
1313
const STAT_VALUE_CLASSES = "text-[1.3rem] font-bold leading-[1.2] whitespace-nowrap text-foreground";
1414
const STAT_LABEL_CLASSES =
1515
"mt-[2px] text-[0.7rem] font-semibold uppercase tracking-[0.04em] whitespace-nowrap text-foreground-subtle";
@@ -19,6 +19,15 @@ const MODEL_LIST_CLASSES = "mt-[6px] mb-0 list-none p-0";
1919
const MODEL_LIST_ITEM_CLASSES = "flex items-center justify-between py-[2px] text-[0.8rem]";
2020
const MODEL_NAME_CLASSES = "font-mono text-[0.78rem] text-foreground-muted";
2121
const MODEL_COUNT_CLASSES = "text-[0.75rem] text-foreground-subtle";
22+
const STATS_STATUS_CLASSES = "text-[0.72rem] font-semibold uppercase tracking-[0.06em] text-foreground-subtle";
23+
const SKELETON_BAR_CLASSES = "rounded-sm bg-border-muted/70";
24+
const SKELETON_VALUE_CLASSES = `${SKELETON_BAR_CLASSES} h-[22px] w-[58%]`;
25+
const SKELETON_LABEL_CLASSES = `${SKELETON_BAR_CLASSES} mt-[10px] h-[10px] w-[42%]`;
26+
const SKELETON_TOKEN_VALUE_CLASSES = `${SKELETON_BAR_CLASSES} h-[20px] w-[70%]`;
27+
const SKELETON_TOKEN_LABEL_CLASSES = `${SKELETON_BAR_CLASSES} mt-[8px] h-[9px] w-[48%]`;
28+
const SKELETON_MODEL_ROW_CLASSES = "flex items-center justify-between gap-[12px]";
29+
const SKELETON_MODEL_NAME_CLASSES = `${SKELETON_BAR_CLASSES} h-[10px] w-[44%]`;
30+
const SKELETON_MODEL_COUNT_CLASSES = `${SKELETON_BAR_CLASSES} h-[10px] w-[24%]`;
2231

2332
const ONE_BILLION = 1_000_000_000;
2433
const ONE_MILLION = 1_000_000;
@@ -49,19 +58,54 @@ function totalTokens(usage: ModelTokenUsage): number {
4958
export type DashboardStatsProps = {
5059
stats: Stats | null;
5160
loading?: boolean | undefined;
61+
refreshing?: boolean | undefined;
5262
error?: string | undefined;
5363
onRetry?: (() => void) | undefined;
5464
};
5565

56-
export function DashboardStats({ stats, loading, error, onRetry }: DashboardStatsProps) {
66+
export function DashboardStats({ stats, loading, refreshing, error, onRetry }: DashboardStatsProps) {
5767
if (loading) {
5868
return (
59-
<div className={DASHBOARD_STATS_CLASSES}>
69+
<div className={DASHBOARD_STATS_CLASSES} aria-busy="true" role="status" aria-label="Loading stats">
70+
<div className={STATS_STATUS_CLASSES}>Loading stats...</div>
6071
<div className={`${STATS_ROW_CLASSES} ${STATS_ROW_3_CLASSES}`}>
6172
{["skeleton-0", "skeleton-1", "skeleton-2"].map((key) => (
62-
<div key={key} className={`${STAT_CARD_CLASSES} ${STAT_CARD_SKELETON_CLASSES}`} />
73+
<div key={key} className={`${STAT_CARD_CLASSES} ${STAT_CARD_SKELETON_CLASSES}`}>
74+
<div className={SKELETON_VALUE_CLASSES} />
75+
<div className={SKELETON_LABEL_CLASSES} />
76+
</div>
6377
))}
6478
</div>
79+
<div className={`${STATS_ROW_CLASSES} ${STATS_ROW_3_CLASSES}`}>
80+
{["skeleton-3", "skeleton-4", "skeleton-5"].map((key) => (
81+
<div key={key} className={`${STAT_CARD_CLASSES} ${STAT_CARD_SKELETON_CLASSES}`}>
82+
<div className={SKELETON_VALUE_CLASSES} />
83+
<div className={SKELETON_LABEL_CLASSES} />
84+
</div>
85+
))}
86+
</div>
87+
<div className={`${STAT_CARD_CLASSES} ${STAT_CARD_SKELETON_CLASSES}`}>
88+
<div className={`${SKELETON_LABEL_CLASSES} mt-0 w-[18%]`} />
89+
<div className={`${STATS_ROW_CLASSES} ${STATS_ROW_4_CLASSES} ${TOKEN_ROW_CLASSES}`}>
90+
{["token-0", "token-1", "token-2", "token-3"].map((key) => (
91+
<div key={key}>
92+
<div className={SKELETON_TOKEN_VALUE_CLASSES} />
93+
<div className={SKELETON_TOKEN_LABEL_CLASSES} />
94+
</div>
95+
))}
96+
</div>
97+
</div>
98+
<div className={`${STAT_CARD_CLASSES} ${STAT_CARD_SKELETON_CLASSES}`}>
99+
<div className={`${SKELETON_LABEL_CLASSES} mt-0 w-[14%]`} />
100+
<ul className={MODEL_LIST_CLASSES}>
101+
{["model-0", "model-1", "model-2", "model-3"].map((key) => (
102+
<li key={key} className={`${MODEL_LIST_ITEM_CLASSES} ${SKELETON_MODEL_ROW_CLASSES}`}>
103+
<div className={SKELETON_MODEL_NAME_CLASSES} />
104+
<div className={SKELETON_MODEL_COUNT_CLASSES} />
105+
</li>
106+
))}
107+
</ul>
108+
</div>
65109
</div>
66110
);
67111
}
@@ -78,6 +122,7 @@ export function DashboardStats({ stats, loading, error, onRetry }: DashboardStat
78122

79123
return (
80124
<div className={DASHBOARD_STATS_CLASSES}>
125+
{refreshing ? <div className={STATS_STATUS_CLASSES}>Refreshing stats...</div> : null}
81126
<div className={`${STATS_ROW_CLASSES} ${STATS_ROW_3_CLASSES}`}>
82127
<div className={STAT_CARD_CLASSES}>
83128
<div className={STAT_VALUE_CLASSES}>{fmt.format(stats.projects)}</div>

packages/ui/src/app/components/dashboard/PackageDashboardStats.test.tsx

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import { afterEach, describe, expect, test } from "bun:test";
1+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
22
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
33
import type { DashboardStats } from "../../../shared/types.ts";
44
import { MockProviders, setupMockRPC } from "../../test-helpers/mock-rpc.ts";
55
import { PackageDashboardStats } from "./PackageDashboardStats.tsx";
66

7+
const STATS_CACHE_KEY = "klovi-dashboard-stats";
8+
79
function makeStats(projects: number): DashboardStats {
810
return {
911
projects: projects,
@@ -21,7 +23,62 @@ function makeStats(projects: number): DashboardStats {
2123
}
2224

2325
describe("PackageDashboardStats", () => {
24-
afterEach(cleanup);
26+
beforeEach(() => {
27+
localStorage.removeItem(STATS_CACHE_KEY);
28+
});
29+
30+
afterEach(() => {
31+
cleanup();
32+
localStorage.removeItem(STATS_CACHE_KEY);
33+
});
34+
35+
test("shows a scaffold on a cold load", () => {
36+
setupMockRPC({
37+
getStats: () => new Promise(() => {}),
38+
});
39+
40+
render(<PackageDashboardStats />, { wrapper: MockProviders });
41+
42+
expect(screen.getByRole("status", { name: "Loading stats" })).toBeDefined();
43+
expect(screen.getByText("Loading stats...")).toBeDefined();
44+
});
45+
46+
test("hydrates from cached stats before the fresh request resolves", async () => {
47+
let resolveStats: ((value: { stats: DashboardStats }) => void) | null = null;
48+
localStorage.setItem(
49+
STATS_CACHE_KEY,
50+
JSON.stringify({
51+
version: 1,
52+
stats: makeStats(4),
53+
}),
54+
);
55+
56+
setupMockRPC({
57+
getStats: () =>
58+
new Promise((resolve) => {
59+
resolveStats = resolve;
60+
}),
61+
});
62+
63+
render(<PackageDashboardStats />, { wrapper: MockProviders });
64+
65+
await waitFor(() => {
66+
const projectsLabel = screen.getByText("Projects");
67+
expect(projectsLabel.previousSibling?.textContent).toBe("4");
68+
});
69+
expect(screen.getByText("Refreshing stats...")).toBeDefined();
70+
71+
await act(async () => {
72+
resolveStats?.({ stats: makeStats(7) });
73+
await Promise.resolve();
74+
});
75+
76+
await waitFor(() => {
77+
const projectsLabel = screen.getByText("Projects");
78+
expect(projectsLabel.previousSibling?.textContent).toBe("7");
79+
});
80+
expect(screen.queryByText("Refreshing stats...")).toBeNull();
81+
});
2582

2683
test("updates when the desktop host pushes refreshed stats", async () => {
2784
let listener: ((stats: DashboardStats) => void) | null = null;

packages/ui/src/app/components/dashboard/PackageDashboardStats.tsx

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,28 +5,111 @@ import { getRpcErrorMessage } from "../../../lib/rpc-errors-effect.ts";
55
import type { DashboardStats as Stats } from "../../../shared/types.ts";
66
import { useEffectQuery } from "../../hooks/useEffectQuery.ts";
77

8+
type StatsCacheStoreV1 = {
9+
version: 1;
10+
stats: Stats;
11+
};
12+
13+
const STATS_CACHE_KEY = "klovi-dashboard-stats";
14+
15+
function isStats(value: unknown): value is Stats {
16+
if (typeof value !== "object" || value === null) {
17+
return false;
18+
}
19+
20+
const candidate = value as Record<string, unknown>;
21+
const requiredNumberFields = [
22+
"projects",
23+
"sessions",
24+
"messages",
25+
"todaySessions",
26+
"thisWeekSessions",
27+
"inputTokens",
28+
"outputTokens",
29+
"cacheReadTokens",
30+
"cacheCreationTokens",
31+
"toolCalls",
32+
] as const;
33+
34+
for (const field of requiredNumberFields) {
35+
if (typeof candidate[field] !== "number") {
36+
return false;
37+
}
38+
}
39+
40+
return typeof candidate["models"] === "object" && candidate["models"] !== null;
41+
}
42+
43+
function loadCachedStats(): Stats | null {
44+
try {
45+
const raw = localStorage.getItem(STATS_CACHE_KEY);
46+
if (!raw) {
47+
return null;
48+
}
49+
50+
const parsed: unknown = JSON.parse(raw);
51+
if (
52+
typeof parsed === "object" &&
53+
parsed !== null &&
54+
"version" in parsed &&
55+
(parsed as StatsCacheStoreV1).version === 1 &&
56+
"stats" in parsed &&
57+
isStats((parsed as StatsCacheStoreV1).stats)
58+
) {
59+
return (parsed as StatsCacheStoreV1).stats;
60+
}
61+
} catch {
62+
// Ignore corrupted cache and fall back to a cold load.
63+
}
64+
65+
return null;
66+
}
67+
68+
function persistCachedStats(stats: Stats): void {
69+
try {
70+
const store: StatsCacheStoreV1 = {
71+
version: 1,
72+
stats: stats,
73+
};
74+
localStorage.setItem(STATS_CACHE_KEY, JSON.stringify(store));
75+
} catch {
76+
// Ignore storage failures; the dashboard still works without persistence.
77+
}
78+
}
79+
880
export function PackageDashboardStats() {
981
const client = useKloviClient();
1082
const hostBridge = useKloviHostBridge();
1183
const { data, loading, error, retry } = useEffectQuery<{ stats: Stats }>(() => client.getStats(), [client]);
12-
const [stats, setStats] = useState<Stats | null>(null);
84+
const [stats, setStats] = useState<Stats | null>(() => loadCachedStats());
1385

1486
useEffect(() => {
1587
if (!data?.stats) {
1688
return;
1789
}
90+
persistCachedStats(data.stats);
1891
setStats(data.stats);
1992
}, [data]);
2093

2194
useEffect(
2295
() =>
2396
hostBridge.onStatsUpdated((nextStats) => {
97+
persistCachedStats(nextStats);
2498
setStats(nextStats);
2599
}),
26100
[hostBridge],
27101
);
28102
const isLoading = loading && stats === null;
103+
const isRefreshing = loading && stats !== null;
29104
const errorMessage = stats !== null || !error ? undefined : getRpcErrorMessage(error);
30105

31-
return <UiDashboardStats stats={stats} loading={isLoading} error={errorMessage} onRetry={retry} />;
106+
return (
107+
<UiDashboardStats
108+
stats={stats}
109+
loading={isLoading}
110+
refreshing={isRefreshing}
111+
error={errorMessage}
112+
onRetry={retry}
113+
/>
114+
);
32115
}

0 commit comments

Comments
 (0)