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
13 changes: 12 additions & 1 deletion src/browser/components/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
const inputRef = useRef<HTMLTextAreaElement>(null);
const modelSelectorRef = useRef<ModelSelectorRef>(null);
const [mode, setMode] = useMode();
const { recentModels, addModel, evictModel } = useModelLRU();
const { recentModels, addModel, evictModel, defaultModel, setDefaultModel } = useModelLRU();
const commandListId = useId();
const telemetry = useTelemetry();
const [vimEnabled, setVimEnabled] = usePersistedState<boolean>(VIM_ENABLED_KEY, false, {
Expand Down Expand Up @@ -176,6 +176,15 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
[storageKeys.modelKey, addModel]
);

// When entering creation mode (or when the default model changes), reset the
// project-scoped model to the explicit default so manual picks don't bleed
// into subsequent creation flows.
useEffect(() => {
if (variant === "creation" && defaultModel) {
updatePersistedState(storageKeys.modelKey, defaultModel);
}
}, [variant, defaultModel, storageKeys.modelKey]);

// Creation-specific state (hook always called, but only used when variant === "creation")
// This avoids conditional hook calls which violate React rules
const creationState = useCreationWorkspace(
Expand Down Expand Up @@ -967,6 +976,8 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
recentModels={recentModels}
onRemoveModel={evictModel}
onComplete={() => inputRef.current?.focus()}
defaultModel={defaultModel}
onSetDefaultModel={setDefaultModel}
/>
<TooltipWrapper inline>
<HelpIndicator>?</HelpIndicator>
Expand Down
15 changes: 15 additions & 0 deletions src/browser/components/ModelSelector.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,18 @@ export const WithManyModels: Story = {
onComplete: action("onComplete"),
},
};

export const WithDefaultModel: Story = {
args: {
value: "anthropic:claude-sonnet-4-5",
onChange: action("onChange"),
onRemoveModel: action("onRemoveModel"),
recentModels: ["anthropic:claude-sonnet-4-5", "anthropic:claude-opus-4-1", "openai:gpt-5-pro"],
onComplete: action("onComplete"),
defaultModel: "anthropic:claude-opus-4-1",
onSetDefaultModel: (model) => {
// Mimic the hook behavior - only allow setting, not clearing
if (model) action("onSetDefaultModel")(model);
},
},
};
72 changes: 59 additions & 13 deletions src/browser/components/ModelSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,28 @@ import React, {
forwardRef,
} from "react";
import { cn } from "@/common/lib/utils";
import { Star } from "lucide-react";
import { TooltipWrapper, Tooltip } from "./Tooltip";

interface ModelSelectorProps {
value: string;
onChange: (value: string) => void;
recentModels: string[];
onRemoveModel?: (model: string) => void;
onComplete?: () => void;
defaultModel?: string | null;
onSetDefaultModel?: (model: string) => void;
}

export interface ModelSelectorRef {
open: () => void;
}

export const ModelSelector = forwardRef<ModelSelectorRef, ModelSelectorProps>(
({ value, onChange, recentModels, onRemoveModel, onComplete }, ref) => {
(
{ value, onChange, recentModels, onRemoveModel, onComplete, defaultModel, onSetDefaultModel },
ref
) => {
const [isEditing, setIsEditing] = useState(false);
const [inputValue, setInputValue] = useState(value);
const [error, setError] = useState<string | null>(null);
Expand Down Expand Up @@ -179,6 +186,14 @@ export const ModelSelector = forwardRef<ModelSelectorRef, ModelSelectorProps>(
setHighlightedIndex(currentIndex);
}, [recentModels, value]);

const handleSetDefault = (e: React.MouseEvent, model: string) => {
e.preventDefault();
e.stopPropagation();
if (defaultModel !== model && onSetDefaultModel) {
onSetDefaultModel(model);
}
};

// Expose open method to parent via ref
useImperativeHandle(
ref,
Expand Down Expand Up @@ -241,18 +256,49 @@ export const ModelSelector = forwardRef<ModelSelectorRef, ModelSelectorProps>(
)}
onClick={() => handleSelectModel(model)}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate">{model}</span>
{onRemoveModel && (
<button
type="button"
onClick={(event) => handleRemoveModel(model, event)}
className="text-muted-light border-border-light/40 hover:border-danger-soft/60 hover:text-danger-soft rounded-sm border px-1 py-0.5 text-[9px] font-semibold tracking-wide uppercase transition-colors duration-150"
aria-label={`Remove ${model} from recent models`}
>
×
</button>
)}
<div className="grid w-full grid-cols-[1fr_48px] items-center gap-2">
<span className="min-w-0 truncate">{model}</span>
<div className="grid w-[48px] grid-cols-[22px_22px] justify-items-center gap-1">
{onSetDefaultModel && (
<TooltipWrapper inline>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => handleSetDefault(e, model)}
className={cn(
"flex items-center justify-center rounded-sm border px-1 py-0.5 transition-colors duration-150",
defaultModel === model
? "text-yellow-400 border-yellow-400/40 cursor-default"
: "text-muted-light border-border-light/40 hover:border-foreground/60 hover:text-foreground"
)}
aria-label={
defaultModel === model
? "Current default model"
: "Set as default model"
}
disabled={defaultModel === model}
>
<Star className="h-3 w-3" />
</button>
<Tooltip className="tooltip" align="center">
{defaultModel === model
? "Current default model"
: "Set as default model"}
</Tooltip>
</TooltipWrapper>
)}
{onRemoveModel && defaultModel !== model && (
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={(event) => handleRemoveModel(model, event)}
className="text-muted-light border-border-light/40 hover:border-danger-soft/60 hover:text-danger-soft rounded-sm border px-1 py-0.5 text-[9px] font-semibold tracking-wide uppercase transition-colors duration-150"
aria-label={`Remove ${model} from recent models`}
>
×
</button>
)}
</div>
</div>
</div>
))}
Expand Down
4 changes: 2 additions & 2 deletions src/browser/hooks/useAIViewKeybinds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { updatePersistedState, readPersistedState } from "@/browser/hooks/usePer
import type { ThinkingLevel, ThinkingLevelOn } from "@/common/types/thinking";
import { DEFAULT_THINKING_LEVEL } from "@/common/types/thinking";
import { getThinkingPolicyForModel } from "@/browser/utils/thinking/policy";
import { getDefaultModelFromLRU } from "@/browser/hooks/useModelLRU";
import { getDefaultModel } from "@/browser/hooks/useModelLRU";
import type { StreamingMessageAggregator } from "@/browser/utils/messages/StreamingMessageAggregator";
import { isCompactingStream, cancelCompaction } from "@/browser/utils/compaction/handler";

Expand Down Expand Up @@ -116,7 +116,7 @@ export function useAIViewKeybinds({
// Fall back to message history model, then to most recent model from LRU
// This matches the same logic as useSendMessageOptions
const selectedModel = readPersistedState<string | null>(getModelKey(workspaceId), null);
const modelToUse = selectedModel ?? currentModel ?? getDefaultModelFromLRU();
const modelToUse = selectedModel ?? currentModel ?? getDefaultModel();

// Storage key for remembering this model's last-used active thinking level
const lastThinkingKey = getLastThinkingByModelKey(modelToUse);
Expand Down
22 changes: 13 additions & 9 deletions src/browser/hooks/useModelLRU.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults";

const MAX_LRU_SIZE = 12;
const LRU_KEY = "model-lru";
const DEFAULT_MODEL_KEY = "model-default";

// Ensure defaultModel is first, then fill with other abbreviations (deduplicated)
const FALLBACK_MODEL = WORKSPACE_DEFAULTS.model ?? defaultModel;
const DEFAULT_MODELS = [
FALLBACK_MODEL,
...Array.from(new Set(Object.values(MODEL_ABBREVIATIONS))).filter((m) => m !== FALLBACK_MODEL),
].slice(0, MAX_LRU_SIZE);

function persistModels(models: string[]): void {
updatePersistedState(LRU_KEY, models.slice(0, MAX_LRU_SIZE));
}
Expand All @@ -31,15 +33,9 @@ export function evictModelFromLRU(model: string): void {
persistModels(nextList);
}

/**
* Get the default model from LRU (non-hook version for use outside React)
* This is the ONLY place that reads from LRU outside of the hook.
*
* @returns The most recently used model, or WORKSPACE_DEFAULTS.model if LRU is empty
*/
export function getDefaultModelFromLRU(): string {
const lru = readPersistedState<string[]>(LRU_KEY, DEFAULT_MODELS.slice(0, MAX_LRU_SIZE));
return lru[0] ?? FALLBACK_MODEL;
export function getDefaultModel(): string {
const persisted = readPersistedState<string | null>(DEFAULT_MODEL_KEY, null);
return persisted ?? FALLBACK_MODEL;
}

/**
Expand All @@ -54,6 +50,12 @@ export function useModelLRU() {
{ listener: true }
);

const [defaultModel, setDefaultModel] = usePersistedState<string>(
DEFAULT_MODEL_KEY,
FALLBACK_MODEL,
{ listener: true }
);

// Merge any new defaults from MODEL_ABBREVIATIONS (only once on mount)
useEffect(() => {
setRecentModels((prev) => {
Expand Down Expand Up @@ -107,5 +109,7 @@ export function useModelLRU() {
evictModel,
getRecentModels,
recentModels,
defaultModel,
setDefaultModel,
};
}
4 changes: 2 additions & 2 deletions src/browser/hooks/useSendMessageOptions.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useThinkingLevel } from "./useThinkingLevel";
import { useMode } from "@/browser/contexts/ModeContext";
import { usePersistedState } from "./usePersistedState";
import { getDefaultModelFromLRU } from "./useModelLRU";
import { getDefaultModel } from "./useModelLRU";
import { modeToToolPolicy, PLAN_MODE_INSTRUCTION } from "@/common/utils/ui/modeUtils";
import { getModelKey } from "@/common/constants/storage";
import type { SendMessageOptions } from "@/common/types/ipc";
Expand Down Expand Up @@ -56,7 +56,7 @@ export function useSendMessageOptions(workspaceId: string): SendMessageOptions {
const [thinkingLevel] = useThinkingLevel();
const [mode] = useMode();
const { options: providerOptions } = useProviderOptions();
const defaultModel = getDefaultModelFromLRU();
const defaultModel = getDefaultModel();
const [preferredModel] = usePersistedState<string>(
getModelKey(workspaceId),
defaultModel, // Default to most recently used model
Expand Down
4 changes: 2 additions & 2 deletions src/browser/utils/messages/sendOptions.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { getModelKey, getThinkingLevelKey, getModeKey } from "@/common/constants/storage";
import { modeToToolPolicy, PLAN_MODE_INSTRUCTION } from "@/common/utils/ui/modeUtils";
import { readPersistedState } from "@/browser/hooks/usePersistedState";
import { getDefaultModelFromLRU } from "@/browser/hooks/useModelLRU";
import { getDefaultModel } from "@/browser/hooks/useModelLRU";
import type { SendMessageOptions } from "@/common/types/ipc";
import type { UIMode } from "@/common/types/mode";
import type { ThinkingLevel } from "@/common/types/thinking";
Expand Down Expand Up @@ -38,7 +38,7 @@ function getProviderOptions(): MuxProviderOptions {
*/
export function getSendOptionsFromStorage(workspaceId: string): SendMessageOptions {
// Read model preference (workspace-specific), fallback to LRU default
const model = readPersistedState<string>(getModelKey(workspaceId), getDefaultModelFromLRU());
const model = readPersistedState<string>(getModelKey(workspaceId), getDefaultModel());

// Read thinking level (workspace-specific)
const thinkingLevel = readPersistedState<ThinkingLevel>(
Expand Down