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
51 changes: 30 additions & 21 deletions cli/src/components/freebuff-model-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Button } from './button'
import {
FALLBACK_FREEBUFF_MODEL_ID,
FREEBUFF_DEPLOYMENT_HOURS_LABEL,
FREEBUFF_GLM_MODEL_ID,
FREEBUFF_MODELS,
getFreebuffDeploymentAvailabilityLabel,
isFreebuffModelAvailable,
} from '@codebuff/common/constants/freebuff-models'

Expand Down Expand Up @@ -48,6 +48,10 @@ export const FreebuffModelSelector: React.FC = () => {
const setSelectedModel = useFreebuffModelStore((s) => s.setSelectedModel)
const session = useFreebuffSessionStore((s) => s.session)
const now = useNow(60_000)
const deploymentAvailabilityLabel = useMemo(
() => getFreebuffDeploymentAvailabilityLabel(new Date(now)),
[now],
)
const [pending, setPending] = useState<string | null>(null)
const [hoveredId, setHoveredId] = useState<string | null>(null)
// Keyboard cursor — separate from the actually-selected model so that
Expand Down Expand Up @@ -96,7 +100,7 @@ export const FreebuffModelSelector: React.FC = () => {
out[id] =
id === session.model
? Math.max(0, session.position - 1)
: depths[id] ?? 0
: (depths[id] ?? 0)
}
return out
}
Expand Down Expand Up @@ -127,21 +131,20 @@ export const FreebuffModelSelector: React.FC = () => {
3 /* " · " */ +
model.tagline.length +
(model.availability === 'deployment_hours'
? 3 + FREEBUFF_DEPLOYMENT_HOURS_LABEL.length
? 3 + deploymentAvailabilityLabel.length
: 0) +
2 /* " " */ +
hintWidth
return sum + inner + BUTTON_CHROME + (idx > 0 ? GAP : 0)
}, 0)
// Leave a small margin for the surrounding padding on the waiting-room screen.
return total > terminalWidth - 4
}, [hintWidth, terminalWidth])
}, [deploymentAvailabilityLabel, hintWidth, terminalWidth])

// "Already committed to this model" — only when the server has us queued
// on it. On the landing screen (status 'none'), nothing is committed yet,
// so picking the focused model is always a real action (first join).
const committedModelId =
session?.status === 'queued' ? session.model : null
const committedModelId = session?.status === 'queued' ? session.model : null

const pick = useCallback(
(modelId: string) => {
Expand All @@ -166,7 +169,8 @@ export const FreebuffModelSelector: React.FC = () => {
name === 'right' || name === 'down' || (name === 'tab' && !key.shift)
const isBackward =
name === 'left' || name === 'up' || (name === 'tab' && key.shift)
const isCommit = name === 'return' || name === 'enter' || name === 'space'
const isCommit =
name === 'return' || name === 'enter' || name === 'space'
if (!isForward && !isBackward && !isCommit) return
if (isCommit) {
if (
Expand Down Expand Up @@ -222,19 +226,20 @@ export const FreebuffModelSelector: React.FC = () => {
const isAvailable = isFreebuffModelAvailable(model.id, new Date(now))
const indicator = isSelected ? '●' : '○'
const indicatorColor = isSelected ? theme.primary : theme.muted
const labelColor = isSelected && isAvailable ? theme.foreground : theme.muted
const labelColor =
isSelected && isAvailable ? theme.foreground : theme.muted
// Clickable whenever picking would actually do something — i.e.
// anything except re-picking the queue we're already in.
const interactable = !pending && isAvailable && model.id !== committedModelId
const interactable =
!pending && isAvailable && model.id !== committedModelId
const ahead = aheadByModel?.[model.id]
const hint =
!isAvailable
? 'Closed'
: ahead === undefined
? ''
: ahead === 0
? 'No wait'
: `${ahead} ahead`
const hint = !isAvailable
? 'Closed'
: ahead === undefined
? ''
: ahead === 0
? 'No wait'
: `${ahead} ahead`

const borderColor = isSelected
? theme.primary
Expand All @@ -250,7 +255,9 @@ export const FreebuffModelSelector: React.FC = () => {
if (isAvailable) pick(model.id)
}}
onMouseOver={() => interactable && setHoveredId(model.id)}
onMouseOut={() => setHoveredId((curr) => (curr === model.id ? null : curr))}
onMouseOut={() =>
setHoveredId((curr) => (curr === model.id ? null : curr))
}
style={{
borderStyle: 'single',
borderColor,
Expand All @@ -263,15 +270,17 @@ export const FreebuffModelSelector: React.FC = () => {
<span fg={indicatorColor}>{indicator} </span>
<span
fg={labelColor}
attributes={isSelected ? TextAttributes.BOLD : TextAttributes.NONE}
attributes={
isSelected ? TextAttributes.BOLD : TextAttributes.NONE
}
>
{model.displayName}
</span>
<span fg={theme.muted}> · {model.tagline}</span>
{model.availability === 'deployment_hours' && (
<span fg={theme.muted}> · {FREEBUFF_DEPLOYMENT_HOURS_LABEL}</span>
<span fg={theme.muted}> · {deploymentAvailabilityLabel}</span>
)}
<span fg={theme.muted}> {hint.padEnd(hintWidth)}</span>
<span fg={theme.muted}> {hint.padEnd(hintWidth)}</span>
</text>
</Button>
)
Expand Down
50 changes: 50 additions & 0 deletions common/src/__tests__/freebuff-models.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, expect, test } from 'bun:test'

import {
getFreebuffDeploymentAvailabilityLabel,
isFreebuffDeploymentHours,
} from '../constants/freebuff-models'

describe('freebuff model availability', () => {
test('formats the close time in the user local timezone while deployment is open', () => {
expect(
getFreebuffDeploymentAvailabilityLabel(new Date('2026-01-05T18:00:00Z'), {
locale: 'en-US',
timeZone: 'America/Los_Angeles',
}),
).toBe('until 5:00 PM local')
})

test('formats the next open time in the user local timezone while deployment is closed', () => {
expect(
getFreebuffDeploymentAvailabilityLabel(new Date('2026-01-05T12:00:00Z'), {
locale: 'en-US',
timeZone: 'America/Los_Angeles',
}),
).toBe('opens 6:00 AM local')
})

test('includes the weekday when the next opening is on a later local day', () => {
expect(
getFreebuffDeploymentAvailabilityLabel(new Date('2026-01-10T20:00:00Z'), {
locale: 'en-US',
timeZone: 'America/Los_Angeles',
}),
).toBe('opens Mon 6:00 AM local')
})

test('tracks deployment hours correctly across the open and close boundaries', () => {
expect(isFreebuffDeploymentHours(new Date('2026-01-05T13:59:00Z'))).toBe(
false,
)
expect(isFreebuffDeploymentHours(new Date('2026-01-05T14:00:00Z'))).toBe(
true,
)
expect(isFreebuffDeploymentHours(new Date('2026-01-06T00:59:00Z'))).toBe(
true,
)
expect(isFreebuffDeploymentHours(new Date('2026-01-06T01:00:00Z'))).toBe(
false,
)
})
})
178 changes: 169 additions & 9 deletions common/src/constants/freebuff-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,28 @@ export interface FreebuffModelOption {
availability: 'always' | 'deployment_hours'
}

/** Server-facing fallback copy for APIs and provider errors that can't know
* the caller's local timezone. The CLI should render
* `getFreebuffDeploymentAvailabilityLabel()` instead. */
export const FREEBUFF_DEPLOYMENT_HOURS_LABEL = '9am ET-5pm PT'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Exported constant is now stale and no longer drives the displayed label

FREEBUFF_DEPLOYMENT_HOURS_LABEL ('9am ET-5pm PT') is still exported but the component now calls getFreebuffDeploymentAvailabilityLabel instead. Anyone importing the constant from another call site would still see the static string while the UI shows a dynamic local-time label. If this constant is no longer intended for external use, removing or deprecating it avoids silent drift; if it still needs to remain for other callers, a doc comment clarifying that it is not the UI-facing label would help.

Prompt To Fix With AI
This is a comment left during a code review.
Path: common/src/constants/freebuff-models.ts
Line: 20

Comment:
**Exported constant is now stale and no longer drives the displayed label**

`FREEBUFF_DEPLOYMENT_HOURS_LABEL` (`'9am ET-5pm PT'`) is still exported but the component now calls `getFreebuffDeploymentAvailabilityLabel` instead. Anyone importing the constant from another call site would still see the static string while the UI shows a dynamic local-time label. If this constant is no longer intended for external use, removing or deprecating it avoids silent drift; if it still needs to remain for other callers, a doc comment clarifying that it is not the UI-facing label would help.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in c703c2ac8. I kept the constant because web/src/server/free-session/public-api.ts and web/src/llm-api/fireworks.ts still need a timezone-agnostic server-side message, and added a doc comment clarifying that the CLI should use getFreebuffDeploymentAvailabilityLabel() instead.

export const FREEBUFF_GLM_MODEL_ID = 'z-ai/glm-5.1'
export const FREEBUFF_MINIMAX_MODEL_ID = 'minimax/minimax-m2.7'
const FREEBUFF_EASTERN_TIMEZONE = 'America/New_York'
const FREEBUFF_PACIFIC_TIMEZONE = 'America/Los_Angeles'

interface ZonedDateParts {
year: number
month: number
day: number
weekday: string
hour: number
minute: number
}
Comment on lines +29 to +36
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Redundant minutes field alongside minute in ZonedDateParts

The interface stores both minute (raw 0–59) and minutes (hour * 60 + minute) as similarly-named fields. minutes is only consumed in isFreebuffDeploymentHours; everywhere else the code uses hour and minute separately. Dropping minutes from the interface and inlining the arithmetic in isFreebuffDeploymentHours removes the ambiguity and one confusingly similar name:

Suggested change
interface ZonedDateParts {
year: number
month: number
day: number
weekday: string
hour: number
minute: number
minutes: number
}
interface ZonedDateParts {
year: number
month: number
day: number
weekday: string
hour: number
minute: number
}

Then in isFreebuffDeploymentHours:

return eastern.hour * 60 + eastern.minute >= 9 * 60 && pacific.hour * 60 + pacific.minute < 17 * 60
Prompt To Fix With AI
This is a comment left during a code review.
Path: common/src/constants/freebuff-models.ts
Line: 26-34

Comment:
**Redundant `minutes` field alongside `minute` in `ZonedDateParts`**

The interface stores both `minute` (raw 0–59) and `minutes` (`hour * 60 + minute`) as similarly-named fields. `minutes` is only consumed in `isFreebuffDeploymentHours`; everywhere else the code uses `hour` and `minute` separately. Dropping `minutes` from the interface and inlining the arithmetic in `isFreebuffDeploymentHours` removes the ambiguity and one confusingly similar name:

```suggestion
interface ZonedDateParts {
  year: number
  month: number
  day: number
  weekday: string
  hour: number
  minute: number
}
```

Then in `isFreebuffDeploymentHours`:
```ts
return eastern.hour * 60 + eastern.minute >= 9 * 60 && pacific.hour * 60 + pacific.minute < 17 * 60
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in c703c2ac8. I removed minutes from ZonedDateParts and now inline the hour * 60 + minute arithmetic directly in isFreebuffDeploymentHours() so the type no longer carries the duplicate field.


interface LocalTimeFormatOptions {
locale?: string
timeZone?: string
}

export const FREEBUFF_MODELS = [
{
Expand Down Expand Up @@ -71,31 +90,172 @@ export function getFreebuffModel(id: string): FreebuffModelOption {
)
}

function getZonedParts(
date: Date,
timeZone: string,
): { weekday: string; minutes: number } {
function getZonedParts(date: Date, timeZone: string): ZonedDateParts {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
weekday: 'short',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).formatToParts(date)
const value = (type: string) => parts.find((part) => part.type === type)?.value
const value = (type: string) =>
parts.find((part) => part.type === type)?.value
const year = Number(value('year') ?? 0)
const month = Number(value('month') ?? 1)
const day = Number(value('day') ?? 1)
const hour = Number(value('hour') ?? 0)
const minute = Number(value('minute') ?? 0)
return {
year,
month,
day,
weekday: value('weekday') ?? '',
minutes: hour * 60 + minute,
hour,
minute,
}
}

function addDaysToYmd(
year: number,
month: number,
day: number,
days: number,
): Pick<ZonedDateParts, 'year' | 'month' | 'day'> {
const next = new Date(Date.UTC(year, month - 1, day))
next.setUTCDate(next.getUTCDate() + days)
return {
year: next.getUTCFullYear(),
month: next.getUTCMonth() + 1,
day: next.getUTCDate(),
}
}

function getUtcForZonedTime(
parts: Pick<ZonedDateParts, 'year' | 'month' | 'day'>,
timeZone: string,
hour: number,
minute: number,
): Date {
let guess = new Date(
Date.UTC(parts.year, parts.month - 1, parts.day, hour, minute),
)

for (let i = 0; i < 3; i++) {
const actual = getZonedParts(guess, timeZone)
const desiredUtc = Date.UTC(
parts.year,
parts.month - 1,
parts.day,
hour,
minute,
)
const actualUtc = Date.UTC(
actual.year,
actual.month - 1,
actual.day,
actual.hour,
actual.minute,
)
guess = new Date(guess.getTime() + (desiredUtc - actualUtc))
}

return guess
}

function isWeekend(
parts: Pick<ZonedDateParts, 'year' | 'month' | 'day'>,
): boolean {
const weekday = getWeekdayIndex(parts)
return weekday === 0 || weekday === 6
}

function getWeekdayIndex(
parts: Pick<ZonedDateParts, 'year' | 'month' | 'day'>,
): number {
return new Date(Date.UTC(parts.year, parts.month - 1, parts.day)).getUTCDay()
}

function getNextFreebuffDeploymentStart(now: Date): Date {
const easternNow = getZonedParts(now, FREEBUFF_EASTERN_TIMEZONE)
const weekday = getWeekdayIndex(easternNow)
const isBeforeTodayOpen = easternNow.hour < 9

const offset =
weekday === 6
? 2
: weekday === 0
? 1
: isBeforeTodayOpen
? 0
: weekday === 5
? 3
: 1

return getUtcForZonedTime(
addDaysToYmd(easternNow.year, easternNow.month, easternNow.day, offset),
FREEBUFF_EASTERN_TIMEZONE,
9,
0,
)
}
Comment on lines +181 to +203
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 getNextFreebuffDeploymentStart loop is harder to follow than necessary

The 8-iteration loop skipping weekends and checking whether the 9 am ET candidate is still in the future can be replaced with direct arithmetic: if the current ET day is already past 9 am (or is a weekend), advance by the number of days to the next weekday, then construct the UTC instant directly. The current fallback after the loop (offset = 8) is also unreachable — in any 8-day window there will always be at least one weekday whose 9 am ET instant is in the future. Simplifying to at-most-a-few lines of offset arithmetic would make the intent clearer.

Prompt To Fix With AI
This is a comment left during a code review.
Path: common/src/constants/freebuff-models.ts
Line: 176-197

Comment:
**`getNextFreebuffDeploymentStart` loop is harder to follow than necessary**

The 8-iteration loop skipping weekends and checking whether the 9 am ET candidate is still in the future can be replaced with direct arithmetic: if the current ET day is already past 9 am (or is a weekend), advance by the number of days to the next weekday, then construct the UTC instant directly. The current fallback after the loop (`offset = 8`) is also unreachable — in any 8-day window there will always be at least one weekday whose 9 am ET instant is in the future. Simplifying to at-most-a-few lines of offset arithmetic would make the intent clearer.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in c703c2ac8. I replaced the loop with direct weekday-based offset arithmetic, so getNextFreebuffDeploymentStart() now computes the next ET 9am opening without the fallback scan.


function getCurrentFreebuffDeploymentEnd(now: Date): Date {
const pacificNow = getZonedParts(now, FREEBUFF_PACIFIC_TIMEZONE)
return getUtcForZonedTime(pacificNow, FREEBUFF_PACIFIC_TIMEZONE, 17, 0)
}

function isSameLocalDay(left: Date, right: Date, timeZone?: string): boolean {
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
return formatter.format(left) === formatter.format(right)
}

function formatLocalTime(
date: Date,
referenceNow: Date,
options: LocalTimeFormatOptions = {},
): string {
const shouldShowWeekday = !isSameLocalDay(
date,
referenceNow,
options.timeZone,
)
return new Intl.DateTimeFormat(options.locale, {
timeZone: options.timeZone,
weekday: shouldShowWeekday ? 'short' : undefined,
hour: 'numeric',
minute: '2-digit',
}).format(date)
}

export function getFreebuffDeploymentAvailabilityLabel(
now: Date = new Date(),
options: LocalTimeFormatOptions = {},
): string {
if (isFreebuffDeploymentHours(now)) {
const closesAt = getCurrentFreebuffDeploymentEnd(now)
return `until ${formatLocalTime(closesAt, now, options)} local`
}

const opensAt = getNextFreebuffDeploymentStart(now)
return `opens ${formatLocalTime(opensAt, now, options)} local`
}

export function isFreebuffDeploymentHours(now: Date = new Date()): boolean {
const eastern = getZonedParts(now, 'America/New_York')
const pacific = getZonedParts(now, 'America/Los_Angeles')
const eastern = getZonedParts(now, FREEBUFF_EASTERN_TIMEZONE)
const pacific = getZonedParts(now, FREEBUFF_PACIFIC_TIMEZONE)
if (eastern.weekday === 'Sat' || eastern.weekday === 'Sun') return false
return eastern.minutes >= 9 * 60 && pacific.minutes < 17 * 60
return (
eastern.hour * 60 + eastern.minute >= 9 * 60 &&
pacific.hour * 60 + pacific.minute < 17 * 60
)
}

export function isFreebuffModelAvailable(
Expand Down
Loading