1- import { Fragment , useCallback , useEffect , useLayoutEffect , useRef , useState , type RefObject } from "react" ;
1+ import { useCallback , useEffect , useLayoutEffect , useRef , useState , type RefObject } from "react" ;
22import { createPortal } from "react-dom" ;
33import type {
44 AgentAccount ,
@@ -20,7 +20,7 @@ import { api } from "../rpc";
2020import { toast } from "../toast" ;
2121import { useT } from "../i18n" ;
2222import { 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) . */
147130function 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 >
0 commit comments