@@ -5,8 +5,8 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'
55import { Button } from './button'
66import {
77 FALLBACK_FREEBUFF_MODEL_ID ,
8- FREEBUFF_MODELS ,
98 getFreebuffDeploymentAvailabilityLabel ,
9+ getFreebuffModelsForAccessTier ,
1010 isFreebuffModelAvailable ,
1111 isFreebuffPremiumModelId ,
1212} from '@codebuff/common/constants/freebuff-models'
@@ -26,39 +26,18 @@ import {
2626import type { FreebuffModelOption } from '@codebuff/common/constants/freebuff-models'
2727import type { KeyEvent } from '@opentui/core'
2828
29- const FREEBUFF_MODEL_IDS = FREEBUFF_MODELS . map ( ( m ) => m . id )
30-
3129// Section grouping: premium models share one quota pool, unlimited has none.
3230// Putting the tier on a section header lets each row drop its redundant
3331// "Premium"/"Unlimited" chip. The shared 0/5 counter lives in the page title
3432// (rendered by the parent), not the section header — this picker is purely a
3533// list of choices grouped by tier. Empty sections are filtered so a model set
3634// with no premium (or no unlimited) entries doesn't render an orphan header.
3735type Section = {
38- key : 'premium' | 'unlimited'
36+ key : 'premium' | 'unlimited' | 'limited'
3937 label : string
4038 models : readonly FreebuffModelOption [ ]
4139}
4240
43- const SECTIONS : readonly Section [ ] = (
44- [
45- {
46- key : 'premium' ,
47- label : 'PREMIUM' ,
48- models : FREEBUFF_MODELS . filter ( ( m ) =>
49- isFreebuffPremiumModelId ( m . id ) ,
50- ) ,
51- } ,
52- {
53- key : 'unlimited' ,
54- label : 'UNLIMITED' ,
55- models : FREEBUFF_MODELS . filter (
56- ( m ) => ! isFreebuffPremiumModelId ( m . id ) ,
57- ) ,
58- } ,
59- ] satisfies readonly Section [ ]
60- ) . filter ( ( section ) => section . models . length > 0 )
61-
6241/**
6342 * Dual-purpose model picker:
6443 * - Pre-chat landing (session 'none'): user hasn't joined any queue. Picking
@@ -86,6 +65,8 @@ export const FreebuffModelSelector: React.FC = () => {
8665 const selectedModel = useFreebuffModelStore ( ( s ) => s . selectedModel )
8766 const setSelectedModel = useFreebuffModelStore ( ( s ) => s . setSelectedModel )
8867 const session = useFreebuffSessionStore ( ( s ) => s . session )
68+ const accessTier =
69+ session && 'accessTier' in session ? session . accessTier : 'full'
8970 const now = useNow ( 60_000 )
9071 const deploymentAvailabilityLabel = useMemo (
9172 ( ) => getFreebuffDeploymentAvailabilityLabel ( new Date ( now ) ) ,
@@ -98,9 +79,48 @@ export const FreebuffModelSelector: React.FC = () => {
9879 // selected model whenever the selection changes (after a successful switch
9980 // or an external selectedModel update).
10081 const [ focusedId , setFocusedId ] = useState < string > ( selectedModel )
82+ const availableModels = useMemo (
83+ ( ) => getFreebuffModelsForAccessTier ( accessTier ) ,
84+ [ accessTier ] ,
85+ )
86+ const availableModelIds = useMemo (
87+ ( ) => availableModels . map ( ( m ) => m . id ) ,
88+ [ availableModels ] ,
89+ )
90+ const sections = useMemo ( ( ) => {
91+ if ( accessTier === 'limited' ) {
92+ return [
93+ {
94+ key : 'limited' ,
95+ label : 'LIMITED' ,
96+ models : availableModels ,
97+ } ,
98+ ] satisfies readonly Section [ ]
99+ }
100+ return (
101+ [
102+ {
103+ key : 'premium' ,
104+ label : 'PREMIUM' ,
105+ models : availableModels . filter ( ( m ) => isFreebuffPremiumModelId ( m . id ) ) ,
106+ } ,
107+ {
108+ key : 'unlimited' ,
109+ label : 'UNLIMITED' ,
110+ models : availableModels . filter (
111+ ( m ) => ! isFreebuffPremiumModelId ( m . id ) ,
112+ ) ,
113+ } ,
114+ ] satisfies readonly Section [ ]
115+ ) . filter ( ( section ) => section . models . length > 0 )
116+ } , [ accessTier , availableModels ] )
101117 useEffect ( ( ) => {
102- setFocusedId ( selectedModel )
103- } , [ selectedModel ] )
118+ setFocusedId (
119+ availableModelIds . includes ( selectedModel )
120+ ? selectedModel
121+ : availableModelIds [ 0 ] ! ,
122+ )
123+ } , [ availableModelIds , selectedModel ] )
104124
105125 useEffect ( ( ) => {
106126 // Landing-screen safety net: if the in-memory selection becomes
@@ -110,11 +130,12 @@ export const FreebuffModelSelector: React.FC = () => {
110130 // preference (e.g. Kimi or DeepSeek) is preserved for the next launch.
111131 if (
112132 ( session ?. status === 'none' || ! session ) &&
113- ! isFreebuffModelAvailable ( selectedModel , new Date ( now ) )
133+ ( ! availableModelIds . includes ( selectedModel ) ||
134+ ! isFreebuffModelAvailable ( selectedModel , new Date ( now ) ) )
114135 ) {
115- setSelectedModel ( FALLBACK_FREEBUFF_MODEL_ID )
136+ setSelectedModel ( availableModelIds [ 0 ] ?? FALLBACK_FREEBUFF_MODEL_ID )
116137 }
117- } , [ now , selectedModel , session , setSelectedModel ] )
138+ } , [ availableModelIds , now , selectedModel , session , setSelectedModel ] )
118139
119140 const committedModelId = session ?. status === 'queued' ? session . model : null
120141 const rateLimitsByModel = getRateLimitsByModel ( session )
@@ -128,7 +149,7 @@ export const FreebuffModelSelector: React.FC = () => {
128149 // terminals where the secondary details spill to an indented second line.
129150 const { wrapDetails, buttonOuterWidth, nameColumnWidth } = useMemo ( ( ) => {
130151 const nameLen = ( m : FreebuffModelOption ) => m . displayName . length
131- const maxNameLen = Math . max ( ...FREEBUFF_MODELS . map ( nameLen ) )
152+ const maxNameLen = Math . max ( ...availableModels . map ( nameLen ) )
132153
133154 const detailsParts = ( model : FreebuffModelOption ) : number [ ] => {
134155 const parts = [ model . tagline . length ]
@@ -149,8 +170,7 @@ export const FreebuffModelSelector: React.FC = () => {
149170 joinedLen ( detailsParts ( model ) )
150171
151172 const maxOneLineOuter =
152- Math . max ( ...FREEBUFF_MODELS . map ( oneLineLen ) ) +
153- BUTTON_CHROME
173+ Math . max ( ...availableModels . map ( oneLineLen ) ) + BUTTON_CHROME
154174 if ( maxOneLineOuter <= contentMaxWidth ) {
155175 return {
156176 wrapDetails : false ,
@@ -173,7 +193,7 @@ export const FreebuffModelSelector: React.FC = () => {
173193 return parts . length === 0 ? 0 : 2 /* indent */ + joinedLen ( parts )
174194 }
175195 const maxTwoLineInner = Math . max (
176- ...FREEBUFF_MODELS . map ( ( m ) =>
196+ ...availableModels . map ( ( m ) =>
177197 Math . max ( labelLineLen ( m ) , detailsLineLen ( m ) ) ,
178198 ) ,
179199 )
@@ -185,7 +205,7 @@ export const FreebuffModelSelector: React.FC = () => {
185205 ) ,
186206 nameColumnWidth : maxNameLen ,
187207 }
188- } , [ contentMaxWidth , deploymentAvailabilityLabel ] )
208+ } , [ availableModels , contentMaxWidth , deploymentAvailabilityLabel ] )
189209
190210 const isJoinable = useCallback (
191211 ( modelId : string ) => {
@@ -228,7 +248,7 @@ export const FreebuffModelSelector: React.FC = () => {
228248 }
229249 if ( ! direction ) return
230250 const targetId = nextFreebuffModelId ( {
231- modelIds : FREEBUFF_MODEL_IDS ,
251+ modelIds : availableModelIds ,
232252 focusedId,
233253 direction,
234254 } )
@@ -238,7 +258,14 @@ export const FreebuffModelSelector: React.FC = () => {
238258 setFocusedId ( targetId )
239259 }
240260 } ,
241- [ pending , pick , focusedId , committedModelId , isJoinable ] ,
261+ [
262+ pending ,
263+ pick ,
264+ focusedId ,
265+ committedModelId ,
266+ isJoinable ,
267+ availableModelIds ,
268+ ] ,
242269 ) ,
243270 )
244271
@@ -345,7 +372,7 @@ export const FreebuffModelSelector: React.FC = () => {
345372 gap : 0 ,
346373 } }
347374 >
348- { SECTIONS . map ( ( section , sectionIdx ) => (
375+ { sections . map ( ( section , sectionIdx ) => (
349376 < box
350377 key = { section . key }
351378 style = { {
0 commit comments