Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 51 additions & 16 deletions cli/src/components/waiting-room-screen.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { TextAttributes } from '@opentui/core'
import { useKeyboard, useRenderer } from '@opentui/react'
import React, { useCallback, useMemo, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useState } from 'react'

import { Button } from './button'
import { ChoiceAdBanner, CHOICE_AD_BANNER_HEIGHT } from './choice-ad-banner'
import { FreebuffModelSelector } from './freebuff-model-selector'
import { ShimmerText } from './shimmer-text'
import { takeOverFreebuffSession } from '../hooks/use-freebuff-session'
import {
refreshFreebuffLandingMetadata,
takeOverFreebuffSession,
} from '../hooks/use-freebuff-session'
import { useFreebuffCtrlCExit } from '../hooks/use-freebuff-ctrl-c-exit'
import { useGravityAd } from '../hooks/use-gravity-ad'
import { useLogo } from '../hooks/use-logo'
Expand All @@ -15,6 +18,10 @@ import { useSheenAnimation } from '../hooks/use-sheen-animation'
import { useTerminalDimensions } from '../hooks/use-terminal-dimensions'
import { useTheme } from '../hooks/use-theme'
import { exitFreebuffCleanly } from '../utils/freebuff-exit'
import {
formatFreebuffPremiumResetCountdown,
getFreebuffPremiumResetAt,
} from '../utils/freebuff-premium-reset'
import { formatSessionUnits } from '../utils/format-session-units'
import { getLogoAccentColor, getLogoBlockColor } from '../utils/theme-system'
import { FREEBUFF_PREMIUM_SESSION_LIMIT } from '@codebuff/common/constants/freebuff-models'
Expand Down Expand Up @@ -247,30 +254,31 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({

const [exitHover, setExitHover] = useState(false)

// Elapsed-in-queue timer. Starts from `queuedAt` so it keeps ticking even if
// the user wanders away and comes back.
const queuedAtMs = useMemo(() => {
if (session?.status === 'queued') return Date.parse(session.queuedAt)
return null
}, [session])
const now = useNow(1000, queuedAtMs !== null)
const elapsedMs = queuedAtMs ? now - queuedAtMs : 0

const isQueued = session?.status === 'queued'
// 'none' = user hasn't joined any queue yet. We're in the pre-chat landing
// state: show the picker with live N-in-line hints and a prompt. Picking a
// model triggers joinFreebuffQueue, which POSTs and transitions us to
// 'queued' (waiting room) or straight to 'active' (chat) if no wait.
const isLanding = session?.status === 'none'
// Elapsed-in-queue timer. Starts from `queuedAt` so it keeps ticking even if
// the user wanders away and comes back. On the landing picker we tick once a
// minute so the premium reset countdown stays fresh.
const queuedAtMs = useMemo(() => {
if (session?.status === 'queued') return Date.parse(session.queuedAt)
return null
}, [session])
const now = useNow(isQueued ? 1000 : 60_000, isQueued || isLanding)
const elapsedMs = queuedAtMs ? now - queuedAtMs : 0

// Premium quota counter for the title line. All premium models share one
// pool; the server replicates the same snapshot under each premium model
// id, so any entry has the right count. Renders amber when exhausted so
// the limit reads as "you've hit it" rather than just another count.
const rateLimitsByModel = getRateLimitsByModel(session)
const sharedPremiumUsed = rateLimitsByModel
? (Object.values(rateLimitsByModel)[0]?.recentCount ?? 0)
: 0
const premiumRateLimit = rateLimitsByModel
? Object.values(rateLimitsByModel)[0]
: undefined
const sharedPremiumUsed = premiumRateLimit?.recentCount ?? 0
const isPremiumExhausted =
sharedPremiumUsed >= FREEBUFF_PREMIUM_SESSION_LIMIT
const premiumUsedColor = isPremiumExhausted ? theme.secondary : theme.muted
Expand All @@ -280,6 +288,26 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
const formattedSharedPremiumUsed = formatSessionUnits(
sharedPremiumUsed,
).padStart(sessionUnitWidth)
const premiumResetAt = getFreebuffPremiumResetAt({
rateLimitsByModel,
nowMs: now,
})
const premiumResetAtMs = premiumResetAt.getTime()
const premiumResetCountdown = formatFreebuffPremiumResetCountdown(
premiumResetAt,
now,
)

useEffect(() => {
if (!isLanding || !premiumRateLimit) return

const delayMs = Math.max(0, premiumResetAtMs - Date.now() + 1_000)
const timer = setTimeout(() => {
refreshFreebuffLandingMetadata().catch(() => {})
}, delayMs)

return () => clearTimeout(timer)
}, [isLanding, premiumRateLimit, premiumResetAtMs])

return (
<box
Expand Down Expand Up @@ -366,10 +394,17 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
<span fg={theme.foreground} attributes={TextAttributes.BOLD}>
Pick a model to start
</span>
</text>
<text
style={{ fg: theme.muted, marginBottom: 1, wrapMode: 'word' }}
>
<span fg={premiumUsedColor}>
{' · '}
{formattedSharedPremiumUsed} of{' '}
{FREEBUFF_PREMIUM_SESSION_LIMIT} premium sessions used today
{FREEBUFF_PREMIUM_SESSION_LIMIT} premium sessions used
</span>
<span fg={theme.muted}>
{' · '}
resets in {premiumResetCountdown}
</span>
</text>
<FreebuffModelSelector />
Expand Down
7 changes: 7 additions & 0 deletions cli/src/hooks/use-freebuff-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,13 @@ export function returnToFreebuffLanding(
})
}

/** Refresh picker-only metadata (quota and queue depths) while staying on the
* model selection screen. Used when a midnight-Pacific premium quota reset
* passes while the landing screen is open. */
export function refreshFreebuffLandingMetadata(): Promise<void> {
return restartFreebuffSession('landing')
}

/**
* Join (or re-queue for) `model`. Dual-purpose:
* - First join: called from the pre-chat landing picker. The session starts
Expand Down
79 changes: 79 additions & 0 deletions cli/src/utils/__tests__/freebuff-premium-reset.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, expect, test } from 'bun:test'

import {
formatFreebuffPremiumResetCountdown,
getFreebuffPremiumResetAt,
} from '../freebuff-premium-reset'

describe('freebuff premium reset helpers', () => {
test('uses server resetAt when it is in the future', () => {
const nowMs = Date.parse('2026-05-11T20:00:00.000Z')
const resetAt = getFreebuffPremiumResetAt({
nowMs,
rateLimitsByModel: {
'test/model': {
model: 'test/model',
limit: 5,
period: 'pacific_day',
resetTimeZone: 'America/Los_Angeles',
resetAt: '2026-05-12T07:00:00.000Z',
windowHours: 24,
recentCount: 2,
},
},
})

expect(resetAt.toISOString()).toBe('2026-05-12T07:00:00.000Z')
})

test('falls back to next midnight Pacific when resetAt is absent', () => {
const resetAt = getFreebuffPremiumResetAt({
nowMs: Date.parse('2026-05-11T20:00:00.000Z'),
})

expect(resetAt.toISOString()).toBe('2026-05-12T07:00:00.000Z')
})

test('keeps expired server resetAt instead of rolling stale quota forward', () => {
const nowMs = Date.parse('2026-05-12T07:05:00.000Z')
const resetAt = getFreebuffPremiumResetAt({
nowMs,
rateLimitsByModel: {
'test/model': {
model: 'test/model',
limit: 5,
period: 'pacific_day',
resetTimeZone: 'America/Los_Angeles',
resetAt: '2026-05-12T07:00:00.000Z',
windowHours: 24,
recentCount: 5,
},
},
})

expect(resetAt.toISOString()).toBe('2026-05-12T07:00:00.000Z')
expect(formatFreebuffPremiumResetCountdown(resetAt, nowMs)).toBe('now')
})

test('handles Pacific daylight saving time boundaries', () => {
const resetAt = getFreebuffPremiumResetAt({
nowMs: Date.parse('2026-01-15T20:00:00.000Z'),
})

expect(resetAt.toISOString()).toBe('2026-01-16T08:00:00.000Z')
})

test('formats hours and minutes left', () => {
const nowMs = Date.parse('2026-05-11T20:00:00.000Z')
const resetAt = new Date('2026-05-12T07:30:00.000Z')

expect(formatFreebuffPremiumResetCountdown(resetAt, nowMs)).toBe('11h 30m')
})

test('formats sub-hour reset countdowns', () => {
const nowMs = Date.parse('2026-05-12T06:30:00.000Z')
const resetAt = new Date('2026-05-12T07:00:00.000Z')

expect(formatFreebuffPremiumResetCountdown(resetAt, nowMs)).toBe('30m')
})
})
42 changes: 42 additions & 0 deletions cli/src/utils/freebuff-premium-reset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { FREEBUFF_PREMIUM_SESSION_RESET_TIMEZONE } from '@codebuff/common/constants/freebuff-models'
import { getZonedDayBounds } from '@codebuff/common/util/zoned-time'

import type { FreebuffSessionRateLimitByModel } from '@codebuff/common/types/freebuff-session'

export function getFreebuffPremiumResetAt(params: {
rateLimitsByModel?: FreebuffSessionRateLimitByModel
nowMs: number
}): Date {
const { rateLimitsByModel, nowMs } = params
const serverResetAt = rateLimitsByModel
? Object.values(rateLimitsByModel)[0]?.resetAt
: undefined
const parsedServerResetAt = serverResetAt ? new Date(serverResetAt) : null

if (
parsedServerResetAt &&
Number.isFinite(parsedServerResetAt.getTime())
) {
return parsedServerResetAt
}

return getZonedDayBounds(
new Date(nowMs),
FREEBUFF_PREMIUM_SESSION_RESET_TIMEZONE,
).resetsAt
}

export function formatFreebuffPremiumResetCountdown(
resetAt: Date,
nowMs: number,
): string {
const diffMs = resetAt.getTime() - nowMs
if (!Number.isFinite(diffMs) || diffMs <= 0) return 'now'

const totalMinutes = Math.max(1, Math.floor(diffMs / 60_000))
const hours = Math.floor(totalMinutes / 60)
const minutes = totalMinutes % 60

if (hours === 0) return `${minutes}m`
return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`
}
Loading