1- import React , { useState , useCallback } from "react" ;
2- import { Plus , Loader2 } from "lucide-react" ;
1+ import React , { useState , useCallback , useEffect , useRef } from "react" ;
2+ import { Plus , Loader2 , GripVertical , X } from "lucide-react" ;
33import { SUPPORTED_PROVIDERS , PROVIDER_DISPLAY_NAMES } from "@/common/constants/providers" ;
44import { KNOWN_MODELS } from "@/common/constants/knownModels" ;
55import { useModelsFromSettings } from "@/browser/hooks/useModelsFromSettings" ;
@@ -15,6 +15,11 @@ import {
1515 SelectValue ,
1616} from "@/browser/components/ui/select" ;
1717import { Button } from "@/browser/components/ui/button" ;
18+ import { ModelSelector } from "@/browser/components/ModelSelector" ;
19+ import { MODE_AI_DEFAULTS_KEY } from "@/common/constants/storage" ;
20+ import { normalizeModeAiDefaults , type ModeAiDefaults } from "@/common/types/modeAiDefaults" ;
21+ import { updatePersistedState } from "@/browser/hooks/usePersistedState" ;
22+ import { formatModelDisplayName } from "@/common/utils/ai/modelDisplay" ;
1823
1924// Providers to exclude from the custom models UI (handled specially or internal)
2025const HIDDEN_PROVIDERS = new Set ( [ "mux-gateway" ] ) ;
@@ -37,13 +42,128 @@ export function ModelsSection() {
3742 const [ editing , setEditing ] = useState < EditingState | null > ( null ) ;
3843 const [ error , setError ] = useState < string | null > ( null ) ;
3944
45+ // Compaction fallback models state
46+ const [ compactionFallbacks , setCompactionFallbacksState ] = useState < string [ ] > ( [ ] ) ;
47+ const [ compactionLoaded , setCompactionLoaded ] = useState ( false ) ;
48+ const compactionSaveTimerRef = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
49+
4050 const selectableProviders = SUPPORTED_PROVIDERS . filter (
4151 ( provider ) => ! HIDDEN_PROVIDERS . has ( provider )
4252 ) ;
43- const { defaultModel, setDefaultModel, hiddenModels, hideModel, unhideModel } =
53+ const { models , defaultModel, setDefaultModel, hiddenModels, hideModel, unhideModel } =
4454 useModelsFromSettings ( ) ;
4555 const gateway = useGateway ( ) ;
4656
57+ // Load compaction fallbacks from config on mount
58+ useEffect ( ( ) => {
59+ if ( ! api ) return ;
60+
61+ void api . config
62+ . getConfig ( )
63+ . then ( ( cfg ) => {
64+ const normalized = normalizeModeAiDefaults ( cfg . modeAiDefaults ?? { } ) ;
65+ setCompactionFallbacksState ( normalized . compact ?. fallbackModels ?? [ ] ) ;
66+ setCompactionLoaded ( true ) ;
67+ } )
68+ . catch ( ( ) => {
69+ setCompactionLoaded ( true ) ;
70+ } ) ;
71+ } , [ api ] ) ;
72+
73+ // Debounced save for compaction fallback changes
74+ const saveCompactionFallbacks = useCallback (
75+ ( fallbacks : string [ ] ) => {
76+ // Clear any pending save
77+ if ( compactionSaveTimerRef . current ) {
78+ clearTimeout ( compactionSaveTimerRef . current ) ;
79+ }
80+
81+ compactionSaveTimerRef . current = setTimeout ( ( ) => {
82+ if ( ! api ) return ;
83+
84+ const fallbackModels = fallbacks . length > 0 ? fallbacks : undefined ;
85+
86+ // Update local cache immediately for non-React readers
87+ updatePersistedState < ModeAiDefaults > (
88+ MODE_AI_DEFAULTS_KEY ,
89+ ( prev ) => {
90+ const next = { ...prev } ;
91+ if ( ! fallbackModels ) {
92+ if ( next . compact ) {
93+ delete next . compact . fallbackModels ;
94+ if ( ! next . compact . modelString && ! next . compact . thinkingLevel ) delete next . compact ;
95+ }
96+ } else {
97+ next . compact = { ...next . compact , fallbackModels } ;
98+ }
99+ return next ;
100+ } ,
101+ { }
102+ ) ;
103+
104+ // Persist to backend
105+ void api . config . getConfig ( ) . then ( ( cfg ) => {
106+ const existing = normalizeModeAiDefaults ( cfg . modeAiDefaults ?? { } ) ;
107+ const updated : ModeAiDefaults = { ...existing } ;
108+
109+ if ( ! fallbackModels ) {
110+ if ( updated . compact ) {
111+ delete updated . compact . fallbackModels ;
112+ if ( ! updated . compact . modelString && ! updated . compact . thinkingLevel ) {
113+ delete updated . compact ;
114+ }
115+ }
116+ } else {
117+ updated . compact = { ...updated . compact , fallbackModels } ;
118+ }
119+
120+ void api . config . updateModeAiDefaults ( { modeAiDefaults : updated } ) ;
121+ } ) ;
122+ } , 400 ) ;
123+ } ,
124+ [ api ]
125+ ) ;
126+
127+ const addCompactionFallback = useCallback (
128+ ( model : string ) => {
129+ if ( ! model || compactionFallbacks . includes ( model ) ) return ;
130+ const updated = [ ...compactionFallbacks , model ] ;
131+ setCompactionFallbacksState ( updated ) ;
132+ saveCompactionFallbacks ( updated ) ;
133+ } ,
134+ [ compactionFallbacks , saveCompactionFallbacks ]
135+ ) ;
136+
137+ const removeCompactionFallback = useCallback (
138+ ( index : number ) => {
139+ const updated = compactionFallbacks . filter ( ( _ , i ) => i !== index ) ;
140+ setCompactionFallbacksState ( updated ) ;
141+ saveCompactionFallbacks ( updated ) ;
142+ } ,
143+ [ compactionFallbacks , saveCompactionFallbacks ]
144+ ) ;
145+
146+ const moveCompactionFallback = useCallback (
147+ ( fromIndex : number , toIndex : number ) => {
148+ if ( toIndex < 0 || toIndex >= compactionFallbacks . length ) return ;
149+ const updated = [ ...compactionFallbacks ] ;
150+ const [ moved ] = updated . splice ( fromIndex , 1 ) ;
151+ updated . splice ( toIndex , 0 , moved ) ;
152+ setCompactionFallbacksState ( updated ) ;
153+ saveCompactionFallbacks ( updated ) ;
154+ } ,
155+ [ compactionFallbacks , saveCompactionFallbacks ]
156+ ) ;
157+
158+ // Cleanup save timer on unmount
159+ useEffect ( ( ) => {
160+ return ( ) => {
161+ if ( compactionSaveTimerRef . current ) {
162+ clearTimeout ( compactionSaveTimerRef . current ) ;
163+ }
164+ } ;
165+ } , [ ] ) ;
166+
47167 // Check if a model already exists (for duplicate prevention)
48168 const modelExists = useCallback (
49169 ( provider : string , modelId : string , excludeOriginal ?: string ) : boolean => {
@@ -169,15 +289,99 @@ export function ModelsSection() {
169289 modelId : model . providerModelId ,
170290 fullId : model . id ,
171291 aliases : model . aliases ,
292+ contextWindow : model . contextWindow ,
293+ description : model . description ,
172294 } ) ) ;
173295
174296 const customModels = getCustomModels ( ) ;
175297
298+ // Models available for fallback selection (exclude already selected)
299+ const availableFallbackModels = models . filter ( ( m ) => ! compactionFallbacks . includes ( m ) ) ;
300+
176301 return (
177302 < div className = "space-y-4" >
178- < p className = "text-muted text-xs" >
179- Manage your models. Click the star to set a default model for new workspaces.
180- </ p >
303+ { /* Model Defaults */ }
304+ { compactionLoaded && (
305+ < div className = "border-border-medium bg-background-secondary rounded-md border p-3" >
306+ < div className = "text-foreground mb-3 text-sm font-medium" > Model Defaults</ div >
307+
308+ { /* Default Model */ }
309+ < div className = "mb-4 space-y-1" >
310+ < div className = "text-muted text-xs" > Default Model</ div >
311+ < ModelSelector
312+ value = { defaultModel }
313+ onChange = { setDefaultModel }
314+ models = { models }
315+ hiddenModels = { hiddenModels }
316+ />
317+ < div className = "text-muted-light text-[10px]" > Used for new workspaces</ div >
318+ </ div >
319+
320+ { /* Compaction Fallback Models */ }
321+ < div className = "space-y-2" >
322+ < div className = "text-muted text-xs" > Compaction Fallback Models</ div >
323+ < div className = "text-muted-light text-[10px]" >
324+ Models to try in order when context window is exceeded. Falls back to workspace model
325+ if none set.
326+ </ div >
327+
328+ { /* List of configured fallbacks */ }
329+ { compactionFallbacks . length > 0 && (
330+ < div className = "space-y-1" >
331+ { compactionFallbacks . map ( ( model , index ) => (
332+ < div
333+ key = { model }
334+ className = "border-border-light bg-modal-bg flex items-center gap-2 rounded border px-2 py-1"
335+ >
336+ < span className = "text-muted-light text-[10px] font-medium" > { index + 1 } .</ span >
337+ < GripVertical className = "text-muted-light h-3 w-3 cursor-grab" />
338+ < span className = "text-foreground flex-1 truncate text-xs" >
339+ { formatModelDisplayName ( model . split ( ":" ) [ 1 ] ?? model ) }
340+ </ span >
341+ < div className = "flex items-center gap-0.5" >
342+ < button
343+ type = "button"
344+ onClick = { ( ) => moveCompactionFallback ( index , index - 1 ) }
345+ disabled = { index === 0 }
346+ className = "text-muted hover:text-foreground p-0.5 disabled:opacity-30"
347+ title = "Move up"
348+ >
349+ ↑
350+ </ button >
351+ < button
352+ type = "button"
353+ onClick = { ( ) => moveCompactionFallback ( index , index + 1 ) }
354+ disabled = { index === compactionFallbacks . length - 1 }
355+ className = "text-muted hover:text-foreground p-0.5 disabled:opacity-30"
356+ title = "Move down"
357+ >
358+ ↓
359+ </ button >
360+ < button
361+ type = "button"
362+ onClick = { ( ) => removeCompactionFallback ( index ) }
363+ className = "text-muted hover:text-error p-0.5"
364+ title = "Remove"
365+ >
366+ < X className = "h-3 w-3" />
367+ </ button >
368+ </ div >
369+ </ div >
370+ ) ) }
371+ </ div >
372+ ) }
373+
374+ { /* Add fallback model */ }
375+ < ModelSelector
376+ value = ""
377+ emptyLabel = "Add fallback model..."
378+ onChange = { addCompactionFallback }
379+ models = { availableFallbackModels }
380+ hiddenModels = { hiddenModels }
381+ />
382+ </ div >
383+ </ div >
384+ ) }
181385
182386 { /* Custom Models - shown first */ }
183387 < div className = "space-y-1.5" >
@@ -236,14 +440,12 @@ export function ModelsSection() {
236440 modelId = { model . modelId }
237441 fullId = { model . fullId }
238442 isCustom = { true }
239- isDefault = { defaultModel === model . fullId }
240443 isEditing = { isModelEditing }
241444 editValue = { isModelEditing ? editing . newModelId : undefined }
242445 editError = { isModelEditing ? error : undefined }
243446 saving = { false }
244447 hasActiveEdit = { editing !== null }
245448 isGatewayEnabled = { gateway . modelUsesGateway ( model . fullId ) }
246- onSetDefault = { ( ) => setDefaultModel ( model . fullId ) }
247449 onStartEdit = { ( ) => handleStartEdit ( model . provider , model . modelId ) }
248450 onSaveEdit = { handleSaveEdit }
249451 onCancelEdit = { handleCancelEdit }
@@ -279,11 +481,11 @@ export function ModelsSection() {
279481 modelId = { model . modelId }
280482 fullId = { model . fullId }
281483 aliases = { model . aliases }
484+ contextWindow = { model . contextWindow }
485+ description = { model . description }
282486 isCustom = { false }
283- isDefault = { defaultModel === model . fullId }
284487 isEditing = { false }
285488 isGatewayEnabled = { gateway . modelUsesGateway ( model . fullId ) }
286- onSetDefault = { ( ) => setDefaultModel ( model . fullId ) }
287489 isHiddenFromSelector = { hiddenModels . includes ( model . fullId ) }
288490 onToggleVisibility = { ( ) =>
289491 hiddenModels . includes ( model . fullId )
0 commit comments