Skip to content

Commit abc718b

Browse files
gabrielMalonsojuliusmarmingezortos293dpav02claude
authored
feat: add global favorite model for new chats (#23)
* remove triggers * feat: replace worktree toggle with discoverable Select dropdown (pingdotgg#1001) Co-authored-by: Julius Marminge <julius0216@outlook.com> * fix(desktop): show dialog after "Check for Updates" menu action (pingdotgg#955) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * chore: Upgrade marketing app to Astro 6 (pingdotgg#1005) * feat(github): add issue templates for automatic triage (pingdotgg#896) * Actions dialog: autofocus script-name field (pingdotgg#912) * chore: update actions/checkout and actions/github-script (pingdotgg#956) Co-authored-by: Julius Marminge <julius0216@outlook.com> * Add Antigravity to supported editors in Open menu (macOS, Windows, and Linux) (pingdotgg#841) Co-authored-by: Julius Marminge <julius0216@outlook.com> * fix: block image uploads during plan mode questions (pingdotgg#621) * chore: update @vitejs/plugin-react to 6.0.0 (pingdotgg#1002) * fix(web): defer diff worker startup until diff opens (pingdotgg#934) Co-authored-by: Julius Marminge <julius0216@outlook.com> * Extract reusable clipboard hook and standardize media queries (pingdotgg#1006) Co-authored-by: macroscopeapp[bot] <170038800+macroscopeapp[bot]@users.noreply.github.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com> Co-authored-by: codex <codex@users.noreply.github.com> Co-authored-by: cursor[bot] <206951365+cursor[bot]@users.noreply.github.com> * Upgrade oxfmt and oxlint tooling versions (pingdotgg#1010) * chore: regenerate bun.lock after upstream cherry-picks Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add compact Codex tool-call icons and details to the chat timeline (pingdotgg#988) Co-authored-by: Julius Marminge <julius0216@outlook.com> * feat: Allow Overriding Timestamp Format in Settings (pingdotgg#855) * fix(web): add default thread env mode setting (pingdotgg#892) Co-authored-by: Julius Marminge <julius0216@outlook.com> * Fix mod+N new thread flow and terminal split limits - ensure `chat.new` creates a fresh draft after a promoted draft thread - enforce terminal cap per split group (4) while allowing additional terminal groups - refine sidebar row selected/active styling via shared class-name logic and tests * Fix new-thread shortcuts when terminal is focused - move chat-wide key handling into `_chat` route-level shortcut handler - extract reusable `useHandleNewThread` hook and `isTerminalFocused` helper - update browser WS fixture to support `terminalOpen` RPC shape * fix: remove duplicate ChatRouteGlobalShortcuts from cherry-pick merge Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: composer @file autocomplete cursor and spacing bugs (pingdotgg#936) * fix(web): resolve preferred editor from available editors & introduce `useLocalStorage` helper (pingdotgg#662) Co-authored-by: Julius Marminge <julius0216@outlook.com> * refactor: mover isToolLifecycleItemType para shared/providerRuntime e remover duplicação * Add global favorite model toggle in provider picker - persist a single favorite provider/model in app settings - use favorite model as default for new local draft chats - add star toggle UI in model picker with toast feedback - cover favorite read/toggle behavior with app settings tests * fix: validate provider in FavoriteModelSchema to prevent crash Use Schema.Literals instead of Schema.String for the provider field in FavoriteModelSchema, ensuring invalid provider values from localStorage are rejected at the schema level. Add runtime guard in getFavoriteModel as defense-in-depth. Remove unsafe `as ProviderKind` casts in appSettings.ts and ChatView.tsx. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: Zortos <zortosdev@proton.me> Co-authored-by: dpav02 <dpav02@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Jono Kemball <Noojuno@users.noreply.github.com> Co-authored-by: 0x1f99d <crazywolf132@gmail.com> Co-authored-by: Vitor Buzinaro <funny.hc@gmail.com> Co-authored-by: Adam Naji <110662505+Bashamega@users.noreply.github.com> Co-authored-by: WilgotM <wilgot10@yahoo.com> Co-authored-by: (CJ) Chukwudi Nwobodo <142016413+chuks-qua@users.noreply.github.com> Co-authored-by: macroscopeapp[bot] <170038800+macroscopeapp[bot]@users.noreply.github.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com> Co-authored-by: codex <codex@users.noreply.github.com> Co-authored-by: cursor[bot] <206951365+cursor[bot]@users.noreply.github.com> Co-authored-by: Hugo Blom <6117705+huxcrux@users.noreply.github.com> Co-authored-by: Magnus Buvarp <magnus.buvarp@gmail.com>
1 parent 31ffd77 commit abc718b

4 files changed

Lines changed: 234 additions & 21 deletions

File tree

‎apps/web/src/appSettings.test.ts‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@ import { describe, expect, it } from "vitest";
33
import {
44
DEFAULT_TIMESTAMP_FORMAT,
55
getAppModelOptions,
6+
getFavoriteModel,
67
normalizeCustomModelSlugs,
78
resolveAppModelSelection,
9+
toggleFavoriteModel,
10+
type AppSettings,
811
} from "./appSettings";
912

1013
describe("normalizeCustomModelSlugs", () => {
@@ -64,3 +67,102 @@ describe("timestamp format defaults", () => {
6467
expect(DEFAULT_TIMESTAMP_FORMAT).toBe("locale");
6568
});
6669
});
70+
71+
function makeSettings(overrides?: Partial<AppSettings>): AppSettings {
72+
return {
73+
codexBinaryPath: "",
74+
codexHomePath: "",
75+
defaultThreadEnvMode: "local",
76+
confirmThreadDelete: true,
77+
enableAssistantStreaming: false,
78+
timestampFormat: "locale",
79+
customCodexModels: [],
80+
customClaudeModels: [],
81+
customCursorModels: [],
82+
favoriteModel: undefined,
83+
...overrides,
84+
};
85+
}
86+
87+
describe("getFavoriteModel", () => {
88+
it("returns null when no favorite is set", () => {
89+
expect(getFavoriteModel(makeSettings())).toBeNull();
90+
});
91+
92+
it("returns the favorite with provider and model when set", () => {
93+
const settings = makeSettings({
94+
favoriteModel: { provider: "codex", model: "gpt-5.4" },
95+
});
96+
const fav = getFavoriteModel(settings);
97+
expect(fav).toEqual({ provider: "codex", model: "gpt-5.4" });
98+
});
99+
100+
it("normalizes aliases to canonical slugs", () => {
101+
const settings = makeSettings({
102+
favoriteModel: { provider: "claudeCode", model: "opus" },
103+
});
104+
const fav = getFavoriteModel(settings);
105+
expect(fav).toEqual({ provider: "claudeCode", model: "claude-opus-4-6" });
106+
});
107+
108+
it("returns null for invalid provider values", () => {
109+
const settings = makeSettings({
110+
favoriteModel: { provider: "invalid" as any, model: "gpt-5.4" },
111+
});
112+
expect(getFavoriteModel(settings)).toBeNull();
113+
});
114+
115+
it("returns null for empty model values", () => {
116+
const settings = makeSettings({
117+
favoriteModel: { provider: "codex", model: "" },
118+
});
119+
expect(getFavoriteModel(settings)).toBeNull();
120+
});
121+
122+
it("returns a single global favorite regardless of provider", () => {
123+
const settings = makeSettings({
124+
favoriteModel: { provider: "claudeCode", model: "claude-opus-4-6" },
125+
});
126+
const fav = getFavoriteModel(settings);
127+
expect(fav?.provider).toBe("claudeCode");
128+
expect(fav?.model).toBe("claude-opus-4-6");
129+
});
130+
});
131+
132+
describe("toggleFavoriteModel", () => {
133+
it("sets a favorite model when none is set", () => {
134+
const settings = makeSettings();
135+
const patch = toggleFavoriteModel(settings, "codex", "gpt-5.4");
136+
expect(patch.favoriteModel).toEqual({ provider: "codex", model: "gpt-5.4" });
137+
});
138+
139+
it("removes the favorite when toggling the same model", () => {
140+
const settings = makeSettings({
141+
favoriteModel: { provider: "codex", model: "gpt-5.4" },
142+
});
143+
const patch = toggleFavoriteModel(settings, "codex", "gpt-5.4");
144+
expect(patch.favoriteModel).toBeUndefined();
145+
});
146+
147+
it("switches the favorite to a different model in the same provider", () => {
148+
const settings = makeSettings({
149+
favoriteModel: { provider: "codex", model: "gpt-5.4" },
150+
});
151+
const patch = toggleFavoriteModel(settings, "codex", "gpt-5.3-codex");
152+
expect(patch.favoriteModel).toEqual({ provider: "codex", model: "gpt-5.3-codex" });
153+
});
154+
155+
it("switches the favorite to a different provider entirely", () => {
156+
const settings = makeSettings({
157+
favoriteModel: { provider: "codex", model: "gpt-5.4" },
158+
});
159+
const patch = toggleFavoriteModel(settings, "claudeCode", "claude-opus-4-6");
160+
expect(patch.favoriteModel).toEqual({ provider: "claudeCode", model: "claude-opus-4-6" });
161+
});
162+
163+
it("normalizes aliases when toggling", () => {
164+
const settings = makeSettings();
165+
const patch = toggleFavoriteModel(settings, "claudeCode", "opus");
166+
expect(patch.favoriteModel).toEqual({ provider: "claudeCode", model: "claude-opus-4-6" });
167+
});
168+
});

‎apps/web/src/appSettings.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>
1616
cursor: new Set(getModelOptions("cursor").map((option) => option.slug)),
1717
};
1818

19+
const FavoriteModelSchema = Schema.Struct({
20+
provider: Schema.Literals(["codex", "claudeCode", "cursor"]),
21+
model: Schema.String,
22+
});
23+
export type FavoriteModel = typeof FavoriteModelSchema.Type;
24+
1925
const AppSettingsSchema = Schema.Struct({
2026
codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(
2127
Schema.withConstructorDefault(() => Option.some("")),
@@ -42,6 +48,7 @@ const AppSettingsSchema = Schema.Struct({
4248
customCursorModels: Schema.Array(Schema.String).pipe(
4349
Schema.withConstructorDefault(() => Option.some([])),
4450
),
51+
favoriteModel: Schema.optional(FavoriteModelSchema),
4552
});
4653
export type AppSettings = typeof AppSettingsSchema.Type;
4754
export interface AppModelOption {
@@ -159,6 +166,39 @@ export function resolveAppModelSelection(
159166
);
160167
}
161168

169+
/**
170+
* Returns the global favorite model, or `null` if none is set.
171+
*/
172+
export function getFavoriteModel(
173+
settings: AppSettings,
174+
): FavoriteModel | null {
175+
const fav = settings.favoriteModel;
176+
if (!fav || !fav.provider || !fav.model) return null;
177+
const provider = fav.provider;
178+
if (!(provider in BUILT_IN_MODEL_SLUGS_BY_PROVIDER)) return null;
179+
const normalized = normalizeModelSlug(fav.model, provider);
180+
if (!normalized) return null;
181+
return { provider, model: normalized };
182+
}
183+
184+
/**
185+
* Returns a partial `AppSettings` patch that toggles the global favorite model.
186+
* If the model is already the favorite, it removes it. Otherwise, it sets it as the new favorite.
187+
*/
188+
export function toggleFavoriteModel(
189+
settings: AppSettings,
190+
provider: ProviderKind,
191+
modelSlug: string,
192+
): Partial<AppSettings> {
193+
const current = getFavoriteModel(settings);
194+
const normalized = normalizeModelSlug(modelSlug, provider);
195+
if (!normalized) return {};
196+
const isSameFavorite = current?.provider === provider && current?.model === normalized;
197+
return {
198+
favoriteModel: isSameFavorite ? undefined : { provider, model: normalized },
199+
};
200+
}
201+
162202
export function useAppSettings() {
163203
const [settings, setSettings] = useLocalStorage(
164204
APP_SETTINGS_STORAGE_KEY,

‎apps/web/src/components/ChatView.tsx‎

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,12 @@ import {
122122
import { SidebarTrigger } from "./ui/sidebar";
123123
import { newCommandId, newMessageId, newThreadId } from "~/lib/utils";
124124
import { readNativeApi } from "~/nativeApi";
125-
import { resolveAppModelSelection, useAppSettings } from "../appSettings";
125+
import {
126+
getFavoriteModel,
127+
resolveAppModelSelection,
128+
toggleFavoriteModel,
129+
useAppSettings,
130+
} from "../appSettings";
126131
import { isTerminalFocused } from "../lib/terminalFocus";
127132
import {
128133
type ComposerImageAttachment,
@@ -203,7 +208,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
203208
const syncServerReadModel = useStore((store) => store.syncServerReadModel);
204209
const setStoreThreadError = useStore((store) => store.setError);
205210
const setStoreThreadBranch = useStore((store) => store.setThreadBranch);
206-
const { settings } = useAppSettings();
211+
const { settings, updateSettings } = useAppSettings();
207212
const timestampFormat = settings.timestampFormat;
208213
const navigate = useNavigate();
209214
const rawSearch = useSearch({
@@ -367,17 +372,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
367372
const serverThread = threads.find((t) => t.id === threadId);
368373
const fallbackDraftProject = projects.find((project) => project.id === draftThread?.projectId);
369374
const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null);
375+
const draftFallbackModel =
376+
fallbackDraftProject?.model ?? DEFAULT_MODEL_BY_PROVIDER.codex;
370377
const localDraftThread = useMemo(
371378
() =>
372379
draftThread
373-
? buildLocalDraftThread(
374-
threadId,
375-
draftThread,
376-
fallbackDraftProject?.model ?? DEFAULT_MODEL_BY_PROVIDER.codex,
377-
localDraftError,
378-
)
380+
? buildLocalDraftThread(threadId, draftThread, draftFallbackModel, localDraftError)
379381
: undefined,
380-
[draftThread, fallbackDraftProject?.model, localDraftError, threadId],
382+
[draftThread, draftFallbackModel, localDraftError, threadId],
381383
);
382384
const activeThread = serverThread ?? localDraftThread;
383385
const runtimeMode =
@@ -503,11 +505,17 @@ export default function ChatView({ threadId }: ChatViewProps) {
503505
? (sessionProvider ?? selectedProviderByThreadId ?? null)
504506
: null;
505507
const inferredProviderFromDraftModel = inferProviderFromModel(composerDraft.model);
508+
const globalFavorite = getFavoriteModel(settings);
509+
const favoriteProvider = isLocalDraftThread && globalFavorite
510+
? globalFavorite.provider
511+
: null;
506512
const selectedProvider: ProviderKind =
507-
lockedProvider ?? selectedProviderByThreadId ?? inferredProviderFromDraftModel ?? "codex";
513+
lockedProvider ?? selectedProviderByThreadId ?? inferredProviderFromDraftModel ?? favoriteProvider ?? "codex";
508514
const baseThreadModel = resolveModelSlugForProvider(
509515
selectedProvider,
510-
activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider),
516+
isLocalDraftThread && globalFavorite && globalFavorite.provider === selectedProvider
517+
? globalFavorite.model
518+
: (activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider)),
511519
);
512520
const customModelsForSelectedProvider = settings.customCodexModels;
513521
const selectedModel = useMemo(() => {
@@ -3052,6 +3060,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
30523060
selectedModel ||
30533061
(activeThread.model as ModelSlug) ||
30543062
(activeProject.model as ModelSlug) ||
3063+
(globalFavorite?.model as ModelSlug) ||
30553064
DEFAULT_MODEL_BY_PROVIDER.codex;
30563065

30573066
sendInFlightRef.current = true;
@@ -3081,6 +3090,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
30813090
activeProposedPlan,
30823091
activeThread,
30833092
beginSendPhase,
3093+
globalFavorite,
30843094
isConnecting,
30853095
isSendBusy,
30863096
isServerThread,
@@ -3112,6 +3122,24 @@ export default function ChatView({ threadId }: ChatViewProps) {
31123122
settings.customCodexModels,
31133123
],
31143124
);
3125+
const onToggleFavorite = useCallback(
3126+
(provider: ProviderKind, model: ModelSlug) => {
3127+
const patch = toggleFavoriteModel(settings, provider, model);
3128+
updateSettings(patch);
3129+
const isFavoriting = patch.favoriteModel !== undefined;
3130+
const modelOptions = modelOptionsByProvider[provider];
3131+
const modelName = modelOptions.find((opt) => opt.slug === model)?.name ?? model;
3132+
toastManager.add({
3133+
type: isFavoriting ? "success" : "info",
3134+
title: isFavoriting ? "Set as default model" : "Default model removed",
3135+
description: isFavoriting
3136+
? `New chats will use ${modelName}`
3137+
: "New chats will use the system default",
3138+
data: { dismissAfterVisibleMs: 2000 },
3139+
});
3140+
},
3141+
[settings, updateSettings, modelOptionsByProvider],
3142+
);
31153143
const onEffortSelect = useCallback(
31163144
(effort: CodexReasoningEffort) => {
31173145
setComposerDraftEffort(threadId, effort);
@@ -3783,7 +3811,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
37833811
model={selectedModelForPickerWithCustomFallback}
37843812
lockedProvider={lockedProvider}
37853813
modelOptionsByProvider={modelOptionsByProvider}
3814+
favoriteModel={globalFavorite}
37863815
onProviderModelChange={onProviderModelSelect}
3816+
onToggleFavorite={onToggleFavorite}
37873817
/>
37883818

37893819
{isComposerFooterCompact ? (

‎apps/web/src/components/chat/ProviderModelPicker.tsx‎

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { type ModelSlug, type ProviderKind } from "@t3tools/contracts";
22
import { normalizeModelSlug } from "@t3tools/shared/model";
33
import { memo, useState } from "react";
44
import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../../session-logic";
5-
import { ChevronDownIcon } from "lucide-react";
5+
import { ChevronDownIcon, StarIcon } from "lucide-react";
6+
import type { FavoriteModel } from "../../appSettings";
67
import { Button } from "../ui/button";
78
import {
89
Menu,
@@ -81,7 +82,9 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
8182
modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>>;
8283
compact?: boolean;
8384
disabled?: boolean;
85+
favoriteModel?: FavoriteModel | null;
8486
onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void;
87+
onToggleFavorite?: (provider: ProviderKind, model: ModelSlug) => void;
8588
}) {
8689
const [isMenuOpen, setIsMenuOpen] = useState(false);
8790
const selectedProviderOptions = props.modelOptionsByProvider[props.provider];
@@ -153,15 +156,53 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
153156
setIsMenuOpen(false);
154157
}}
155158
>
156-
{props.modelOptionsByProvider[option.value].map((modelOption) => (
157-
<MenuRadioItem
158-
key={`${option.value}:${modelOption.slug}`}
159-
value={modelOption.slug}
160-
onClick={() => setIsMenuOpen(false)}
161-
>
162-
{modelOption.name}
163-
</MenuRadioItem>
164-
))}
159+
{props.modelOptionsByProvider[option.value].map((modelOption) => {
160+
const isFavorite =
161+
props.favoriteModel?.provider === option.value &&
162+
props.favoriteModel?.model === modelOption.slug;
163+
return (
164+
<MenuRadioItem
165+
key={`${option.value}:${modelOption.slug}`}
166+
value={modelOption.slug}
167+
onClick={() => setIsMenuOpen(false)}
168+
>
169+
<span className="inline-flex w-full items-center gap-2">
170+
<span className="flex-1 truncate">{modelOption.name}</span>
171+
{props.onToggleFavorite && (
172+
<button
173+
type="button"
174+
aria-label={
175+
isFavorite
176+
? `Remove ${modelOption.name} as default`
177+
: `Set ${modelOption.name} as default`
178+
}
179+
className={cn(
180+
"shrink-0 rounded-sm p-0.5 transition-colors pointer-events-auto",
181+
isFavorite
182+
? "text-amber-400"
183+
: "text-muted-foreground/30 hover:text-amber-400/70",
184+
)}
185+
onPointerDown={(e) => {
186+
e.stopPropagation();
187+
e.preventDefault();
188+
}}
189+
onClick={(e) => {
190+
e.stopPropagation();
191+
e.preventDefault();
192+
props.onToggleFavorite?.(option.value, modelOption.slug);
193+
}}
194+
>
195+
<StarIcon
196+
aria-hidden="true"
197+
className="size-3.5 shrink-0 pointer-events-none"
198+
{...(isFavorite ? { fill: "currentColor" } : {})}
199+
/>
200+
</button>
201+
)}
202+
</span>
203+
</MenuRadioItem>
204+
);
205+
})}
165206
</MenuRadioGroup>
166207
</MenuGroup>
167208
</MenuSubPopup>

0 commit comments

Comments
 (0)