Skip to content

Commit d557387

Browse files
authored
Show per-account usage bars inline in the launch account popover (#1235)
1 parent 01a8278 commit d557387

10 files changed

Lines changed: 80 additions & 111 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Short: Account usage bars visible without a click
2+
3+
The launch picker's account popover now shows each account's 5h/7d usage bars, percentages and reset countdowns inline, so comparing accounts no longer costs a click per row. The per-row expand chevron is gone, the capture age rides the last bar line as a compact suffix (warning-tinted once stale), and the panel scrolls instead of growing past its dialog.

src/mainview/components/AgentAccountIndicator.tsx

Lines changed: 45 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Fragment, useCallback, useEffect, useLayoutEffect, useRef, useState, type RefObject } from "react";
1+
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type RefObject } from "react";
22
import { createPortal } from "react-dom";
33
import type {
44
AgentAccount,
@@ -20,7 +20,7 @@ import { api } from "../rpc";
2020
import { toast } from "../toast";
2121
import { useT } from "../i18n";
2222
import { useOverlayLayer } from "../utils/useOverlayLayer";
23-
import { CapturedNote, UsageBar, severityText } from "./rate-limit-ui";
23+
import { CapturedAgeSuffix, UsageBar, severityText } from "./rate-limit-ui";
2424

2525
/** Fired on window after any account mutation (switch from this popover,
2626
* add/remove/switch in Settings → Agent Accounts), so every mounted listener
@@ -112,66 +112,51 @@ function quotaLines(snap: AgentRateLimitSnapshot, monthlyLabel: string): QuotaLi
112112
return lines;
113113
}
114114

115-
/** The one reading a collapsed row keeps: an unlimited chip, or the most severe
116-
* window percentage — enough to choose an account without expanding it. */
117-
function RowHeadline({ usage, monthlyLabel }: { usage: RowUsage; monthlyLabel: string }) {
115+
/** An unlimited account has no bars to show, so the chip carries its whole
116+
* reading; every other state renders its numbers in the quota block below. */
117+
function RowHeadline({ usage }: { usage: RowUsage }) {
118118
const t = useT();
119-
if (!usage.snap) return null;
120-
if (usage.state === "unlimited") {
121-
return (
122-
<span className="text-success-strong text-micro px-1 py-px bg-success/10 rounded font-medium shrink-0">
123-
{t("rateLimits.unlimited")}
124-
</span>
125-
);
126-
}
127-
const lines = quotaLines(usage.snap, monthlyLabel);
128-
if (lines.length === 0) return null;
129-
const percent = Math.round(Math.max(...lines.map((line) => line.usedPercent)));
130-
// "37%" alone reads as either used or left; the title carries the direction,
131-
// and an exhausted account says so outright — picking it wastes the launch.
119+
if (!usage.snap || usage.state !== "unlimited") return null;
132120
return (
133-
<span
134-
className={`shrink-0 text-xs font-semibold tabular-nums ${severityText(percent)}`}
135-
title={percent >= RATE_LIMIT_DANGER_PERCENT
136-
? t("rateLimits.quotaExhausted")
137-
: t("rateLimits.percentUsed", { percent: String(percent) })}
138-
>
139-
{t("rateLimits.percentUsed", { percent: String(percent) })}
121+
<span className="text-success-strong text-micro px-1 py-px bg-success/10 rounded font-medium shrink-0">
122+
{t("rateLimits.unlimited")}
140123
</span>
141124
);
142125
}
143126

144-
/** Expanded per-account quota block: one "label · bar · % · reset" line per
145-
* limit window plus the captured note. Collapsed by default so a row is one
146-
* line and the popover fits below its trigger. */
127+
/** Per-account quota block, always visible: one dense "label · bar · % · reset"
128+
* line per limit window. Picking an account is a comparison, so hiding the bars
129+
* behind a per-row toggle made every open cost a click (decision: inline). */
147130
function RowQuota({ usage, now }: { usage: RowUsage; now: number }) {
148131
const t = useT();
149-
if (!usage.snap) return null;
150-
const lines = usage.state === "used" ? quotaLines(usage.snap, t("rateLimits.monthlyLabel")) : [];
132+
if (!usage.snap || usage.state !== "used") return null;
133+
const lines = quotaLines(usage.snap, t("rateLimits.monthlyLabel"));
134+
if (lines.length === 0) return null;
151135
const exhausted = lines.some((line) => line.usedPercent >= RATE_LIMIT_DANGER_PERCENT);
136+
const lastKey = lines[lines.length - 1]?.key;
152137
return (
153-
<div className="pl-8 pr-3 pb-2">
138+
<span className="mt-1 block">
154139
{lines.map((line) => {
155140
const percent = Math.round(line.usedPercent);
156141
const reset = formatResetDelta(line.resetsAt, now);
157142
return (
158-
<div key={line.key} className="mt-1.5 flex items-center gap-1.5">
143+
<span key={line.key} className="mt-1 flex items-center gap-1.5">
159144
<span className="min-w-[1.5rem] shrink-0 text-xs text-fg-3 tabular-nums whitespace-nowrap">{line.label}</span>
160-
<UsageBar percent={line.usedPercent} className="h-1 min-w-0 flex-1" />
161-
<span className="shrink-0 text-xs tabular-nums">
145+
<UsageBar percent={line.usedPercent} className="h-1 min-w-[3rem] flex-1" />
146+
<span className="shrink-0 text-xs tabular-nums whitespace-nowrap">
162147
<span className={`font-semibold ${severityText(percent)}`}>
163148
{t("rateLimits.percentUsed", { percent: String(percent) })}
164149
</span>
165150
{reset && <span className="text-fg-3"> · {t("rateLimits.resetsIn", { time: reset })}</span>}
151+
{/* Provenance rides the last line: a reading is only as good as its age,
152+
and a separate line per account doubled the block's height. */}
153+
{line.key === lastKey && <CapturedAgeSuffix capturedAt={usage.snap!.capturedAt} now={now} />}
166154
</span>
167-
</div>
155+
</span>
168156
);
169157
})}
170-
{exhausted && <p className="mt-1.5 text-xs text-danger">{t("rateLimits.quotaExhausted")}</p>}
171-
<div className="mt-1">
172-
<CapturedNote capturedAt={usage.snap.capturedAt} now={now} />
173-
</div>
174-
</div>
158+
{exhausted && <span className="mt-1 block text-xs text-danger">{t("rateLimits.quotaExhausted")}</span>}
159+
</span>
175160
);
176161
}
177162

@@ -226,7 +211,6 @@ function SwitcherPopover({
226211
const menuRef = useRef<HTMLDivElement>(null);
227212
const [pos, setPos] = useState({ top: anchor.top, left: anchor.left });
228213
const [visible, setVisible] = useState(false);
229-
const [expanded, setExpanded] = useState<ReadonlySet<string>>(() => new Set<string>());
230214
const now = Date.now();
231215

232216
// Registers the panel as an overlay layer: Tab reaches it, Escape closes it
@@ -279,19 +263,13 @@ function SwitcherPopover({
279263
}, [reposition]);
280264

281265
const autoFocusKey = (rows.find((row) => row.isActive) ?? rows[0])?.key ?? null;
282-
const toggleExpanded = (key: string) =>
283-
setExpanded((prev) => {
284-
const next = new Set(prev);
285-
if (!next.delete(key)) next.add(key);
286-
return next;
287-
});
288266

289267
return createPortal(
290268
<div
291269
ref={menuRef}
292270
role="menu"
293271
aria-label={title}
294-
className="fixed z-[10000] bg-overlay rounded-xl shadow-2xl shadow-black/40 border border-edge-active py-1.5 w-[21rem] max-w-[calc(100vw-1rem)]"
272+
className="fixed z-[10000] bg-overlay rounded-xl shadow-2xl shadow-black/40 border border-edge-active py-1.5 w-[25rem] max-w-[calc(100vw-1rem)]"
295273
// Opacity, not `visibility`, hides the pre-measure frame: a
296274
// visibility-hidden element cannot take focus, so autofocusing the
297275
// active row silently did nothing.
@@ -302,16 +280,19 @@ function SwitcherPopover({
302280
<div className="text-fg-2 text-sm font-semibold uppercase tracking-wider">{title}</div>
303281
<p className="text-fg-3 text-xs leading-snug mt-1">{subtitle}</p>
304282
</div>
305-
{rows.map((row) => {
306-
// Informational rows (codex "unmanaged") and a busy switch stay inert —
307-
// via aria-disabled, so every row keeps its place in the Tab ring.
308-
const inert = busy || !row.onSelect;
309-
const showSub = !!row.sub && row.sub !== row.label && !row.label.includes(row.sub);
310-
const hasQuota = !!row.usage?.snap;
311-
const isOpen = expanded.has(row.key);
312-
return (
313-
<Fragment key={row.key}>
314-
<div className={`flex items-start ${inert ? "" : "hover:bg-elevated-hover"} transition-colors`}>
283+
{/* Usage bars make a row 3 lines tall, so the list is the part that scrolls —
284+
the title block and the provenance hint stay pinned. */}
285+
<div className="max-h-[min(28rem,60vh)] overflow-y-auto overscroll-contain">
286+
{rows.map((row) => {
287+
// Informational rows (codex "unmanaged") and a busy switch stay inert —
288+
// via aria-disabled, so every row keeps its place in the Tab ring.
289+
const inert = busy || !row.onSelect;
290+
const showSub = !!row.sub && row.sub !== row.label && !row.label.includes(row.sub);
291+
return (
292+
<div
293+
key={row.key}
294+
className={`flex items-start ${inert ? "" : "hover:bg-elevated-hover"} transition-colors`}
295+
>
315296
<button
316297
type="button"
317298
role="menuitemradio"
@@ -322,7 +303,7 @@ function SwitcherPopover({
322303
if (inert) return;
323304
row.onSelect?.();
324305
}}
325-
className={`min-w-0 flex-1 text-left pl-3 pr-2 py-2 flex items-start gap-2 focus:bg-elevated-hover ${
306+
className={`min-w-0 flex-1 text-left pl-3 pr-3 py-2 flex items-start gap-2 focus:bg-elevated-hover ${
326307
inert ? "cursor-default" : "cursor-pointer"
327308
}`}
328309
>
@@ -345,7 +326,7 @@ function SwitcherPopover({
345326
{row.planLabel}
346327
</span>
347328
) : null}
348-
{row.usage ? <RowHeadline usage={row.usage} monthlyLabel={t("rateLimits.monthlyLabel")} /> : null}
329+
{row.usage ? <RowHeadline usage={row.usage} /> : null}
349330
</span>
350331
{showSub || row.workspaceLabel ? (
351332
<span className="mt-1 flex flex-wrap items-center gap-1.5 min-w-0">
@@ -367,6 +348,7 @@ function SwitcherPopover({
367348
{row.usage && row.usage.state === "none" ? (
368349
<span className="mt-1 block text-xs text-fg-3">{t("rateLimits.noRecentData")}</span>
369350
) : null}
351+
{row.usage ? <RowQuota usage={row.usage} now={now} /> : null}
370352
</span>
371353
{row.isActive ? (
372354
<svg
@@ -381,32 +363,10 @@ function SwitcherPopover({
381363
</svg>
382364
) : null}
383365
</button>
384-
{hasQuota ? (
385-
<button
386-
type="button"
387-
role="menuitem"
388-
aria-expanded={isOpen}
389-
aria-label={t(isOpen ? "launch.accountHideUsage" : "launch.accountShowUsage")}
390-
title={t(isOpen ? "launch.accountHideUsage" : "launch.accountShowUsage")}
391-
onClick={() => toggleExpanded(row.key)}
392-
className="shrink-0 px-2 py-2.5 text-fg-3 hover:text-fg focus:bg-elevated-hover"
393-
>
394-
<svg
395-
className={`w-3.5 h-3.5 transition-transform ${isOpen ? "rotate-180" : ""}`}
396-
viewBox="0 0 24 24"
397-
fill="none"
398-
stroke="currentColor"
399-
strokeWidth={2}
400-
>
401-
<path strokeLinecap="round" strokeLinejoin="round" d="M6 9l6 6 6-6" />
402-
</svg>
403-
</button>
404-
) : null}
405366
</div>
406-
{hasQuota && isOpen && row.usage ? <RowQuota usage={row.usage} now={now} /> : null}
407-
</Fragment>
408-
);
409-
})}
367+
);
368+
})}
369+
</div>
410370
<div className="border-t border-edge mt-1 pt-1.5 px-3 pb-1">
411371
<p className="text-fg-3 text-xs leading-snug">{hint}</p>
412372
</div>

src/mainview/components/__tests__/AgentAccountIndicator.test.tsx

Lines changed: 10 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -102,13 +102,6 @@ function makeReport(snapshots: AgentRateLimitSnapshot[]): AgentRateLimitsReport
102102
return { snapshots, generatedAt: Date.now() };
103103
}
104104

105-
/** Quota bars live behind a per-row disclosure; collapsed rows only show the
106-
* worst percentage. `index` picks which row's toggle to open. */
107-
async function expandUsage(user: ReturnType<typeof userEvent.setup>, index = 0) {
108-
const toggles = await screen.findAllByLabelText("Show usage details");
109-
await user.click(toggles[index]!);
110-
}
111-
112105
beforeEach(() => {
113106
vi.clearAllMocks();
114107
mockedApi.request.listAgentAccounts.mockResolvedValue(makeState());
@@ -289,15 +282,13 @@ describe("AgentAccountIndicator", () => {
289282

290283
await user.click(await screen.findByTestId("agent-account-trigger"));
291284

292-
// Collapsed rows carry the worst percentage, phrased so the direction is
293-
// explicit: 62% used (system, neutral tier) and
294-
// 97% (managed account, danger tier).
285+
// Every window renders its own bar line with no click: the percentages are
286+
// tiered by severity (62% neutral, 97% danger) and both rows are readable
287+
// side by side, which is the whole point of picking an account.
295288
const okPercent = await screen.findByText("62% used");
296289
expect(okPercent.className).toContain("text-fg-2");
297290
const dangerPercent = screen.getByText("97% used");
298291
expect(dangerPercent.className).toContain("text-danger");
299-
// Expanding the system row reveals every window.
300-
await expandUsage(user, 0);
301292
expect(screen.getByText("34% used")).toBeTruthy();
302293
expect(screen.getByText("5h")).toBeTruthy();
303294
});
@@ -330,13 +321,14 @@ describe("AgentAccountIndicator", () => {
330321
renderIndicator(claudeAgent, { value: null, onSelect: vi.fn() });
331322

332323
await user.click(await screen.findByTestId("agent-account-trigger"));
333-
await expandUsage(user, 0);
334324

335-
// No hover needed — the quota line and its provenance render in the row
336-
// (34% shows twice: the collapsed headline and the expanded window line).
337-
expect((await screen.findAllByText("34% used")).length).toBe(2);
325+
// No click and no hover: the quota line, its reset countdown and the
326+
// capture age all render in the row itself.
327+
expect((await screen.findAllByText("34% used")).length).toBe(1);
338328
expect(screen.getByText(/· resets in 2h/)).toBeTruthy();
339-
expect(screen.getByText("captured 20m ago")).toBeTruthy();
329+
const age = screen.getByTitle("captured 20m ago");
330+
expect(age.textContent).toContain("20m");
331+
expect(age.className).toContain("text-warning");
340332
});
341333

342334
it("renders the monthly credits line when the plan exposes it", async () => {
@@ -353,9 +345,7 @@ describe("AgentAccountIndicator", () => {
353345
renderIndicator(claudeAgent, { value: null, onSelect: vi.fn() });
354346

355347
await user.click(await screen.findByTestId("agent-account-trigger"));
356-
expect(await screen.findByText("25% used")).toBeTruthy(); // headline, collapsed
357-
await expandUsage(user, 0);
358-
348+
expect(await screen.findByText("25% used")).toBeTruthy();
359349
expect(screen.getByText("monthly credits")).toBeTruthy();
360350
});
361351

src/mainview/components/rate-limit-ui.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,25 @@ export function CapturedNote({ capturedAt, now }: { capturedAt: number; now: num
231231
);
232232
}
233233

234+
/**
235+
* Capture age as a dense inline suffix ("· 15h") for surfaces that cannot spend
236+
* a whole line on provenance. Same honesty contract as CapturedNote: warning
237+
* tint past STALE_AFTER_MS so a reading from days ago never reads as live.
238+
*/
239+
export function CapturedAgeSuffix({ capturedAt, now }: { capturedAt: number; now: number }) {
240+
const t = useT();
241+
const age = Math.max(0, now - capturedAt);
242+
const stale = age > STALE_AFTER_MS;
243+
const short = age < 60_000 ? t("rateLimits.capturedAgeNow") : formatAge(age);
244+
const full = age < 60_000 ? t("rateLimits.capturedNow") : t("rateLimits.captured", { time: formatAge(age) });
245+
return (
246+
<span title={full} className={stale ? "text-warning" : "text-fg-3"}>
247+
{" · "}
248+
{short}
249+
</span>
250+
);
251+
}
252+
234253
/** Compact age like "12m" or "3h" for the staleness note. */
235254
function formatAge(ms: number): string {
236255
const mins = Math.round(ms / 60000);

src/mainview/i18n/translations/en/dashboard.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ const dashboard = {
130130
"rateLimits.monthlyUsage": "{used} / {limit} used · {remaining}% left",
131131
"rateLimits.captured": "captured {time} ago",
132132
"rateLimits.capturedNow": "captured just now",
133+
"rateLimits.capturedAgeNow": "just now",
133134
"rateLimits.noRecentData": "no recent usage data",
134135
"rateLimits.quotaExhausted": "No quota left — this launch will fail until it resets.",
135136

src/mainview/i18n/translations/en/kanban.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -329,8 +329,6 @@ const kanban = {
329329
"kanban.loadFailedDesc": "Loading this project's tasks failed. Try again.",
330330
"kanban.loadFailedOffline": "dev-3.0 can't reach your computer right now. The board reloads itself once the connection is back.",
331331
"kanban.loadRetry": "Retry",
332-
"launch.accountShowUsage": "Show usage details",
333-
"launch.accountHideUsage": "Hide usage details",
334332
} as const;
335333

336334
export default kanban;

src/mainview/i18n/translations/es/dashboard.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ const dashboard = {
130130
"rateLimits.monthlyUsage": "{used} / {limit} usados · {remaining}% restantes",
131131
"rateLimits.captured": "capturado hace {time}",
132132
"rateLimits.capturedNow": "capturado ahora mismo",
133+
"rateLimits.capturedAgeNow": "ahora mismo",
133134
"rateLimits.noRecentData": "sin datos de uso recientes",
134135
"rateLimits.quotaExhausted": "Sin cuota disponible — este lanzamiento fallará hasta que se restablezca.",
135136

src/mainview/i18n/translations/es/kanban.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -329,8 +329,6 @@ const kanban = {
329329
"kanban.loadFailedDesc": "No se pudieron cargar las tareas del proyecto. Inténtalo de nuevo.",
330330
"kanban.loadFailedOffline": "dev-3.0 no puede alcanzar tu ordenador ahora mismo. El tablero se recargará solo cuando vuelva la conexión.",
331331
"kanban.loadRetry": "Reintentar",
332-
"launch.accountShowUsage": "Mostrar detalles de uso",
333-
"launch.accountHideUsage": "Ocultar detalles de uso",
334332
};
335333

336334
export default kanban;

src/mainview/i18n/translations/ru/dashboard.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ const dashboard = {
146146
"rateLimits.monthlyUsage": "использовано {used} / {limit} · осталось {remaining}%",
147147
"rateLimits.captured": "снято {time} назад",
148148
"rateLimits.capturedNow": "снято только что",
149+
"rateLimits.capturedAgeNow": "только что",
149150
"rateLimits.noRecentData": "нет свежих данных об использовании",
150151
"rateLimits.quotaExhausted": "Лимит исчерпан — запуск будет падать до сброса.",
151152

src/mainview/i18n/translations/ru/kanban.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -335,8 +335,6 @@ const kanban = {
335335
"kanban.loadFailedDesc": "Задачи проекта не загрузились. Попробуйте ещё раз.",
336336
"kanban.loadFailedOffline": "dev-3.0 сейчас не видит ваш компьютер. Доска перезагрузится сама, когда связь вернётся.",
337337
"kanban.loadRetry": "Повторить",
338-
"launch.accountShowUsage": "Показать детали расхода",
339-
"launch.accountHideUsage": "Скрыть детали расхода",
340338
};
341339

342340
export default kanban;

0 commit comments

Comments
 (0)