Skip to content

Commit 84cc946

Browse files
jahoomaclaude
andauthored
Show premium-session quota in freebuff session-ended banner (#618)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 38babfe commit 84cc946

8 files changed

Lines changed: 124 additions & 33 deletions

File tree

cli/src/components/freebuff-model-selector.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
isFreebuffModelAvailable,
1212
isFreebuffPremiumModelId,
1313
} from '@codebuff/common/constants/freebuff-models'
14+
import { getRateLimitsByModel } from '@codebuff/common/types/freebuff-session'
1415

1516
import { joinFreebuffQueue } from '../hooks/use-freebuff-session'
1617
import { useNow } from '../hooks/use-now'
@@ -127,10 +128,7 @@ export const FreebuffModelSelector: React.FC = () => {
127128
}, [now, selectedModel, session, setSelectedModel])
128129

129130
const committedModelId = session?.status === 'queued' ? session.model : null
130-
const rateLimitsByModel =
131-
session && 'rateLimitsByModel' in session
132-
? session.rateLimitsByModel
133-
: undefined
131+
const rateLimitsByModel = getRateLimitsByModel(session)
134132

135133
const BUTTON_CHROME = 4 // 2 border + 2 padding
136134
const NAME_GAP = 2 // spaces between name column and details column

cli/src/components/session-ended-banner.tsx

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { getRateLimitsByModel } from '@codebuff/common/types/freebuff-session'
12
import { TextAttributes } from '@opentui/core'
23
import { useKeyboard } from '@opentui/react'
34
import React, { useCallback, useState } from 'react'
@@ -8,6 +9,8 @@ import {
89
returnToFreebuffLanding,
910
} from '../hooks/use-freebuff-session'
1011
import { useTheme } from '../hooks/use-theme'
12+
import { useFreebuffSessionStore } from '../state/freebuff-session-store'
13+
import { formatSessionUnits } from '../utils/format-session-units'
1114
import { BORDER_CHARS } from '../utils/ui-constants'
1215

1316
import type { KeyEvent } from '@opentui/core'
@@ -32,6 +35,19 @@ export const SessionEndedBanner: React.FC<SessionEndedBannerProps> = ({
3235
'waiting-room' | 'same-chat' | null
3336
>(null)
3437

38+
// All premium models share one daily pool; the server replicates the same
39+
// snapshot under each premium model id, so the first entry has the right
40+
// count.
41+
const premiumQuota = useFreebuffSessionStore(
42+
(s) => Object.values(getRateLimitsByModel(s.session) ?? {})[0] ?? null,
43+
)
44+
const isQuotaExhausted = premiumQuota
45+
? premiumQuota.recentCount >= premiumQuota.limit
46+
: false
47+
const bannerTitle = premiumQuota
48+
? `Session ended · ${formatSessionUnits(premiumQuota.recentCount)} of ${premiumQuota.limit} premium sessions used today`
49+
: 'Session ended'
50+
3551
// While a request is still streaming, restart is disabled: it would
3652
// unmount <Chat> and abort the in-flight agent run. The promise is "we
3753
// let the agent finish" — honoring that means Enter does nothing until
@@ -78,12 +94,15 @@ export const SessionEndedBanner: React.FC<SessionEndedBannerProps> = ({
7894

7995
return (
8096
<box
81-
title="Session ended"
97+
title={bannerTitle}
8298
titleAlignment="center"
8399
style={{
84100
width: '100%',
85101
borderStyle: 'single',
86-
borderColor: theme.muted,
102+
// Amber border doubles as the "you've hit the cap" signal now that
103+
// the quota count lives in the title (which can't carry per-char
104+
// color); muted otherwise.
105+
borderColor: isQuotaExhausted ? theme.secondary : theme.muted,
87106
customBorderChars: BORDER_CHARS,
88107
paddingLeft: 1,
89108
paddingRight: 1,
@@ -93,9 +112,6 @@ export const SessionEndedBanner: React.FC<SessionEndedBannerProps> = ({
93112
gap: 0,
94113
}}
95114
>
96-
<text style={{ fg: theme.foreground, wrapMode: 'word' }}>
97-
Your freebuff session has ended.
98-
</text>
99115
{isStreaming ? (
100116
<text style={{ fg: theme.muted, wrapMode: 'word' }}>
101117
Agent is wrapping up. Rejoin the wait room after it's finished.
@@ -115,7 +131,7 @@ export const SessionEndedBanner: React.FC<SessionEndedBannerProps> = ({
115131
fg:
116132
pendingAction === 'same-chat'
117133
? theme.muted
118-
: theme.primary,
134+
: theme.foreground,
119135
}}
120136
attributes={TextAttributes.BOLD}
121137
>
@@ -144,11 +160,14 @@ export const SessionEndedBanner: React.FC<SessionEndedBannerProps> = ({
144160
? theme.muted
145161
: theme.foreground,
146162
}}
147-
attributes={TextAttributes.BOLD}
148163
>
149-
{pendingAction === 'waiting-room'
150-
? 'Opening model selection…'
151-
: 'Change model (ESC)'}
164+
{pendingAction === 'waiting-room' ? (
165+
'Opening model selection…'
166+
) : (
167+
<>
168+
Change model<span fg={theme.muted}>{' Esc'}</span>
169+
</>
170+
)}
152171
</text>
153172
</Button>
154173
</box>

cli/src/components/waiting-room-screen.tsx

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@ import { useSheenAnimation } from '../hooks/use-sheen-animation'
1515
import { useTerminalDimensions } from '../hooks/use-terminal-dimensions'
1616
import { useTheme } from '../hooks/use-theme'
1717
import { exitFreebuffCleanly } from '../utils/freebuff-exit'
18+
import { formatSessionUnits } from '../utils/format-session-units'
1819
import { getLogoAccentColor, getLogoBlockColor } from '../utils/theme-system'
1920
import { FREEBUFF_PREMIUM_SESSION_LIMIT } from '@codebuff/common/constants/freebuff-models'
21+
import { getRateLimitsByModel } from '@codebuff/common/types/freebuff-session'
2022

2123
import type { FreebuffSessionResponse } from '../types/freebuff-session'
2224
import type { FreebuffIpPrivacySignal } from '@codebuff/common/types/freebuff-session'
@@ -59,9 +61,6 @@ const formatRetryAfter = (ms: number): string => {
5961
return rem === 0 ? `${hours}h` : `${hours}h ${rem}m`
6062
}
6163

62-
const formatSessionUnits = (units: number): string =>
63-
Number.isInteger(units) ? String(units) : units.toFixed(1)
64-
6564
const PRIVACY_SIGNAL_LABELS: Partial<Record<FreebuffIpPrivacySignal, string>> =
6665
{
6766
anonymous: 'anonymized network',
@@ -268,10 +267,7 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
268267
// pool; the server replicates the same snapshot under each premium model
269268
// id, so any entry has the right count. Renders amber when exhausted so
270269
// the limit reads as "you've hit it" rather than just another count.
271-
const rateLimitsByModel =
272-
session && 'rateLimitsByModel' in session
273-
? session.rateLimitsByModel
274-
: undefined
270+
const rateLimitsByModel = getRateLimitsByModel(session)
275271
const sharedPremiumUsed = rateLimitsByModel
276272
? (Object.values(rateLimitsByModel)[0]?.recentCount ?? 0)
277273
: 0

cli/src/hooks/use-freebuff-session.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
FALLBACK_FREEBUFF_MODEL_ID,
44
resolveFreebuffModel,
55
} from '@codebuff/common/constants/freebuff-models'
6+
import { getRateLimitsByModel } from '@codebuff/common/types/freebuff-session'
67
import { useEffect } from 'react'
78

89
import {
@@ -351,11 +352,16 @@ export function markFreebuffSessionCountryBlocked(params: {
351352
}
352353

353354
/** Flip into the local `ended` state without an instanceId (server has lost
354-
* our row). The chat surface stays mounted with the rejoin banner. */
355+
* our row). The chat surface stays mounted with the rejoin banner.
356+
* Preserves any `rateLimitsByModel` snapshot from the prior session so the
357+
* banner can show today's premium-session count without an extra fetch. */
355358
export function markFreebuffSessionEnded(): void {
356359
if (!IS_FREEBUFF) return
357360
controller?.abort()
358-
controller?.apply({ status: 'ended' })
361+
const rateLimitsByModel = getRateLimitsByModel(
362+
useFreebuffSessionStore.getState().session,
363+
)
364+
controller?.apply({ status: 'ended', rateLimitsByModel })
359365
}
360366

361367
interface UseFreebuffSessionResult {
@@ -508,12 +514,18 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
508514
// active|ended → none means we've passed the server's hard cutoff.
509515
// Synthesize a no-instanceId ended state so the chat surface stays
510516
// mounted with the Enter-to-rejoin banner instead of looping back
511-
// through the waiting room.
517+
// through the waiting room. Carry forward whichever rate-limit
518+
// snapshot we have — preferring the fresh `none` snapshot, falling
519+
// back to whatever was on the prior active/ended row — so the
520+
// banner's "N of M used today" line stays populated.
512521
if (
513522
(previousStatus === 'active' || previousStatus === 'ended') &&
514523
next.status === 'none'
515524
) {
516-
apply({ status: 'ended' })
525+
const rateLimitsByModel =
526+
next.rateLimitsByModel ??
527+
getRateLimitsByModel(useFreebuffSessionStore.getState().session)
528+
apply({ status: 'ended', rateLimitsByModel })
517529
return
518530
}
519531

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/** Premium-session counts come back from the server as `recentCount` units
2+
* that may be fractional (a long agent run can consume 1.3 sessions). Render
3+
* integers without a trailing `.0`, fractionals at one decimal — matches the
4+
* `limit` field which is always integer. */
5+
export const formatSessionUnits = (units: number): string =>
6+
Number.isInteger(units) ? String(units) : units.toFixed(1)

common/src/types/freebuff-session.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,20 @@ export type FreebuffSessionRateLimitByModel = Record<
3131
FreebuffSessionRateLimit
3232
>
3333

34+
/** Pull the per-model premium quota snapshot off whichever session statuses
35+
* carry it (queued, active, ended, none). Returns undefined for terminal /
36+
* pre-join states that have no quota field. The parameter is intentionally
37+
* loose so the CLI can pass its `FreebuffSessionResponse` (which adds the
38+
* client-only `takeover_prompt` variant) without a discriminated-union
39+
* ceremony at every call site. */
40+
export const getRateLimitsByModel = (
41+
session: { status: string } | null | undefined,
42+
): FreebuffSessionRateLimitByModel | undefined =>
43+
session && 'rateLimitsByModel' in session
44+
? (session as { rateLimitsByModel?: FreebuffSessionRateLimitByModel })
45+
.rateLimitsByModel
46+
: undefined
47+
3448
export type FreebuffCountryBlockReason =
3549
| 'country_not_allowed'
3650
| 'anonymized_or_unknown_country'
@@ -119,6 +133,10 @@ export type FreebuffSessionServerResponse =
119133
expiresAt?: string
120134
gracePeriodEndsAt?: string
121135
gracePeriodRemainingMs?: number
136+
/** Snapshot of the user's premium-session quota at the moment the
137+
* session ended. Lets the post-session banner show "N of M premium
138+
* sessions used today" without an extra round-trip. */
139+
rateLimitsByModel?: FreebuffSessionRateLimitByModel
122140
}
123141
| {
124142
/** Another CLI on the same account rotated our instance id. Polling

web/src/server/free-session/__tests__/public-api.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -960,6 +960,38 @@ describe('getSessionState', () => {
960960
expect(state.gracePeriodRemainingMs).toBe(GRACE_MS - 60_000)
961961
})
962962

963+
test('ended view carries the full premium-quota snapshot', async () => {
964+
// The post-session banner reads any entry from rateLimitsByModel since
965+
// all premium models share one daily pool. Unlike queued/active, the
966+
// ended view ships the full unfiltered map so a single banner read is
967+
// always safe.
968+
await requestSession({ userId: 'u1', model: DEFAULT_MODEL, deps })
969+
const row = deps.rows.get('u1')!
970+
row.status = 'active'
971+
row.admitted_at = new Date(deps._now().getTime() - SESSION_LEN - 60_000)
972+
row.expires_at = new Date(deps._now().getTime() - 60_000)
973+
deps.admits.push({
974+
user_id: 'u1',
975+
model: FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID,
976+
admitted_at: new Date(deps._now().getTime() - 30 * 60_000),
977+
})
978+
979+
const state = await getSessionState({
980+
userId: 'u1',
981+
claimedInstanceId: row.active_instance_id,
982+
deps,
983+
})
984+
if (state.status !== 'ended') throw new Error('unreachable')
985+
expect(
986+
state.rateLimitsByModel?.[FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID],
987+
).toEqual(expectedRateLimit(FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, 1))
988+
// Every premium model is present (sharing the same recentCount) so the
989+
// banner can read any entry without caring which model the user was on.
990+
expect(state.rateLimitsByModel?.[FREEBUFF_KIMI_MODEL_ID]).toEqual(
991+
expectedRateLimit(FREEBUFF_KIMI_MODEL_ID, 1),
992+
)
993+
})
994+
963995
test('row past grace window returns none', async () => {
964996
await requestSession({ userId: 'u1', model: DEFAULT_MODEL, deps })
965997
const row = deps.rows.get('u1')!

web/src/server/free-session/public-api.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -416,21 +416,31 @@ export async function requestSession(params: {
416416
return attachRateLimit(params.userId, view, deps)
417417
}
418418

419-
/** Thread the current quota snapshot onto queued/active views so the CLI can
420-
* render "N of M sessions used". Other statuses pass through unchanged.
421-
* Called on both POST and GET so the line stays live across polls. */
419+
/** Thread the current quota snapshot onto queued/active/ended views so the
420+
* CLI can render "N of M sessions used" — both during the session and on
421+
* the post-session banner. Other statuses pass through unchanged. Called on
422+
* both POST and GET so the line stays live across polls. */
422423
async function attachRateLimit(
423424
userId: string,
424425
view: SessionStateResponse,
425426
deps: SessionDeps,
426427
): Promise<SessionStateResponse> {
427-
if (view.status !== 'queued' && view.status !== 'active') return view
428-
if (view.status === 'active') {
429-
const snapshot = await fetchRateLimitSnapshot(userId, view.model, deps)
430-
return snapshot ? { ...view, rateLimit: snapshot.info } : view
428+
if (
429+
view.status !== 'queued' &&
430+
view.status !== 'active' &&
431+
view.status !== 'ended'
432+
) {
433+
return view
431434
}
432-
433435
const allRateLimitsByModel = await fetchRateLimitsByModel(userId, deps)
436+
// The ended view doesn't carry a model id, so it gets the full snapshot
437+
// unfiltered — the banner reads any entry's recentCount (they all share the
438+
// same daily premium pool). Queued/active filter out unused models so the
439+
// landing screen and waiting-room title don't list every premium model with
440+
// a "0 used today" hint.
441+
if (view.status === 'ended') {
442+
return { ...view, rateLimitsByModel: allRateLimitsByModel }
443+
}
434444
const rateLimit = allRateLimitsByModel[view.model]
435445
return {
436446
...view,

0 commit comments

Comments
 (0)