Skip to content
Draft
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
1 change: 1 addition & 0 deletions apps/desktop/src/clientPersistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const clientSettings: ClientSettings = {
confirmThreadDelete: false,
diffWordWrap: true,
favorites: [],
hiddenModels: [],
sidebarProjectGroupingMode: "repository_path",
sidebarProjectGroupingOverrides: {
"environment-1:/tmp/project-a": "separate",
Expand Down
112 changes: 87 additions & 25 deletions apps/web/src/components/chat/ModelListRow.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { type ProviderKind } from "@t3tools/contracts";
import { memo } from "react";
import { StarIcon } from "lucide-react";
import { ArrowDownIcon, ArrowUpIcon, StarIcon } from "lucide-react";
import {
getDisplayModelName,
getProviderLabel,
Expand All @@ -23,9 +23,16 @@ export const ModelListRow = memo(function ModelListRow(props: {
useTriggerLabel?: boolean;
showNewBadge?: boolean;
jumpLabel?: string | null;
reorderControls?: {
canMoveUp: boolean;
canMoveDown: boolean;
onMoveUp: () => void;
onMoveDown: () => void;
} | null;
onToggleFavorite: () => void;
}) {
const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.provider];
const reorderControls = props.isFavorite ? props.reorderControls : null;

return (
<ComboboxItem
Expand All @@ -38,31 +45,86 @@ export const ModelListRow = memo(function ModelListRow(props: {
"data-highlighted:bg-muted data-selected:bg-accent data-selected:text-foreground",
)}
>
<Tooltip>
<TooltipTrigger
render={
<button
className="mt-0.5 shrink-0 cursor-pointer opacity-40 transition-opacity group-hover:opacity-100"
onClick={(event) => {
event.stopPropagation();
props.onToggleFavorite();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
type="button"
aria-label={props.isFavorite ? "Remove from favorites" : "Add to favorites"}
>
<StarIcon
className={cn("size-4", props.isFavorite && "fill-current text-yellow-500")}
<div className="mt-0.5 flex shrink-0 items-center gap-0.5">
<Tooltip>
<TooltipTrigger
render={
<button
className="shrink-0 cursor-pointer opacity-40 transition-opacity group-hover:opacity-100"
onClick={(event) => {
event.stopPropagation();
props.onToggleFavorite();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
type="button"
aria-label={props.isFavorite ? "Remove from favorites" : "Add to favorites"}
>
<StarIcon
className={cn("size-4", props.isFavorite && "fill-current text-yellow-500")}
/>
</button>
}
/>
<TooltipPopup side="top" align="center">
{props.isFavorite ? "Remove from favorites" : "Add to favorites"}
</TooltipPopup>
</Tooltip>

{reorderControls ? (
<div className="flex flex-col">
<Tooltip>
<TooltipTrigger
render={
<button
className="rounded-sm text-muted-foreground/50 transition-colors hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
disabled={!reorderControls.canMoveUp}
onClick={(event) => {
event.stopPropagation();
reorderControls.onMoveUp();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
type="button"
aria-label={`Move ${props.model.name} up in favorites`}
>
<ArrowUpIcon className="size-3" />
</button>
}
/>
<TooltipPopup side="top" align="center">
Move favorite up
</TooltipPopup>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<button
className="rounded-sm text-muted-foreground/50 transition-colors hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
disabled={!reorderControls.canMoveDown}
onClick={(event) => {
event.stopPropagation();
reorderControls.onMoveDown();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
type="button"
aria-label={`Move ${props.model.name} down in favorites`}
>
<ArrowDownIcon className="size-3" />
</button>
}
/>
</button>
}
/>
<TooltipPopup side="top" align="center">
{props.isFavorite ? "Remove from favorites" : "Add to favorites"}
</TooltipPopup>
</Tooltip>
<TooltipPopup side="top" align="center">
Move favorite down
</TooltipPopup>
</Tooltip>
</div>
) : null}
</div>

<div className="min-w-0 flex-1 text-left">
<div className="flex items-center justify-between gap-2 min-w-0">
Expand Down
64 changes: 56 additions & 8 deletions apps/web/src/components/chat/ModelPickerContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
const listRegionRef = useRef<HTMLDivElement>(null);
const highlightedModelKeyRef = useRef<string | null>(null);
const favorites = useSettings((s) => s.favorites ?? []);
const hiddenModels = useSettings((s) => s.hiddenModels ?? []);
const [selectedProvider, setSelectedProvider] = useState<ProviderKind | "favorites">(() => {
if (props.lockedProvider !== null) {
return props.lockedProvider;
Expand Down Expand Up @@ -99,6 +100,11 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
favorites.map((favorite, index) => [`${favorite.provider}:${favorite.model}`, index]),
);
}, [favorites]);
const hiddenModelsSet = useMemo(() => {
return new Set(
hiddenModels.map((hiddenModel) => `${hiddenModel.provider}:${hiddenModel.model}`),
);
}, [hiddenModels]);

const readyProviderSet = useMemo(() => {
if (!props.providers || props.providers.length === 0) {
Expand All @@ -117,15 +123,23 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
if (readyProviderSet && !readyProviderSet.has(providerKind as ProviderKind)) {
return [];
}
return models.map((m) => ({
slug: m.slug,
name: m.name,
...(m.shortName ? { shortName: m.shortName } : {}),
...(m.subProvider ? { subProvider: m.subProvider } : {}),
provider: providerKind as ProviderKind,
})) satisfies Array<ModelPickerItem>;
return models.flatMap((m) => {
const provider = providerKind as ProviderKind;
if (hiddenModelsSet.has(`${provider}:${m.slug}`)) {
return [];
}
return [
{
slug: m.slug,
name: m.name,
...(m.shortName ? { shortName: m.shortName } : {}),
...(m.subProvider ? { subProvider: m.subProvider } : {}),
provider,
},
];
}) satisfies Array<ModelPickerItem>;
});
}, [props.modelOptionsByProvider, readyProviderSet]);
}, [hiddenModelsSet, props.modelOptionsByProvider, readyProviderSet]);

// Filter models based on search query and selected provider
const filteredModels = useMemo(() => {
Expand Down Expand Up @@ -249,8 +263,30 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
[favorites, updateSettings],
);

const moveFavorite = useCallback(
(provider: ProviderKind, model: string, direction: -1 | 1) => {
const index = favorites.findIndex(
(favorite) => favorite.provider === provider && favorite.model === model,
);
const nextIndex = index + direction;
if (index < 0 || nextIndex < 0 || nextIndex >= favorites.length) {
return;
}

const newFavorites = [...favorites];
const [movedFavorite] = newFavorites.splice(index, 1);
if (!movedFavorite) {
return;
}
newFavorites.splice(nextIndex, 0, movedFavorite);
updateSettings({ favorites: newFavorites });
},
[favorites, updateSettings],
);

const isLocked = props.lockedProvider !== null;
const isSearching = searchQuery.trim().length > 0;
const canReorderFavorites = selectedProvider === "favorites" && !isSearching;
const showSidebar = !isLocked && !isSearching;
const LockedProviderIcon =
isLocked && props.lockedProvider ? PROVIDER_ICON_BY_PROVIDER[props.lockedProvider] : null;
Expand Down Expand Up @@ -503,6 +539,18 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
showNewBadge={isModelPickerNewModel(model.provider, model.slug)}
jumpLabel={modelJumpLabelByKey.get(modelKey) ?? null}
onToggleFavorite={() => toggleFavorite(model.provider, model.slug)}
reorderControls={
canReorderFavorites && favoritesSet.has(modelKey)
? {
canMoveUp: (favoriteOrder.get(modelKey) ?? -1) > 0,
canMoveDown:
(favoriteOrder.get(modelKey) ?? favorites.length) <
favorites.length - 1,
onMoveUp: () => moveFavorite(model.provider, model.slug, -1),
onMoveDown: () => moveFavorite(model.provider, model.slug, 1),
}
: null
}
/>
);
})}
Expand Down
74 changes: 74 additions & 0 deletions apps/web/src/components/chat/ProviderModelPicker.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { render } from "vitest-browser-react";
import { ProviderModelPicker } from "./ProviderModelPicker";
import { getCustomModelOptionsByProvider } from "../../modelSelection";
import { DEFAULT_CLIENT_SETTINGS, DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
import { __resetClientSettingsPersistenceForTests } from "../../hooks/useSettings";
import { __resetLocalApiForTests } from "../../localApi";

// Mock the environments/runtime module to provide a mock primary environment connection
Expand Down Expand Up @@ -294,12 +295,16 @@ function getSidebarProviderOrder() {
describe("ProviderModelPicker", () => {
beforeEach(async () => {
// Reset test environment before each test
localStorage.clear();
await __resetLocalApiForTests();
__resetClientSettingsPersistenceForTests();
});

afterEach(async () => {
document.body.innerHTML = "";
localStorage.clear();
await __resetLocalApiForTests();
__resetClientSettingsPersistenceForTests();
});

it("shows provider sidebar in unlocked mode", async () => {
Expand Down Expand Up @@ -908,6 +913,75 @@ describe("ProviderModelPicker", () => {
}
});

it("reorders favorited models from the favorites list", async () => {
localStorage.setItem(
"t3code:client-settings:v1",
JSON.stringify({
...DEFAULT_CLIENT_SETTINGS,
favorites: [
{ provider: "codex", model: "gpt-5-codex" },
{ provider: "claudeAgent", model: "claude-sonnet-4-6" },
],
}),
);

const mounted = await mountPicker({
provider: "codex",
model: "gpt-5-codex",
lockedProvider: null,
});

try {
await page.getByRole("button").click();
await page.getByRole("button", { name: "Favorites", exact: true }).click();

await vi.waitFor(() => {
expect(getVisibleModelNames().slice(0, 2)).toEqual(["GPT-5 Codex", "Claude Sonnet 4.6"]);
});

await page
.getByRole("button", { name: "Move Claude Sonnet 4.6 up in favorites", exact: true })
.click();

await vi.waitFor(() => {
expect(getVisibleModelNames().slice(0, 2)).toEqual(["Claude Sonnet 4.6", "GPT-5 Codex"]);
});
} finally {
await mounted.cleanup();
localStorage.removeItem("t3code:client-settings:v1");
}
});

it("does not show hidden models in the picker", async () => {
localStorage.setItem(
"t3code:client-settings:v1",
JSON.stringify({
...DEFAULT_CLIENT_SETTINGS,
hiddenModels: [{ provider: "codex", model: "gpt-5.3-codex" }],
}),
);

const mounted = await mountPicker({
provider: "codex",
model: "gpt-5-codex",
lockedProvider: null,
});

try {
await page.getByRole("button").click();
await page.getByRole("button", { name: "Codex", exact: true }).click();

await vi.waitFor(() => {
const visibleModelNames = getVisibleModelNames();
expect(visibleModelNames).toContain("GPT-5 Codex");
expect(visibleModelNames).not.toContain("GPT-5.3 Codex");
});
} finally {
await mounted.cleanup();
localStorage.removeItem("t3code:client-settings:v1");
}
});

it("dispatches callback with correct provider and model when selected", async () => {
const mounted = await mountPicker({
provider: "claudeAgent",
Expand Down
Loading
Loading