Skip to content

Commit 5f950ec

Browse files
itizarsaclaude
andauthored
Make remaining usage and email masking opt-in settings (#592)
* Make remaining usage and email masking opt-in settings Usage meters fill with used capacity and account emails show as plain text by default again. Settings > Providers gains a Usage and privacy group with Show remaining usage and Mask account emails toggles. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01VE68N84bAmxeHW6pC8m413 * Keep usage and email settings in sync across windows - Hold a saved value in memory when localStorage rejects the write - Re-read either setting when another window saves it - Blur a revealed email again when masking is turned back on Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01VE68N84bAmxeHW6pC8m413 --------- Co-authored-by: Claude <[email protected]>
1 parent 9381071 commit 5f950ec

12 files changed

Lines changed: 450 additions & 9 deletions

‎src/app/shell/UsageProviderChip.test.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ import type { ProviderRateLimits } from "../../features/providers/model/rateLimi
88
import { projectKey } from "../../shared/lib/paths";
99
import { saveTabGroupMascot } from "../../features/workspace/model/tabGroups";
1010
import { needsProviderLogin, UsageProviderChip } from "./UsageProviderChip";
11+
import {
12+
saveMaskEmails,
13+
saveShowRemainingUsage,
14+
} from "../../features/settings/model/displayPrefs";
1115

1216
vi.mock("@tauri-apps/api/core", () => ({
1317
invoke: vi.fn(async () => null),
@@ -128,6 +132,7 @@ describe("UsageProviderChip", () => {
128132
});
129133

130134
it("opens a column of detailed progress bars", async () => {
135+
saveShowRemainingUsage(true);
131136
act(() =>
132137
root.render(
133138
createElement(UsageProviderChip, { limits: codexLimits(), now }),
@@ -164,7 +169,30 @@ describe("UsageProviderChip", () => {
164169
);
165170
});
166171

172+
it("fills bars with used capacity by default", async () => {
173+
act(() =>
174+
root.render(
175+
createElement(UsageProviderChip, { limits: codexLimits(), now }),
176+
),
177+
);
178+
179+
const trigger = button("Codex usage details");
180+
expect(trigger.querySelector(".w-8 > span")?.getAttribute("style")).toBe(
181+
"width: 81%;",
182+
);
183+
await act(async () => trigger.click());
184+
185+
const weeklyBar = document.querySelector(
186+
'[role="dialog"] [aria-label="Weekly limit used"]',
187+
);
188+
expect(weeklyBar?.getAttribute("aria-valuenow")).toBe("81");
189+
expect(weeklyBar?.querySelector("span")?.getAttribute("style")).toBe(
190+
"width: 81%;",
191+
);
192+
});
193+
167194
it("shows a full bar before usage and an empty bar when exhausted", async () => {
195+
saveShowRemainingUsage(true);
168196
const limits = codexLimits();
169197
limits.session!.usedPercent = 0;
170198
limits.weekly!.usedPercent = 100;
@@ -195,6 +223,7 @@ describe("UsageProviderChip", () => {
195223
});
196224

197225
it("switches between named accounts from the usage popover", async () => {
226+
saveShowRemainingUsage(true);
198227
const onSelectAccount = vi.fn();
199228
act(() =>
200229
root.render(
@@ -237,6 +266,7 @@ describe("UsageProviderChip", () => {
237266
});
238267

239268
it("reveals emails independently of account switching and hides them on reopening", async () => {
269+
saveMaskEmails(true);
240270
vi.mocked(invoke).mockImplementation(async (command) =>
241271
command === "provider_account_identity"
242272
? { email: "[email protected]", plan: "Pro" }

‎src/app/shell/UsageProviderChip.tsx‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import {
5252
type ProviderAccountIdentity,
5353
} from "../../features/providers/model/providerAccountIdentity";
5454
import { ProviderAccountSubtitle } from "../../features/providers/ui/ProviderAccountSubtitle";
55+
import { useShowRemainingUsage } from "../../features/settings/model/displayPrefs";
5556

5657
type UsageWindowEntry = {
5758
key: "session" | "weekly" | "monthly";
@@ -759,8 +760,10 @@ function UsageWindowCard({
759760
window: RateLimitWindow;
760761
now: number;
761762
}) {
763+
const showRemaining = useShowRemainingUsage();
762764
const pct = clampUsedPercent(window.usedPercent);
763765
const remaining = 100 - pct;
766+
const shown = showRemaining ? remaining : pct;
764767
const title =
765768
kind === "session"
766769
? "5-hour limit"
@@ -780,14 +783,14 @@ function UsageWindowCard({
780783
<div
781784
className="mt-2 h-1.5 overflow-hidden rounded-full bg-content/10"
782785
role="progressbar"
783-
aria-label={`${title} remaining`}
786+
aria-label={`${title} ${showRemaining ? "remaining" : "used"}`}
784787
aria-valuemin={0}
785788
aria-valuemax={100}
786-
aria-valuenow={Math.round(remaining)}
789+
aria-valuenow={Math.round(shown)}
787790
>
788791
<span
789792
className={`block h-full rounded-full ${barClass(pct)}`}
790-
style={{ width: `${remaining}%` }}
793+
style={{ width: `${shown}%` }}
791794
/>
792795
</div>
793796
<div className="mt-1.5 flex items-center justify-between gap-3 text-[10px] leading-4 text-content/40">
@@ -1146,6 +1149,7 @@ function emptyUsageLabel(limits: ProviderRateLimits): string {
11461149
}
11471150

11481151
function MiniBar({ usedPct }: { usedPct: number }) {
1152+
const showRemaining = useShowRemainingUsage();
11491153
const pct = clampUsedPercent(usedPct);
11501154
return (
11511155
<span
@@ -1154,7 +1158,7 @@ function MiniBar({ usedPct }: { usedPct: number }) {
11541158
>
11551159
<span
11561160
className={`block h-full rounded-full ${barClass(pct)}`}
1157-
style={{ width: `${100 - pct}%` }}
1161+
style={{ width: `${showRemaining ? 100 - pct : pct}%` }}
11581162
/>
11591163
</span>
11601164
);
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// @vitest-environment happy-dom
2+
import { act, createElement } from "react";
3+
import { createRoot, type Root } from "react-dom/client";
4+
import { afterEach, beforeEach, expect, it, vi } from "vitest";
5+
import { UsageMeter } from "./ProviderAccountUsage";
6+
7+
let container: HTMLDivElement;
8+
let root: Root;
9+
10+
beforeEach(() => {
11+
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
12+
container = document.createElement("div");
13+
document.body.append(container);
14+
root = createRoot(container);
15+
});
16+
17+
afterEach(async () => {
18+
await act(async () => root.unmount());
19+
container.remove();
20+
localStorage.clear();
21+
vi.unstubAllGlobals();
22+
});
23+
24+
it("flips the meter when another window turns on remaining usage", async () => {
25+
const now = Date.now();
26+
await act(async () =>
27+
root.render(
28+
createElement(UsageMeter, {
29+
title: "5h",
30+
now,
31+
window: {
32+
usedPercent: 23,
33+
windowMinutes: 300,
34+
resetsAt: now + 3_600_000,
35+
},
36+
}),
37+
),
38+
);
39+
const bar = () => container.querySelector('[role="progressbar"]');
40+
expect(bar()?.getAttribute("aria-label")).toBe("5h limit used");
41+
expect(bar()?.querySelector("span")?.getAttribute("style")).toBe(
42+
"width: 23%;",
43+
);
44+
45+
await act(async () => {
46+
localStorage.setItem("monocode.showRemainingUsage", "1");
47+
window.dispatchEvent(
48+
new StorageEvent("storage", { key: "monocode.showRemainingUsage" }),
49+
);
50+
});
51+
52+
expect(bar()?.getAttribute("aria-label")).toBe("5h limit remaining");
53+
expect(bar()?.querySelector("span")?.getAttribute("style")).toBe(
54+
"width: 77%;",
55+
);
56+
expect(container.textContent).toContain("77% left");
57+
});

‎src/features/providers/ui/ProviderAccountUsage.tsx‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
AccountUsage,
1313
} from "../model/accountUsage";
1414
import { RefreshCw } from "../../../shared/ui/icons";
15+
import { useShowRemainingUsage } from "../../settings/model/displayPrefs";
1516

1617
const STATUS_DOT: Record<AccountStatusTone, string> = {
1718
ready: "bg-emerald-400",
@@ -142,8 +143,10 @@ export function UsageMeter({
142143
now: number;
143144
className?: string;
144145
}) {
146+
const showRemaining = useShowRemainingUsage();
145147
const pct = clampUsedPercent(window.usedPercent);
146148
const remaining = 100 - pct;
149+
const shown = showRemaining ? remaining : pct;
147150
const full = pct >= 100 && (window.resetsAt == null || window.resetsAt > now);
148151
const reset =
149152
window.resetsAt == null
@@ -167,20 +170,24 @@ export function UsageMeter({
167170
<span
168171
className={`shrink-0 tabular-nums ${full ? "font-medium text-red-400" : "text-content/60"}`}
169172
>
170-
{formatUsagePercent(remaining)} left
173+
{showRemaining
174+
? `${formatUsagePercent(remaining)} left`
175+
: full
176+
? "Full"
177+
: formatUsagePercent(pct)}
171178
</span>
172179
</div>
173180
<div
174181
className="mt-1.5 h-1 overflow-hidden rounded-full bg-content/10"
175182
role="progressbar"
176-
aria-label={`${title} limit remaining`}
183+
aria-label={`${title} limit ${showRemaining ? "remaining" : "used"}`}
177184
aria-valuemin={0}
178185
aria-valuemax={100}
179-
aria-valuenow={Math.round(remaining)}
186+
aria-valuenow={Math.round(shown)}
180187
>
181188
<span
182189
className={`block h-full rounded-full transition-[width] duration-300 ${barClass(pct)}`}
183-
style={{ width: `${remaining}%` }}
190+
style={{ width: `${shown}%` }}
184191
/>
185192
</div>
186193
</div>
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// @vitest-environment happy-dom
2+
import { afterEach, describe, expect, it, vi } from "vitest";
3+
import {
4+
loadMaskEmails,
5+
loadShowRemainingUsage,
6+
saveMaskEmails,
7+
saveShowRemainingUsage,
8+
subscribeMaskEmails,
9+
subscribeShowRemainingUsage,
10+
} from "./displayPrefs";
11+
12+
const prefs = [
13+
{
14+
name: "show remaining usage",
15+
key: "monocode.showRemainingUsage",
16+
load: loadShowRemainingUsage,
17+
save: saveShowRemainingUsage,
18+
subscribe: subscribeShowRemainingUsage,
19+
},
20+
{
21+
name: "mask emails",
22+
key: "monocode.maskEmails",
23+
load: loadMaskEmails,
24+
save: saveMaskEmails,
25+
subscribe: subscribeMaskEmails,
26+
},
27+
];
28+
29+
afterEach(() => {
30+
vi.restoreAllMocks();
31+
localStorage.clear();
32+
});
33+
34+
describe.each(prefs)("$name preference", (pref) => {
35+
it("defaults off and notifies this window when saved", () => {
36+
const listener = vi.fn();
37+
const unsubscribe = pref.subscribe(listener);
38+
expect(pref.load()).toBe(false);
39+
40+
pref.save(true);
41+
42+
expect(pref.load()).toBe(true);
43+
expect(localStorage.getItem(pref.key)).toBe("1");
44+
expect(listener).toHaveBeenCalledTimes(1);
45+
unsubscribe();
46+
});
47+
48+
it("keeps the saved value in memory when storage rejects the write", () => {
49+
vi.spyOn(localStorage, "setItem").mockImplementation(() => {
50+
throw new Error("quota");
51+
});
52+
const listener = vi.fn();
53+
const unsubscribe = pref.subscribe(listener);
54+
listener.mockImplementation(() => expect(pref.load()).toBe(true));
55+
56+
pref.save(true);
57+
58+
expect(listener).toHaveBeenCalledTimes(1);
59+
expect(pref.load()).toBe(true);
60+
unsubscribe();
61+
pref.save(false);
62+
expect(pref.load()).toBe(false);
63+
});
64+
65+
it("follows a change saved in another window", () => {
66+
const listener = vi.fn();
67+
const unsubscribe = pref.subscribe(listener);
68+
69+
localStorage.setItem(pref.key, "1");
70+
window.dispatchEvent(new StorageEvent("storage", { key: pref.key }));
71+
expect(listener).toHaveBeenCalledTimes(1);
72+
expect(pref.load()).toBe(true);
73+
74+
window.dispatchEvent(new StorageEvent("storage", { key: "unrelated" }));
75+
expect(listener).toHaveBeenCalledTimes(1);
76+
77+
localStorage.removeItem(pref.key);
78+
window.dispatchEvent(new StorageEvent("storage", { key: null }));
79+
expect(listener).toHaveBeenCalledTimes(2);
80+
expect(pref.load()).toBe(false);
81+
unsubscribe();
82+
});
83+
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { useSyncExternalStore } from "react";
2+
import { readFlag, writeFlag } from "./storageFlags";
3+
4+
const SHOW_REMAINING_USAGE_KEY = "monocode.showRemainingUsage";
5+
const MASK_EMAILS_KEY = "monocode.maskEmails";
6+
7+
export const SHOW_REMAINING_USAGE_DEFAULT = false;
8+
export const MASK_EMAILS_DEFAULT = false;
9+
10+
/** Fired on `window` whenever the usage meter direction flips (detail: boolean). */
11+
export const SHOW_REMAINING_USAGE_CHANGE_EVENT =
12+
"monocode:showremainingusagechange";
13+
/** Fired on `window` whenever email masking flips (detail: boolean). */
14+
export const MASK_EMAILS_CHANGE_EVENT = "monocode:maskemailschange";
15+
16+
function flagStore(key: string, fallback: boolean, event: string) {
17+
// Holds a saved value only while storage failed to keep it, so the switch
18+
// still flips for this window when localStorage is unavailable.
19+
let unsaved: boolean | null = null;
20+
const load = () => unsaved ?? readFlag(key) ?? fallback;
21+
const save = (value: boolean) => {
22+
writeFlag(key, value);
23+
unsaved = readFlag(key) === value ? null : value;
24+
if (typeof window === "undefined") return;
25+
window.dispatchEvent(new CustomEvent<boolean>(event, { detail: value }));
26+
};
27+
const subscribe = (onStoreChange: () => void) => {
28+
if (typeof window === "undefined") return () => {};
29+
// Other windows write the same localStorage; `storage` reports their saves.
30+
const onStorage = (storage: StorageEvent) => {
31+
if (storage.key !== key && storage.key !== null) return;
32+
unsaved = null;
33+
onStoreChange();
34+
};
35+
window.addEventListener(event, onStoreChange);
36+
window.addEventListener("storage", onStorage);
37+
return () => {
38+
window.removeEventListener(event, onStoreChange);
39+
window.removeEventListener("storage", onStorage);
40+
};
41+
};
42+
const useFlag = () => useSyncExternalStore(subscribe, load, () => fallback);
43+
return { load, save, subscribe, useFlag };
44+
}
45+
46+
const showRemainingUsage = flagStore(
47+
SHOW_REMAINING_USAGE_KEY,
48+
SHOW_REMAINING_USAGE_DEFAULT,
49+
SHOW_REMAINING_USAGE_CHANGE_EVENT,
50+
);
51+
const maskEmails = flagStore(
52+
MASK_EMAILS_KEY,
53+
MASK_EMAILS_DEFAULT,
54+
MASK_EMAILS_CHANGE_EVENT,
55+
);
56+
57+
/** Usage meters fill with what is left instead of what is used. */
58+
export const loadShowRemainingUsage = showRemainingUsage.load;
59+
export const saveShowRemainingUsage = showRemainingUsage.save;
60+
export const subscribeShowRemainingUsage = showRemainingUsage.subscribe;
61+
export const useShowRemainingUsage = showRemainingUsage.useFlag;
62+
63+
/** Account emails stay blurred until clicked. */
64+
export const loadMaskEmails = maskEmails.load;
65+
export const saveMaskEmails = maskEmails.save;
66+
export const subscribeMaskEmails = maskEmails.subscribe;
67+
export const useMaskEmails = maskEmails.useFlag;

‎src/features/settings/model/settings.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,18 @@ export const SETTINGS_INDEX: SettingsEntry[] = [
371371
keywords:
372372
"account sign in login rename remove delete credentials profile usage limit quota exhausted",
373373
},
374+
{
375+
id: "show-remaining-usage",
376+
section: "providers",
377+
label: "Show remaining usage",
378+
keywords: "usage limit meter bar left used quota percent",
379+
},
380+
{
381+
id: "mask-emails",
382+
section: "providers",
383+
label: "Mask account emails",
384+
keywords: "email privacy blur hide screenshot account",
385+
},
374386
{
375387
id: "claude-hooks",
376388
section: "providers",

0 commit comments

Comments
 (0)