Skip to content

Commit f205783

Browse files
committed
feat: surface home account limits across usage views
1 parent 9f55e3a commit f205783

12 files changed

Lines changed: 555 additions & 45 deletions

src/features/app/components/MainApp.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import { useMainAppSidebarMenuOrchestration } from "@app/hooks/useMainAppSidebar
4747
import { useMainAppWorktreeState } from "@app/hooks/useMainAppWorktreeState";
4848
import { useMainAppWorkspaceActions } from "@app/hooks/useMainAppWorkspaceActions";
4949
import { useMainAppWorkspaceLifecycle } from "@app/hooks/useMainAppWorkspaceLifecycle";
50+
import { useHomeAccount } from "@app/hooks/useHomeAccount";
5051
import type {
5152
ComposerEditorSettings,
5253
ServiceTier,
@@ -1170,6 +1171,18 @@ export default function MainApp() {
11701171
const activeRateLimits = activeWorkspaceId
11711172
? rateLimitsByWorkspace[activeWorkspaceId] ?? null
11721173
: null;
1174+
const {
1175+
homeAccount,
1176+
homeRateLimits,
1177+
} = useHomeAccount({
1178+
showHome,
1179+
usageWorkspaceId,
1180+
workspaces,
1181+
rateLimitsByWorkspace,
1182+
accountByWorkspace,
1183+
refreshAccountInfo,
1184+
refreshAccountRateLimits,
1185+
});
11731186
const activeTokenUsage = activeThreadId
11741187
? tokenUsageByThread[activeThreadId] ?? null
11751188
: null;
@@ -1707,6 +1720,8 @@ export default function MainApp() {
17071720
approvals,
17081721
activeRateLimits,
17091722
activeAccount,
1723+
homeRateLimits,
1724+
homeAccount,
17101725
accountSwitching,
17111726
onSwitchAccount: handleSwitchAccount,
17121727
onCancelSwitchAccount: handleCancelSwitchAccount,

src/features/app/components/Sidebar.test.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,30 @@ describe("Sidebar", () => {
141141
expect(onSetThreadListOrganizeMode).toHaveBeenCalledWith("threads_only");
142142
});
143143

144+
it("renders available credits in the footer when present", () => {
145+
render(
146+
<Sidebar
147+
{...baseProps}
148+
accountRateLimits={{
149+
primary: {
150+
usedPercent: 62,
151+
windowDurationMins: 300,
152+
resetsAt: Math.round(Date.now() / 1000) + 3600,
153+
},
154+
secondary: null,
155+
credits: {
156+
hasCredits: true,
157+
unlimited: false,
158+
balance: "120",
159+
},
160+
planType: "pro",
161+
}}
162+
/>,
163+
);
164+
165+
expect(screen.getByText("Available credits: 120")).toBeTruthy();
166+
});
167+
144168
it("renders threads-only mode as a global chronological list", () => {
145169
const older = Date.now() - 10_000;
146170
const newer = Date.now();
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
// @vitest-environment jsdom
2+
import { renderHook, waitFor } from "@testing-library/react";
3+
import { describe, expect, it, vi } from "vitest";
4+
import type {
5+
AccountSnapshot,
6+
RateLimitSnapshot,
7+
WorkspaceInfo,
8+
} from "@/types";
9+
import {
10+
resolveHomeAccountWorkspaceId,
11+
useHomeAccount,
12+
} from "./useHomeAccount";
13+
14+
function makeWorkspace(
15+
id: string,
16+
overrides: Partial<WorkspaceInfo> = {},
17+
): WorkspaceInfo {
18+
return {
19+
id,
20+
name: id,
21+
path: `/tmp/${id}`,
22+
connected: true,
23+
settings: {
24+
sidebarCollapsed: false,
25+
},
26+
...overrides,
27+
};
28+
}
29+
30+
function makeAccount(
31+
overrides: Partial<AccountSnapshot> = {},
32+
): AccountSnapshot {
33+
return {
34+
type: "chatgpt",
35+
email: "user@example.com",
36+
planType: "pro",
37+
requiresOpenaiAuth: false,
38+
...overrides,
39+
};
40+
}
41+
42+
function makeRateLimits(
43+
overrides: Partial<RateLimitSnapshot> = {},
44+
): RateLimitSnapshot {
45+
return {
46+
primary: {
47+
usedPercent: 42,
48+
windowDurationMins: 300,
49+
resetsAt: 1_700_000_000,
50+
},
51+
secondary: null,
52+
credits: null,
53+
planType: "pro",
54+
...overrides,
55+
};
56+
}
57+
58+
describe("resolveHomeAccountWorkspaceId", () => {
59+
it("prefers the workspace selected from Home usage controls", () => {
60+
expect(
61+
resolveHomeAccountWorkspaceId({
62+
usageWorkspaceId: "ws-2",
63+
workspaces: [makeWorkspace("ws-1"), makeWorkspace("ws-2")],
64+
rateLimitsByWorkspace: { "ws-1": makeRateLimits() },
65+
accountByWorkspace: { "ws-1": makeAccount() },
66+
}),
67+
).toBe("ws-2");
68+
});
69+
70+
it("keeps Home unset for the All workspaces usage filter", () => {
71+
expect(
72+
resolveHomeAccountWorkspaceId({
73+
usageWorkspaceId: null,
74+
workspaces: [makeWorkspace("ws-1"), makeWorkspace("ws-2")],
75+
rateLimitsByWorkspace: { "ws-2": makeRateLimits() },
76+
accountByWorkspace: {},
77+
}),
78+
).toBeNull();
79+
});
80+
81+
it("ignores empty cached rate-limit snapshots when falling back from a stale selection", () => {
82+
expect(
83+
resolveHomeAccountWorkspaceId({
84+
usageWorkspaceId: "missing",
85+
workspaces: [makeWorkspace("ws-1"), makeWorkspace("ws-2")],
86+
rateLimitsByWorkspace: {
87+
"ws-1": makeRateLimits({
88+
primary: null,
89+
secondary: null,
90+
credits: null,
91+
planType: null,
92+
}),
93+
"ws-2": makeRateLimits(),
94+
},
95+
accountByWorkspace: {},
96+
}),
97+
).toBe("ws-2");
98+
});
99+
100+
it("prefers connected workspaces over disconnected cached data", () => {
101+
expect(
102+
resolveHomeAccountWorkspaceId({
103+
usageWorkspaceId: "missing",
104+
workspaces: [
105+
makeWorkspace("ws-1", { connected: false }),
106+
makeWorkspace("ws-2"),
107+
],
108+
rateLimitsByWorkspace: { "ws-1": makeRateLimits() },
109+
accountByWorkspace: {},
110+
}),
111+
).toBe("ws-2");
112+
});
113+
114+
it("prefers connected workspaces with current data over disconnected cached data", () => {
115+
expect(
116+
resolveHomeAccountWorkspaceId({
117+
usageWorkspaceId: "missing",
118+
workspaces: [
119+
makeWorkspace("ws-1", { connected: false }),
120+
makeWorkspace("ws-2"),
121+
],
122+
rateLimitsByWorkspace: {
123+
"ws-1": makeRateLimits({ primary: { usedPercent: 99, windowDurationMins: 300, resetsAt: 1_700_000_000 } }),
124+
"ws-2": makeRateLimits({ primary: { usedPercent: 42, windowDurationMins: 300, resetsAt: 1_700_000_000 } }),
125+
},
126+
accountByWorkspace: {
127+
"ws-1": makeAccount({ email: "stale@example.com" }),
128+
"ws-2": makeAccount({ email: "current@example.com" }),
129+
},
130+
}),
131+
).toBe("ws-2");
132+
});
133+
134+
it("skips placeholder unknown account snapshots when later workspaces have real data", () => {
135+
expect(
136+
resolveHomeAccountWorkspaceId({
137+
usageWorkspaceId: "missing",
138+
workspaces: [makeWorkspace("ws-1"), makeWorkspace("ws-2")],
139+
rateLimitsByWorkspace: {},
140+
accountByWorkspace: {
141+
"ws-1": makeAccount({
142+
type: "unknown",
143+
email: null,
144+
planType: null,
145+
}),
146+
"ws-2": makeAccount(),
147+
},
148+
}),
149+
).toBe("ws-2");
150+
});
151+
});
152+
153+
describe("useHomeAccount", () => {
154+
it("returns null Home account props for the All workspaces usage filter", async () => {
155+
const refreshAccountInfo = vi.fn();
156+
const refreshAccountRateLimits = vi.fn();
157+
const workspaces = [
158+
makeWorkspace("ws-1"),
159+
makeWorkspace("ws-2", { connected: false }),
160+
];
161+
162+
const { result } = renderHook(() =>
163+
useHomeAccount({
164+
showHome: true,
165+
usageWorkspaceId: null,
166+
workspaces,
167+
rateLimitsByWorkspace: { "ws-1": makeRateLimits() },
168+
accountByWorkspace: { "ws-1": makeAccount() },
169+
refreshAccountInfo,
170+
refreshAccountRateLimits,
171+
}),
172+
);
173+
174+
expect(result.current.homeAccountWorkspaceId).toBeNull();
175+
expect(result.current.homeAccountWorkspace).toBeNull();
176+
expect(result.current.homeAccount).toBeNull();
177+
expect(result.current.homeRateLimits).toBeNull();
178+
179+
await waitFor(() => {
180+
expect(refreshAccountInfo).not.toHaveBeenCalled();
181+
expect(refreshAccountRateLimits).not.toHaveBeenCalled();
182+
});
183+
});
184+
185+
it("returns Home account props from the selected workspace and refreshes them on Home", async () => {
186+
const refreshAccountInfo = vi.fn();
187+
const refreshAccountRateLimits = vi.fn();
188+
const workspaces = [
189+
makeWorkspace("ws-1"),
190+
makeWorkspace("ws-2", { connected: false }),
191+
];
192+
193+
const { result } = renderHook(() =>
194+
useHomeAccount({
195+
showHome: true,
196+
usageWorkspaceId: "ws-1",
197+
workspaces,
198+
rateLimitsByWorkspace: { "ws-1": makeRateLimits() },
199+
accountByWorkspace: { "ws-1": makeAccount() },
200+
refreshAccountInfo,
201+
refreshAccountRateLimits,
202+
}),
203+
);
204+
205+
expect(result.current.homeAccountWorkspaceId).toBe("ws-1");
206+
expect(result.current.homeAccountWorkspace?.name).toBe("ws-1");
207+
expect(result.current.homeAccount?.email).toBe("user@example.com");
208+
expect(result.current.homeRateLimits?.primary?.usedPercent).toBe(42);
209+
210+
await waitFor(() => {
211+
expect(refreshAccountInfo).toHaveBeenCalledWith("ws-1");
212+
expect(refreshAccountRateLimits).toHaveBeenCalledWith("ws-1");
213+
});
214+
});
215+
216+
it("refreshes the first connected workspace when a stale selection points elsewhere", async () => {
217+
const refreshAccountInfo = vi.fn();
218+
const refreshAccountRateLimits = vi.fn();
219+
220+
const { result } = renderHook(() =>
221+
useHomeAccount({
222+
showHome: true,
223+
usageWorkspaceId: "missing",
224+
workspaces: [
225+
makeWorkspace("ws-1", { connected: false }),
226+
makeWorkspace("ws-2"),
227+
],
228+
rateLimitsByWorkspace: { "ws-1": makeRateLimits() },
229+
accountByWorkspace: { "ws-1": makeAccount() },
230+
refreshAccountInfo,
231+
refreshAccountRateLimits,
232+
}),
233+
);
234+
235+
expect(result.current.homeAccountWorkspaceId).toBe("ws-2");
236+
expect(result.current.homeAccountWorkspace?.name).toBe("ws-2");
237+
expect(result.current.homeAccount).toBeNull();
238+
expect(result.current.homeRateLimits).toBeNull();
239+
240+
await waitFor(() => {
241+
expect(refreshAccountInfo).toHaveBeenCalledWith("ws-2");
242+
expect(refreshAccountRateLimits).toHaveBeenCalledWith("ws-2");
243+
});
244+
});
245+
246+
it("does not refresh account state when Home is hidden", async () => {
247+
const refreshAccountInfo = vi.fn();
248+
const refreshAccountRateLimits = vi.fn();
249+
250+
renderHook(() =>
251+
useHomeAccount({
252+
showHome: false,
253+
usageWorkspaceId: "ws-1",
254+
workspaces: [makeWorkspace("ws-1")],
255+
rateLimitsByWorkspace: { "ws-1": makeRateLimits() },
256+
accountByWorkspace: { "ws-1": makeAccount() },
257+
refreshAccountInfo,
258+
refreshAccountRateLimits,
259+
}),
260+
);
261+
262+
await waitFor(() => {
263+
expect(refreshAccountInfo).not.toHaveBeenCalled();
264+
expect(refreshAccountRateLimits).not.toHaveBeenCalled();
265+
});
266+
});
267+
});

0 commit comments

Comments
 (0)