Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
e70f44b
remove triggers
juliusmarminge Mar 12, 2026
9304266
feat: replace worktree toggle with discoverable Select dropdown (#1001)
zortos293 Mar 13, 2026
5b6b7a8
fix(desktop): show dialog after "Check for Updates" menu action (#955)
dpav02 Mar 13, 2026
3158d23
chore: Upgrade marketing app to Astro 6 (#1005)
Noojuno Mar 13, 2026
313b4bf
feat(github): add issue templates for automatic triage (#896)
binbandit Mar 13, 2026
cdb74ad
Actions dialog: autofocus script-name field (#912)
buzinas Mar 14, 2026
06c672d
chore: update actions/checkout and actions/github-script (#956)
Bashamega Mar 12, 2026
b7f1e02
Add Antigravity to supported editors in Open menu (macOS, Windows, an…
WilgotM Mar 12, 2026
bf1b72e
fix: block image uploads during plan mode questions (#621)
chuks-qua Mar 13, 2026
3b7ff0d
chore: update @vitejs/plugin-react to 6.0.0 (#1002)
Noojuno Mar 13, 2026
4550a84
fix(web): defer diff worker startup until diff opens (#934)
binbandit Mar 13, 2026
966b355
Extract reusable clipboard hook and standardize media queries (#1006)
juliusmarminge Mar 13, 2026
5beaca0
Upgrade oxfmt and oxlint tooling versions (#1010)
juliusmarminge Mar 13, 2026
700f93f
chore: regenerate bun.lock after upstream cherry-picks
gabrielMalonso Mar 14, 2026
eb87006
Add compact Codex tool-call icons and details to the chat timeline (#…
zortos293 Mar 13, 2026
60c9541
feat: Allow Overriding Timestamp Format in Settings (#855)
huxcrux Mar 12, 2026
f7e81fa
fix(web): add default thread env mode setting (#892)
binbandit Mar 13, 2026
ecc2715
Fix mod+N new thread flow and terminal split limits
juliusmarminge Mar 12, 2026
c118c8f
Fix new-thread shortcuts when terminal is focused
juliusmarminge Mar 12, 2026
78596c5
fix: remove duplicate ChatRouteGlobalShortcuts from cherry-pick merge
gabrielMalonso Mar 14, 2026
327b70d
fix: composer @file autocomplete cursor and spacing bugs (#936)
chuks-qua Mar 12, 2026
fcce77f
fix(web): resolve preferred editor from available editors & introduce…
mbuvarp Mar 12, 2026
3cc4949
refactor: mover isToolLifecycleItemType para shared/providerRuntime e…
gabrielMalonso Mar 14, 2026
781e56b
Merge remote-tracking branch 'origin/main' into upstream-sync
gabrielMalonso Mar 14, 2026
a8e54a1
Add global favorite model toggle in provider picker
gabrielMalonso Mar 14, 2026
602ea97
fix: validate provider in FavoriteModelSchema to prevent crash
gabrielMalonso Mar 14, 2026
9b3442f
merge: resolve conflicts with origin/main
gabrielMalonso Mar 14, 2026
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
102 changes: 102 additions & 0 deletions apps/web/src/appSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ import { describe, expect, it } from "vitest";
import {
DEFAULT_TIMESTAMP_FORMAT,
getAppModelOptions,
getFavoriteModel,
normalizeCustomModelSlugs,
resolveAppModelSelection,
toggleFavoriteModel,
type AppSettings,
} from "./appSettings";

describe("normalizeCustomModelSlugs", () => {
Expand Down Expand Up @@ -64,3 +67,102 @@ describe("timestamp format defaults", () => {
expect(DEFAULT_TIMESTAMP_FORMAT).toBe("locale");
});
});

function makeSettings(overrides?: Partial<AppSettings>): AppSettings {
return {
codexBinaryPath: "",
codexHomePath: "",
defaultThreadEnvMode: "local",
confirmThreadDelete: true,
enableAssistantStreaming: false,
timestampFormat: "locale",
customCodexModels: [],
customClaudeModels: [],
customCursorModels: [],
favoriteModel: undefined,
...overrides,
};
}

describe("getFavoriteModel", () => {
it("returns null when no favorite is set", () => {
expect(getFavoriteModel(makeSettings())).toBeNull();
});

it("returns the favorite with provider and model when set", () => {
const settings = makeSettings({
favoriteModel: { provider: "codex", model: "gpt-5.4" },
});
const fav = getFavoriteModel(settings);
expect(fav).toEqual({ provider: "codex", model: "gpt-5.4" });
});

it("normalizes aliases to canonical slugs", () => {
const settings = makeSettings({
favoriteModel: { provider: "claudeCode", model: "opus" },
});
const fav = getFavoriteModel(settings);
expect(fav).toEqual({ provider: "claudeCode", model: "claude-opus-4-6" });
});

it("returns null for invalid provider values", () => {
const settings = makeSettings({
favoriteModel: { provider: "invalid" as any, model: "gpt-5.4" },
});
expect(getFavoriteModel(settings)).toBeNull();
});

it("returns null for empty model values", () => {
const settings = makeSettings({
favoriteModel: { provider: "codex", model: "" },
});
expect(getFavoriteModel(settings)).toBeNull();
});

it("returns a single global favorite regardless of provider", () => {
const settings = makeSettings({
favoriteModel: { provider: "claudeCode", model: "claude-opus-4-6" },
});
const fav = getFavoriteModel(settings);
expect(fav?.provider).toBe("claudeCode");
expect(fav?.model).toBe("claude-opus-4-6");
});
});

describe("toggleFavoriteModel", () => {
it("sets a favorite model when none is set", () => {
const settings = makeSettings();
const patch = toggleFavoriteModel(settings, "codex", "gpt-5.4");
expect(patch.favoriteModel).toEqual({ provider: "codex", model: "gpt-5.4" });
});

it("removes the favorite when toggling the same model", () => {
const settings = makeSettings({
favoriteModel: { provider: "codex", model: "gpt-5.4" },
});
const patch = toggleFavoriteModel(settings, "codex", "gpt-5.4");
expect(patch.favoriteModel).toBeUndefined();
});

it("switches the favorite to a different model in the same provider", () => {
const settings = makeSettings({
favoriteModel: { provider: "codex", model: "gpt-5.4" },
});
const patch = toggleFavoriteModel(settings, "codex", "gpt-5.3-codex");
expect(patch.favoriteModel).toEqual({ provider: "codex", model: "gpt-5.3-codex" });
});

it("switches the favorite to a different provider entirely", () => {
const settings = makeSettings({
favoriteModel: { provider: "codex", model: "gpt-5.4" },
});
const patch = toggleFavoriteModel(settings, "claudeCode", "claude-opus-4-6");
expect(patch.favoriteModel).toEqual({ provider: "claudeCode", model: "claude-opus-4-6" });
});

it("normalizes aliases when toggling", () => {
const settings = makeSettings();
const patch = toggleFavoriteModel(settings, "claudeCode", "opus");
expect(patch.favoriteModel).toEqual({ provider: "claudeCode", model: "claude-opus-4-6" });
});
});
40 changes: 40 additions & 0 deletions apps/web/src/appSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>
cursor: new Set(getModelOptions("cursor").map((option) => option.slug)),
};

const FavoriteModelSchema = Schema.Struct({
provider: Schema.Literals(["codex", "claudeCode", "cursor"]),
model: Schema.String,
});
export type FavoriteModel = typeof FavoriteModelSchema.Type;

const AppSettingsSchema = Schema.Struct({
codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(
Schema.withConstructorDefault(() => Option.some("")),
Expand All @@ -42,6 +48,7 @@ const AppSettingsSchema = Schema.Struct({
customCursorModels: Schema.Array(Schema.String).pipe(
Schema.withConstructorDefault(() => Option.some([])),
),
favoriteModel: Schema.optional(FavoriteModelSchema),
});
export type AppSettings = typeof AppSettingsSchema.Type;
export interface AppModelOption {
Expand Down Expand Up @@ -159,6 +166,39 @@ export function resolveAppModelSelection(
);
}

/**
* Returns the global favorite model, or `null` if none is set.
*/
export function getFavoriteModel(
settings: AppSettings,
): FavoriteModel | null {
const fav = settings.favoriteModel;
if (!fav || !fav.provider || !fav.model) return null;
const provider = fav.provider;
if (!(provider in BUILT_IN_MODEL_SLUGS_BY_PROVIDER)) return null;
const normalized = normalizeModelSlug(fav.model, provider);
if (!normalized) return null;
return { provider, model: normalized };
}

/**
* Returns a partial `AppSettings` patch that toggles the global favorite model.
* If the model is already the favorite, it removes it. Otherwise, it sets it as the new favorite.
*/
export function toggleFavoriteModel(
settings: AppSettings,
provider: ProviderKind,
modelSlug: string,
): Partial<AppSettings> {
const current = getFavoriteModel(settings);
const normalized = normalizeModelSlug(modelSlug, provider);
if (!normalized) return {};
const isSameFavorite = current?.provider === provider && current?.model === normalized;
return {
favoriteModel: isSameFavorite ? undefined : { provider, model: normalized },
};
}

export function useAppSettings() {
const [settings, setSettings] = useLocalStorage(
APP_SETTINGS_STORAGE_KEY,
Expand Down
52 changes: 41 additions & 11 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,12 @@ import {
import { SidebarTrigger } from "./ui/sidebar";
import { newCommandId, newMessageId, newThreadId } from "~/lib/utils";
import { readNativeApi } from "~/nativeApi";
import { resolveAppModelSelection, useAppSettings } from "../appSettings";
import {
getFavoriteModel,
resolveAppModelSelection,
toggleFavoriteModel,
useAppSettings,
} from "../appSettings";
import { isTerminalFocused } from "../lib/terminalFocus";
import {
type ComposerImageAttachment,
Expand Down Expand Up @@ -203,7 +208,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
const syncServerReadModel = useStore((store) => store.syncServerReadModel);
const setStoreThreadError = useStore((store) => store.setError);
const setStoreThreadBranch = useStore((store) => store.setThreadBranch);
const { settings } = useAppSettings();
const { settings, updateSettings } = useAppSettings();
const timestampFormat = settings.timestampFormat;
const navigate = useNavigate();
const rawSearch = useSearch({
Expand Down Expand Up @@ -367,17 +372,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
const serverThread = threads.find((t) => t.id === threadId);
const fallbackDraftProject = projects.find((project) => project.id === draftThread?.projectId);
const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null);
const draftFallbackModel =
fallbackDraftProject?.model ?? DEFAULT_MODEL_BY_PROVIDER.codex;
const localDraftThread = useMemo(
() =>
draftThread
? buildLocalDraftThread(
threadId,
draftThread,
fallbackDraftProject?.model ?? DEFAULT_MODEL_BY_PROVIDER.codex,
localDraftError,
)
? buildLocalDraftThread(threadId, draftThread, draftFallbackModel, localDraftError)
: undefined,
[draftThread, fallbackDraftProject?.model, localDraftError, threadId],
[draftThread, draftFallbackModel, localDraftError, threadId],
);
const activeThread = serverThread ?? localDraftThread;
const runtimeMode =
Expand Down Expand Up @@ -503,11 +505,17 @@ export default function ChatView({ threadId }: ChatViewProps) {
? (sessionProvider ?? selectedProviderByThreadId ?? null)
: null;
const inferredProviderFromDraftModel = inferProviderFromModel(composerDraft.model);
const globalFavorite = getFavoriteModel(settings);
const favoriteProvider = isLocalDraftThread && globalFavorite
? globalFavorite.provider
: null;
const selectedProvider: ProviderKind =
lockedProvider ?? selectedProviderByThreadId ?? inferredProviderFromDraftModel ?? "codex";
lockedProvider ?? selectedProviderByThreadId ?? inferredProviderFromDraftModel ?? favoriteProvider ?? "codex";
const baseThreadModel = resolveModelSlugForProvider(
selectedProvider,
activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider),
isLocalDraftThread && globalFavorite && globalFavorite.provider === selectedProvider
? globalFavorite.model
: (activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider)),
);
const customModelsForSelectedProvider = settings.customCodexModels;
const selectedModel = useMemo(() => {
Expand Down Expand Up @@ -3052,6 +3060,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
selectedModel ||
(activeThread.model as ModelSlug) ||
(activeProject.model as ModelSlug) ||
(globalFavorite?.model as ModelSlug) ||
DEFAULT_MODEL_BY_PROVIDER.codex;

sendInFlightRef.current = true;
Expand Down Expand Up @@ -3081,6 +3090,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
activeProposedPlan,
activeThread,
beginSendPhase,
globalFavorite,
isConnecting,
isSendBusy,
isServerThread,
Expand Down Expand Up @@ -3112,6 +3122,24 @@ export default function ChatView({ threadId }: ChatViewProps) {
settings.customCodexModels,
],
);
const onToggleFavorite = useCallback(
(provider: ProviderKind, model: ModelSlug) => {
const patch = toggleFavoriteModel(settings, provider, model);
updateSettings(patch);
const isFavoriting = patch.favoriteModel !== undefined;
const modelOptions = modelOptionsByProvider[provider];
const modelName = modelOptions.find((opt) => opt.slug === model)?.name ?? model;
toastManager.add({
type: isFavoriting ? "success" : "info",
title: isFavoriting ? "Set as default model" : "Default model removed",
description: isFavoriting
? `New chats will use ${modelName}`
: "New chats will use the system default",
data: { dismissAfterVisibleMs: 2000 },
});
},
[settings, updateSettings, modelOptionsByProvider],
);
const onEffortSelect = useCallback(
(effort: CodexReasoningEffort) => {
setComposerDraftEffort(threadId, effort);
Expand Down Expand Up @@ -3783,7 +3811,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
model={selectedModelForPickerWithCustomFallback}
lockedProvider={lockedProvider}
modelOptionsByProvider={modelOptionsByProvider}
favoriteModel={globalFavorite}
onProviderModelChange={onProviderModelSelect}
onToggleFavorite={onToggleFavorite}
/>

{isComposerFooterCompact ? (
Expand Down
61 changes: 51 additions & 10 deletions apps/web/src/components/chat/ProviderModelPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { type ModelSlug, type ProviderKind } from "@t3tools/contracts";
import { normalizeModelSlug } from "@t3tools/shared/model";
import { memo, useState } from "react";
import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../../session-logic";
import { ChevronDownIcon } from "lucide-react";
import { ChevronDownIcon, StarIcon } from "lucide-react";
import type { FavoriteModel } from "../../appSettings";
import { Button } from "../ui/button";
import {
Menu,
Expand Down Expand Up @@ -81,7 +82,9 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>>;
compact?: boolean;
disabled?: boolean;
favoriteModel?: FavoriteModel | null;
onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void;
onToggleFavorite?: (provider: ProviderKind, model: ModelSlug) => void;
}) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const selectedProviderOptions = props.modelOptionsByProvider[props.provider];
Expand Down Expand Up @@ -153,15 +156,53 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
setIsMenuOpen(false);
}}
>
{props.modelOptionsByProvider[option.value].map((modelOption) => (
<MenuRadioItem
key={`${option.value}:${modelOption.slug}`}
value={modelOption.slug}
onClick={() => setIsMenuOpen(false)}
>
{modelOption.name}
</MenuRadioItem>
))}
{props.modelOptionsByProvider[option.value].map((modelOption) => {
const isFavorite =
props.favoriteModel?.provider === option.value &&
props.favoriteModel?.model === modelOption.slug;
return (
<MenuRadioItem
key={`${option.value}:${modelOption.slug}`}
value={modelOption.slug}
onClick={() => setIsMenuOpen(false)}
>
<span className="inline-flex w-full items-center gap-2">
<span className="flex-1 truncate">{modelOption.name}</span>
{props.onToggleFavorite && (
<button
type="button"
aria-label={
isFavorite
? `Remove ${modelOption.name} as default`
: `Set ${modelOption.name} as default`
}
className={cn(
"shrink-0 rounded-sm p-0.5 transition-colors pointer-events-auto",
isFavorite
? "text-amber-400"
: "text-muted-foreground/30 hover:text-amber-400/70",
)}
onPointerDown={(e) => {
e.stopPropagation();
e.preventDefault();
}}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
props.onToggleFavorite?.(option.value, modelOption.slug);
}}
>
<StarIcon
aria-hidden="true"
className="size-3.5 shrink-0 pointer-events-none"
{...(isFavorite ? { fill: "currentColor" } : {})}
/>
</button>
)}
</span>
</MenuRadioItem>
);
})}
</MenuRadioGroup>
</MenuGroup>
</MenuSubPopup>
Expand Down
Loading