Skip to content

Commit 385a723

Browse files
Sync public snapshot from freebuff-private
Source: CodebuffAI/freebuff-private@3214b79cab126762fa710126776577de6d3d021f
1 parent a79e6bc commit 385a723

12 files changed

Lines changed: 476 additions & 109 deletions

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

Lines changed: 178 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { SOLAR_PRICE_CHANGES, solarOfferAt } from '@codebuff/common/constants/freebuff-solar-promo'
1+
import { getFreebucksInfo } from '@codebuff/common/types/freebuff-session'
2+
import {
3+
SOLAR_PRICE_CHANGES,
4+
solarOfferAt,
5+
} from '@codebuff/common/constants/freebuff-solar-promo'
26
import {
37
toLandingSession,
48
resolveFreebuffModelPickForSession,
@@ -73,7 +77,7 @@ afterEach(() => {
7377

7478
const renderSelector = async (
7579
maxHeight = 40,
76-
startSession?: (model: string) => Promise<void>,
80+
startSession?: (model: string, limit?: number | 'session') => Promise<void>,
7781
) => {
7882
// Tear down any selector this test already rendered. Only the LAST one was
7983
// reachable from afterEach, so a test that renders twice used to leave the
@@ -1118,8 +1122,12 @@ test('an open Solar CLI picker leaves the holiday price at the cutoff and submit
11181122
[FREEBUFF_GLM_V53_FLASH_MODEL_ID]: 5,
11191123
[FREEBUFF_SOLAR_PRO_4_MODEL_ID]: 0,
11201124
}),
1121-
priceNotices: { [FREEBUFF_SOLAR_PRO_4_MODEL_ID]: solarOfferAt(cutoff - 137).tagline },
1122-
priceChanges: SOLAR_PRICE_CHANGES.filter((change) => Date.parse(change.at) >= cutoff),
1125+
priceNotices: {
1126+
[FREEBUFF_SOLAR_PRO_4_MODEL_ID]: solarOfferAt(cutoff - 137).tagline,
1127+
},
1128+
priceChanges: SOLAR_PRICE_CHANGES.filter(
1129+
(change) => Date.parse(change.at) >= cutoff,
1130+
),
11231131
},
11241132
})
11251133
useFreebuffModelStore
@@ -1155,7 +1163,9 @@ test('an open Solar CLI picker leaves the holiday price at the cutoff and submit
11551163
await setup.renderOnce()
11561164
expect(setup.captureCharFrame()).not.toContain('Labor Day weekend')
11571165
expect(setup.captureCharFrame()).toContain('Solar Pro 4')
1158-
expect(setup.captureCharFrame()).toMatch(/Solar Pro 4[^\n]*\n[^\n]*5 Freebucks\/hr/)
1166+
expect(setup.captureCharFrame()).toMatch(
1167+
/Solar Pro 4[^\n]*\n[^\n]*5 Freebucks\/hr/,
1168+
)
11591169
await setup.mockInput.pressEnter()
11601170
await setup.renderOnce()
11611171
expect(requested).toEqual([FREEBUFF_SOLAR_PRO_4_MODEL_ID])
@@ -1230,3 +1240,166 @@ describe('FreebuffModelSelector limited upgrade CTA', () => {
12301240
).not.toContain('usage for $')
12311241
})
12321242
})
1243+
1244+
describe('unavailable balances in the mounted CLI picker', () => {
1245+
test.each(['full', 'limited'] as const)(
1246+
'%s: fresh admission requires confirmation and ignores exhausted legacy quotas',
1247+
async (accessTier) => {
1248+
const id = FREEBUFF_GLM_V53_FLASH_MODEL_ID
1249+
const pending = {
1250+
status: 'none' as const,
1251+
accessTier,
1252+
freebucks: null,
1253+
rateLimitsByModel: {
1254+
[id]: {
1255+
model: id,
1256+
limit: 0,
1257+
recentCount: 0,
1258+
period: 'pacific_day' as const,
1259+
resetTimeZone: 'America/Los_Angeles',
1260+
resetAt: '2027-01-01',
1261+
windowHours: 24,
1262+
},
1263+
},
1264+
}
1265+
useFreebuffSessionStore.getState().setSession(pending)
1266+
useFreebuffModelStore.getState().setSelectedModel(id)
1267+
const requests: string[] = []
1268+
const limits: (number | 'session' | undefined)[] = []
1269+
const setup = await renderSelector(40, async (model, limit) => {
1270+
limits.push(limit)
1271+
requests.push(
1272+
resolveFreebuffModelPickForSession(
1273+
model,
1274+
useFreebuffSessionStore.getState().session,
1275+
),
1276+
)
1277+
})
1278+
expect(getSelectedFreebuffModel()).toBe(id)
1279+
expect(setup.captureCharFrame()).toContain(
1280+
'balance temporarily unavailable',
1281+
)
1282+
expect(setup.captureCharFrame()).not.toContain('0 of 0')
1283+
flushSync(() => setup.mockInput.pressEnter())
1284+
await setup.renderOnce()
1285+
expect(requests).toEqual([])
1286+
expect(setup.captureCharFrame()).toContain('Balance unavailable')
1287+
flushSync(() => setup.mockInput.pressEnter())
1288+
await setup.renderOnce()
1289+
expect(requests).toEqual([id])
1290+
// A poll recovers the open control without remounting or changing its model.
1291+
useFreebuffSessionStore
1292+
.getState()
1293+
.setSession({ ...pending, freebucks: freebucksFixture(5) })
1294+
await setup.renderOnce()
1295+
expect(setup.captureCharFrame()).not.toContain('unavailable')
1296+
expect(setup.captureCharFrame()).toContain('5 Freebucks/hr')
1297+
flushSync(() => setup.mockInput.pressEnter())
1298+
await setup.renderOnce()
1299+
expect(requests).toEqual([id, id])
1300+
const known = freebucksFixture(5)
1301+
useFreebuffSessionStore.getState().setSession({
1302+
...pending,
1303+
freebucks: {
1304+
...known,
1305+
daily: { ...known.daily, remaining: 0 },
1306+
wallet: { ...known.wallet, balance: 5 },
1307+
},
1308+
})
1309+
await setup.renderOnce()
1310+
flushSync(() => setup.mockInput.pressEnter())
1311+
await setup.renderOnce()
1312+
expect(requests).toEqual([id, id])
1313+
expect(setup.captureCharFrame()).toContain(
1314+
'Enter uses 5 from your wallet',
1315+
)
1316+
flushSync(() => setup.mockInput.pressEnter())
1317+
await setup.renderOnce()
1318+
expect(requests).toEqual([id, id, id])
1319+
expect(limits).toEqual(['session', undefined, 5])
1320+
},
1321+
)
1322+
1323+
test.each(['full', 'limited'] as const)(
1324+
'%s: paid reuse is accessible until expiry, then asks before admission',
1325+
async (accessTier) => {
1326+
const id = FREEBUFF_GLM_V53_FLASH_MODEL_ID
1327+
const live = {
1328+
status: 'active' as const,
1329+
accessTier,
1330+
model: id,
1331+
instanceId: 'paid-picker',
1332+
admittedAt: new Date(FIXED_NOW_MS - 30_000).toISOString(),
1333+
remainingMs: 30_000,
1334+
expiresAt: new Date(FIXED_NOW_MS + 30_000).toISOString(),
1335+
freebucks: null,
1336+
}
1337+
useFreebuffSessionStore.getState().setSession(live)
1338+
useFreebuffModelStore.getState().setSelectedModel(id)
1339+
const requests: string[] = []
1340+
const setup = await renderSelector(40, async (model) => {
1341+
requests.push(model)
1342+
})
1343+
flushSync(() => setup.mockInput.pressEnter())
1344+
await setup.renderOnce()
1345+
expect(requests).toEqual([id])
1346+
// Even a known zero balance cannot hide a paid reuse.
1347+
useFreebuffSessionStore
1348+
.getState()
1349+
.setSession({ ...live, freebucks: freebucksFixture(0) })
1350+
await setup.renderOnce()
1351+
flushSync(() => setup.mockInput.pressEnter())
1352+
await setup.renderOnce()
1353+
expect(requests).toEqual([id, id])
1354+
useFreebuffSessionStore.getState().setSession({
1355+
...live,
1356+
expiresAt: new Date(FIXED_NOW_MS).toISOString(),
1357+
})
1358+
await setup.renderOnce()
1359+
flushSync(() => setup.mockInput.pressEnter())
1360+
await setup.renderOnce()
1361+
expect(requests).toHaveLength(2)
1362+
expect(setup.captureCharFrame()).toContain('Balance unavailable')
1363+
flushSync(() => setup.mockInput.pressEnter())
1364+
await setup.renderOnce()
1365+
expect(requests).toEqual([id, id, id])
1366+
},
1367+
)
1368+
1369+
test('returning to the landing picker preserves null rather than reviving the legacy meter', () => {
1370+
expect(
1371+
toLandingSession({ status: 'ended', freebucks: null }).freebucks,
1372+
).toBeNull()
1373+
expect(toLandingSession({ status: 'ended' }).freebucks).toBeUndefined()
1374+
})
1375+
})
1376+
1377+
1378+
test.each(['full', 'limited'] as const)(
1379+
'%s earned grants offer confirmation without inflating the CLI balance',
1380+
async (accessTier) => {
1381+
const id = FREEBUFF_GLM_V53_FLASH_MODEL_ID
1382+
useFreebuffSessionStore
1383+
.getState()
1384+
.setSession({
1385+
status: 'none',
1386+
accessTier,
1387+
freebucks: { ...freebucksFixture(0), claimableGrantFreebucks: 15 },
1388+
})
1389+
useFreebuffModelStore.getState().setSelectedModel(id)
1390+
const picked: string[] = []
1391+
const setup = await renderSelector(40, async (model) => {
1392+
picked.push(model)
1393+
})
1394+
flushSync(() => setup.mockInput.pressEnter())
1395+
await setup.renderOnce()
1396+
expect(picked).toEqual([])
1397+
expect(setup.captureCharFrame()).toContain('Claim earned Freebucks')
1398+
expect(getFreebucksInfo(useFreebuffSessionStore.getState().session!)?.balance).toBe(
1399+
0,
1400+
)
1401+
flushSync(() => setup.mockInput.pressEnter())
1402+
await setup.renderOnce()
1403+
expect(picked).toEqual([id])
1404+
},
1405+
)

cli/src/components/freebuff-landing-screen.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,7 @@ export const FreebuffLandingScreen: React.FC<FreebuffLandingScreenProps> = ({
451451
// instead.
452452
const belowPickerNotices = compact
453453
? []
454-
: freebucksOf(session)
454+
: freebucksOf(session) !== undefined
455455
? [FREEBUCKS_PICKER_NOTICE]
456456
: accessTier === 'limited'
457457
? [getLimitedModeNotice(session)]

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

Lines changed: 59 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,7 @@ export const FreebuffModelSelector: React.FC<FreebuffModelSelectorProps> = ({
283283
// accounts it meters, so there is no client-side role check here that could
284284
// drift from what is actually charged.
285285
const freebucks = freebucksOf(session)
286+
const balanceUnavailable = freebucks === null
286287
// The plan the daily pool was sized from. `planId` is the server's own
287288
// verdict, so the name cannot disagree with the number beside it.
288289
const planName = freebucks?.planId
@@ -291,7 +292,10 @@ export const FreebuffModelSelector: React.FC<FreebuffModelSelectorProps> = ({
291292
// The live session's model, for the switch question. `activeModel` is only
292293
// set while a session is running, so an idle picker never asks.
293294
const activeSessionModel =
294-
session?.status === 'active' ? session.model : undefined
295+
session?.status === 'active' &&
296+
Date.parse(session.expiresAt) > (nowMs ?? Date.now())
297+
? session.model
298+
: undefined
295299
const availableModels = useMemo(
296300
// CHEAPEST FIRST once metered — the same order Web and Desktop use. Off
297301
// the meter this returns the catalog untouched, so the recommended-first
@@ -334,7 +338,8 @@ export const FreebuffModelSelector: React.FC<FreebuffModelSelectorProps> = ({
334338
const rateLimitsByModel = getRateLimitsByModel(session)
335339
const [, refreshPrices] = useState(0)
336340
useEffect(
337-
() => watchFreebucksPriceChanges(freebucks, () => refreshPrices((n) => n + 1)),
341+
() =>
342+
watchFreebucksPriceChanges(freebucks, () => refreshPrices((n) => n + 1)),
338343
[freebucks],
339344
)
340345
const taglineFor = useCallback(
@@ -447,7 +452,11 @@ export const FreebuffModelSelector: React.FC<FreebuffModelSelectorProps> = ({
447452
if (freebucks?.peak && isFreebucksPeakModel(freebucks, model.id)) {
448453
const base = (rowPrice ?? 0) - freebucks.peak.surcharge
449454
details.push({
450-
text: freebucksPeakCopy({ peak: freebucks.peak, basePrice: base, now }).tooltip,
455+
text: freebucksPeakCopy({
456+
peak: freebucks.peak,
457+
basePrice: base,
458+
now,
459+
}).tooltip,
451460
warn: true,
452461
})
453462
}
@@ -483,7 +492,13 @@ export const FreebuffModelSelector: React.FC<FreebuffModelSelectorProps> = ({
483492
}
484493
return details
485494
},
486-
[deploymentAvailabilityLabel, now, premiumSectionQuotas, meterFor, freebucks],
495+
[
496+
deploymentAvailabilityLabel,
497+
now,
498+
premiumSectionQuotas,
499+
meterFor,
500+
freebucks,
501+
],
487502
)
488503
const rowDetailsText = useCallback(
489504
(model: FreebuffModelOption): string =>
@@ -503,9 +518,15 @@ export const FreebuffModelSelector: React.FC<FreebuffModelSelectorProps> = ({
503518
// a quota nobody is using.
504519
const offer = offerByModelId.get(modelId)
505520
if (offer) return offer.userRemaining > 0
521+
if (
522+
session?.status === 'active' &&
523+
session.model === modelId &&
524+
Date.parse(session.expiresAt) > (nowMs ?? Date.now())
525+
)
526+
return true
506527
return meterFor(modelId).canStart
507528
},
508-
[now, offerByModelId, meterFor],
529+
[now, nowMs, session, offerByModelId, meterFor],
509530
)
510531

511532
const recommendedModel = useMemo(() => {
@@ -529,8 +550,15 @@ export const FreebuffModelSelector: React.FC<FreebuffModelSelectorProps> = ({
529550
*/
530551
const rowIntent = useCallback(
531552
(modelId: string) =>
532-
freebucksRowIntent(freebucks, modelId, activeSessionModel),
533-
[freebucks, activeSessionModel],
553+
freebucksRowIntent(
554+
freebucks,
555+
modelId,
556+
session?.status === 'active' &&
557+
Date.parse(session.expiresAt) > (nowMs ?? Date.now())
558+
? session.model
559+
: undefined,
560+
),
561+
[freebucks, session, nowMs],
534562
)
535563

536564
/**
@@ -621,10 +649,15 @@ export const FreebuffModelSelector: React.FC<FreebuffModelSelectorProps> = ({
621649
)} against ${formatFreebucks(freebucks?.balance ?? 0)} left. Enter opens plans.`
622650
}
623651
if (intent.kind === 'confirm') {
652+
if (intent.price === undefined) {
653+
return `Balance unavailable. Enter may spend wallet Freebucks${activeSessionModel ? ' and end this session' : ''}.`
654+
}
624655
// ONE question. When a switch would also dip into the wallet the
625656
// wallet is the fact that matters — the daily pool refills, the wallet
626657
// does not — so the overage wording wins outright and the session
627658
// ending is a clause inside it, never a second prompt.
659+
if (intent.claimEarned)
660+
return `Claim earned Freebucks on admission, then spend ${intent.price} for this session. Enter to confirm.`
628661
return intent.walletSpend > 0
629662
? `Today's ${FREEBUCKS_LABEL} are spent. Enter uses ${formatFreebucks(
630663
intent.walletSpend,
@@ -1141,7 +1174,12 @@ export const FreebuffModelSelector: React.FC<FreebuffModelSelectorProps> = ({
11411174
// Two Enter events can arrive before React commits the pending state.
11421175
admissionPending.current = true
11431176
setPending(modelId)
1144-
startSession(modelId).finally(() => {
1177+
startSession(
1178+
modelId,
1179+
intent.kind === 'confirm'
1180+
? (intent.walletSpend ?? 'session')
1181+
: undefined,
1182+
).finally(() => {
11451183
admissionPending.current = false
11461184
setPending(null)
11471185
})
@@ -1566,14 +1604,23 @@ export const FreebuffModelSelector: React.FC<FreebuffModelSelectorProps> = ({
15661604
meters for one account is the arrangement that lies outright: the
15671605
windows count sessions that nothing charges any more, while the
15681606
balance quietly drains beside them. */}
1607+
{balanceUnavailable && (
1608+
<text style={{ fg: theme.muted, marginTop: SECTION_GAP }}>
1609+
Freebucks balance temporarily unavailable.
1610+
</text>
1611+
)}
15691612
{freebucks && (
15701613
<text
1571-
style={{ fg: theme.muted, wrapMode: 'none', marginTop: SECTION_GAP }}
1614+
style={{
1615+
fg: theme.muted,
1616+
wrapMode: 'none',
1617+
marginTop: SECTION_GAP,
1618+
}}
15721619
>
15731620
{planName.toUpperCase()} · {freebucksHeaderLine(freebucks, now)}
15741621
</text>
15751622
)}
1576-
{!freebucks && freeWindows && !planSummary && (
1623+
{freebucks === undefined && freeWindows && !planSummary && (
15771624
<text
15781625
style={{
15791626
fg: theme.muted,
@@ -1584,7 +1631,7 @@ export const FreebuffModelSelector: React.FC<FreebuffModelSelectorProps> = ({
15841631
FREE · {formatPlanWindows(freeWindows as never)}
15851632
</text>
15861633
)}
1587-
{!freebucks && planSummary && (
1634+
{freebucks === undefined && planSummary && (
15881635
<text
15891636
style={{
15901637
fg: theme.muted,
@@ -1600,7 +1647,7 @@ export const FreebuffModelSelector: React.FC<FreebuffModelSelectorProps> = ({
16001647
overruns the card width, and wrapMode 'none' clips it silently — the
16011648
one part of the summary a blocked user actually needs was the part
16021649
that vanished. */}
1603-
{!freebucks && planSummary?.blocked && (
1650+
{freebucks === undefined && planSummary?.blocked && (
16041651
<text style={{ fg: theme.secondary, wrapMode: 'none' }}>
16051652
{planSummary.blocked.label}
16061653
{planSummary.blocked.resetsAt

0 commit comments

Comments
 (0)