Skip to content

Commit 05c1031

Browse files
committed
feat: compaction fallback models and model info in Settings
- Add contextWindow and description fields to KnownModelDefinition - Replace single compaction model dropdown with ordered fallback list - Add reorder/remove controls for compaction fallback models - Display context window size (e.g., 200K, 1M) in model rows - Add info tooltip with model description for built-in models - Update ModeAiDefaults to support fallbackModels array - Add getCompactionFallbackModels() for ordered model fallback
1 parent 4c0b368 commit 05c1031

File tree

5 files changed

+327
-56
lines changed

5 files changed

+327
-56
lines changed

src/browser/components/Settings/sections/ModelRow.tsx

Lines changed: 31 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,29 @@
11
import React from "react";
2-
import { Check, Eye, Pencil, Star, Trash2, X } from "lucide-react";
2+
import { Check, Eye, Info, Pencil, Trash2, X } from "lucide-react";
33
import { createEditKeyHandler } from "@/browser/utils/ui/keybinds";
44
import { GatewayIcon } from "@/browser/components/icons/GatewayIcon";
55
import { cn } from "@/common/lib/utils";
66
import { Tooltip, TooltipTrigger, TooltipContent } from "@/browser/components/ui/tooltip";
77
import { ProviderWithIcon } from "@/browser/components/ProviderIcon";
88
import { Button } from "@/browser/components/ui/button";
99

10+
/** Format token count as human-readable string (e.g., 200000 -> "200K") */
11+
function formatTokenCount(tokens: number): string {
12+
if (tokens >= 1_000_000) {
13+
return `${(tokens / 1_000_000).toFixed(tokens % 1_000_000 === 0 ? 0 : 1)}M`;
14+
}
15+
if (tokens >= 1_000) {
16+
return `${(tokens / 1_000).toFixed(tokens % 1_000 === 0 ? 0 : 1)}K`;
17+
}
18+
return tokens.toString();
19+
}
20+
1021
export interface ModelRowProps {
1122
provider: string;
1223
modelId: string;
1324
fullId: string;
1425
aliases?: string[];
1526
isCustom: boolean;
16-
isDefault: boolean;
1727
isEditing: boolean;
1828
editValue?: string;
1929
editError?: string | null;
@@ -23,7 +33,10 @@ export interface ModelRowProps {
2333
isGatewayEnabled?: boolean;
2434
/** Whether this model is hidden from the selector */
2535
isHiddenFromSelector?: boolean;
26-
onSetDefault: () => void;
36+
/** Context window size in tokens */
37+
contextWindow?: number;
38+
/** Brief description of the model */
39+
description?: string;
2740
onStartEdit?: () => void;
2841
onSaveEdit?: () => void;
2942
onCancelEdit?: () => void;
@@ -74,6 +87,21 @@ export function ModelRow(props: ModelRowProps) {
7487
<TooltipContent align="center">Use with /m {props.aliases[0]}</TooltipContent>
7588
</Tooltip>
7689
)}
90+
{props.contextWindow && (
91+
<span className="text-muted-light shrink-0 text-[10px]">
92+
{formatTokenCount(props.contextWindow)}
93+
</span>
94+
)}
95+
{props.description && (
96+
<Tooltip>
97+
<TooltipTrigger asChild>
98+
<Info className="text-muted-light h-3 w-3 shrink-0 cursor-help" />
99+
</TooltipTrigger>
100+
<TooltipContent align="center" className="max-w-xs">
101+
{props.description}
102+
</TooltipContent>
103+
</Tooltip>
104+
)}
77105
</div>
78106
)}
79107
</div>
@@ -163,31 +191,6 @@ export function ModelRow(props: ModelRowProps) {
163191
</TooltipContent>
164192
</Tooltip>
165193
)}
166-
{/* Favorite/default button */}
167-
<Tooltip>
168-
<TooltipTrigger asChild>
169-
<Button
170-
variant="ghost"
171-
size="icon"
172-
onClick={() => {
173-
if (!props.isDefault) props.onSetDefault();
174-
}}
175-
className={cn(
176-
"h-6 w-6",
177-
props.isDefault
178-
? "cursor-default text-yellow-400 hover:text-yellow-400"
179-
: "text-muted hover:text-yellow-400"
180-
)}
181-
disabled={props.isDefault}
182-
aria-label={props.isDefault ? "Current default model" : "Set as default model"}
183-
>
184-
<Star className={cn("h-3.5 w-3.5", props.isDefault && "fill-current")} />
185-
</Button>
186-
</TooltipTrigger>
187-
<TooltipContent align="center">
188-
{props.isDefault ? "Default model" : "Set as default"}
189-
</TooltipContent>
190-
</Tooltip>
191194
{/* Edit/delete buttons only for custom models */}
192195
{props.isCustom && (
193196
<>

src/browser/components/Settings/sections/ModelsSection.tsx

Lines changed: 212 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
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";
33
import { SUPPORTED_PROVIDERS, PROVIDER_DISPLAY_NAMES } from "@/common/constants/providers";
44
import { KNOWN_MODELS } from "@/common/constants/knownModels";
55
import { useModelsFromSettings } from "@/browser/hooks/useModelsFromSettings";
@@ -15,6 +15,11 @@ import {
1515
SelectValue,
1616
} from "@/browser/components/ui/select";
1717
import { 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)
2025
const 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

Comments
 (0)