feat(models): add user model favorites (#5441)

* feat(models): add user model favorites

* fix(models): use anchored favorites picker

* fix(models): keep model picker compact

* fix(models): remove obsolete favorites search path

* fix(models): address picker review feedback
This commit is contained in:
Ryker_Feng 2026-09-16 18:42:13 +08:00 committed by GitHub
parent a246c928e9
commit 6ca12c6c8f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 2162 additions and 297 deletions

View File

@ -154,6 +154,14 @@ It is disabled by default; see the linked guide to enable it.
DeerFlow disables Console cost estimates when currencies are mixed rather
than presenting an invalid aggregate.
When several models are configured, open either model picker and use the
star beside a model to favorite it. Favorites appear first in both the main
chat and Side Chat pickers without changing either chat's selected or
default model. They are stored for the signed-in user in the current browser,
so they do not sync to another browser or device and do not require a startup
setting. The compact favorites picker intentionally omits search and only
adds favorite ordering to the two-line model list.
<details>
<summary>Manual model configuration examples</summary>

View File

@ -35,6 +35,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",

View File

@ -56,6 +56,9 @@ importers:
'@radix-ui/react-icons':
specifier: ^1.3.2
version: 1.3.2(react@19.2.4)
'@radix-ui/react-popover':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-progress':
specifier: ^1.1.8
version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@ -1410,6 +1413,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-popover@1.1.15':
resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-popper@1.2.8':
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
peerDependencies:
@ -7197,6 +7213,29 @@ snapshots:
'@types/react': 19.2.13
'@types/react-dom': 19.2.3(@types/react@19.2.13)
'@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.13)(react@19.2.4)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.13)(react@19.2.4)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.13)(react@19.2.4)
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.13)(react@19.2.4)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.13)(react@19.2.4)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.13)(react@19.2.4)
aria-hidden: 1.2.6
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
react-remove-scroll: 2.7.2(@types/react@19.2.13)(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.13
'@types/react-dom': 19.2.3(@types/react@19.2.13)
'@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@floating-ui/react-dom': 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)

View File

@ -145,6 +145,18 @@ Array previews coalesce consecutive generated markers only at the end into one o
### Interaction Ownership
- `src/components/workspace/model-picker-content.tsx` owns the compact model
list, favorite grouping, and the anchored non-modal picker shared by the main
composer and Side Chat. Each row keeps model selection and its inline
favorite star as sibling buttons. The picker deliberately follows the
pre-favorites two-line row density and does not add a search field. Favorites
are stored by
`core/models/favorites-store.ts` under a user-scoped browser key and only
reorder derived display arrays: never sort `useModels().models`, promote a
favorite to the default model, prune a temporarily unavailable favorite, or
merge the main and Side Chat selection callbacks. Keep favorite buttons out
of model-selection buttons; the two call sites continue to own their triggers
and their distinct mode/reasoning-effort transitions.
- `src/app/workspace/chats/[thread_id]/page.tsx` owns composer busy-state wiring.
- `src/app/workspace/chats/[thread_id]/page.tsx` owns branch-from-turn submission and navigation; sidecar `MessageList` instances do not receive the branch action.
- `core/threads/thread-branch-tree.ts` projects only loaded, same-pin branch lineage into Recent chats. Missing, malformed, cross-pin, self, or cyclic parents stay top-level; unpinned groups follow their freshest descendant while pinned root order stays stable. `recent-chat-list.tsx` caps visual indentation without changing the recursive order.

View File

@ -1,208 +0,0 @@
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import type { ComponentProps, ReactNode } from "react";
export type ModelSelectorProps = ComponentProps<typeof Dialog>;
export const ModelSelector = (props: ModelSelectorProps) => (
<Dialog {...props} />
);
export type ModelSelectorTriggerProps = ComponentProps<typeof DialogTrigger>;
export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
<DialogTrigger {...props} />
);
export type ModelSelectorContentProps = ComponentProps<typeof DialogContent> & {
title?: ReactNode;
};
export const ModelSelectorContent = ({
className,
children,
title = "Model Selector",
...props
}: ModelSelectorContentProps) => (
<DialogContent className={cn("p-0", className)} {...props}>
<DialogTitle className="sr-only">{title}</DialogTitle>
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
{children}
</Command>
</DialogContent>
);
export type ModelSelectorDialogProps = ComponentProps<typeof CommandDialog>;
export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (
<CommandDialog {...props} />
);
export type ModelSelectorInputProps = ComponentProps<typeof CommandInput>;
export const ModelSelectorInput = ({
className,
...props
}: ModelSelectorInputProps) => (
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
);
export type ModelSelectorListProps = ComponentProps<typeof CommandList>;
export const ModelSelectorList = (props: ModelSelectorListProps) => (
<CommandList {...props} />
);
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>;
export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => (
<CommandEmpty {...props} />
);
export type ModelSelectorGroupProps = ComponentProps<typeof CommandGroup>;
export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (
<CommandGroup {...props} />
);
export type ModelSelectorItemProps = ComponentProps<typeof CommandItem>;
export const ModelSelectorItem = (props: ModelSelectorItemProps) => (
<CommandItem {...props} />
);
export type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>;
export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => (
<CommandShortcut {...props} />
);
export type ModelSelectorSeparatorProps = ComponentProps<
typeof CommandSeparator
>;
export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => (
<CommandSeparator {...props} />
);
export type ModelSelectorLogoProps = Omit<
ComponentProps<"img">,
"src" | "alt"
> & {
provider:
| "moonshotai-cn"
| "lucidquery"
| "moonshotai"
| "zai-coding-plan"
| "alibaba"
| "xai"
| "vultr"
| "nvidia"
| "upstage"
| "groq"
| "github-copilot"
| "mistral"
| "vercel"
| "nebius"
| "deepseek"
| "alibaba-cn"
| "google-vertex-anthropic"
| "venice"
| "chutes"
| "cortecs"
| "github-models"
| "togetherai"
| "azure"
| "baseten"
| "huggingface"
| "opencode"
| "fastrouter"
| "google"
| "google-vertex"
| "cloudflare-workers-ai"
| "inception"
| "wandb"
| "openai"
| "zhipuai-coding-plan"
| "perplexity"
| "openrouter"
| "zenmux"
| "v0"
| "iflowcn"
| "synthetic"
| "deepinfra"
| "zhipuai"
| "submodel"
| "zai"
| "inference"
| "requesty"
| "morph"
| "lmstudio"
| "anthropic"
| "aihubmix"
| "fireworks-ai"
| "modelscope"
| "llama"
| "scaleway"
| "amazon-bedrock"
| "cerebras"
| (string & {});
};
export const ModelSelectorLogo = ({
provider,
className,
...props
}: ModelSelectorLogoProps) => (
<img
{...props}
alt={`${provider} logo`}
className={cn("size-3 dark:invert", className)}
height={12}
src={`https://models.dev/logos/${provider}.svg`}
width={12}
/>
);
export type ModelSelectorLogoGroupProps = ComponentProps<"div">;
export const ModelSelectorLogoGroup = ({
className,
...props
}: ModelSelectorLogoGroupProps) => (
<div
className={cn(
"[&>img]:bg-background dark:[&>img]:bg-foreground flex shrink-0 items-center -space-x-1 [&>img]:rounded-full [&>img]:p-px [&>img]:ring-1",
className,
)}
{...props}
/>
);
export type ModelSelectorNameProps = ComponentProps<"span">;
export const ModelSelectorName = ({
className,
...props
}: ModelSelectorNameProps) => (
<span
className={cn("flex-1 truncate text-left text-xs", className)}
{...props}
/>
);

View File

@ -116,15 +116,6 @@ import {
import { isIMEComposing } from "@/lib/ime";
import { cn } from "@/lib/utils";
import {
ModelSelector,
ModelSelectorContent,
ModelSelectorInput,
ModelSelectorItem,
ModelSelectorList,
ModelSelectorName,
ModelSelectorTrigger,
} from "../ai-elements/model-selector";
import { Suggestion, Suggestions } from "../ai-elements/suggestion";
import {
DropdownMenu,
@ -154,6 +145,11 @@ import {
} from "./input-box-helpers";
import { useThread } from "./messages/context";
import { ModeHoverGuide } from "./mode-hover-guide";
import {
ModelPicker,
ModelPickerContent,
ModelPickerTrigger,
} from "./model-picker-content";
import { ReferenceAttachmentSummary, useMaybeSidecar } from "./sidecar";
import { SlashSkillChip } from "./slash-skill-chip";
import { Tooltip } from "./tooltip";
@ -2726,47 +2722,29 @@ export function InputBox({
{goalObjectiveCounter.length}/{goalObjectiveCounter.max}
</span>
)}
<ModelSelector
<ModelPicker
open={modelDialogOpen}
onOpenChange={setModelDialogOpen}
>
<ModelSelectorTrigger asChild>
<ModelPickerTrigger asChild>
<PromptInputButton
className="max-w-40 min-w-0 sm:max-w-56"
disabled={composerLocked}
>
<div className="flex min-w-0 flex-col text-left">
<ModelSelectorName className="text-xs font-normal">
<span className="flex-1 truncate text-left text-xs font-normal">
{selectedModel?.display_name}
</ModelSelectorName>
</span>
</div>
</PromptInputButton>
</ModelSelectorTrigger>
<ModelSelectorContent>
<ModelSelectorInput placeholder={t.inputBox.searchModels} />
<ModelSelectorList>
{models.map((m) => (
<ModelSelectorItem
key={m.name}
value={m.name}
onSelect={() => handleModelSelect(m.name)}
>
<div className="flex min-w-0 flex-1 flex-col">
<ModelSelectorName>{m.display_name}</ModelSelectorName>
<span className="text-muted-foreground truncate text-[10px]">
{m.model}
</span>
</div>
{m.name === context.model_name ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</ModelSelectorItem>
))}
</ModelSelectorList>
</ModelSelectorContent>
</ModelSelector>
</ModelPickerTrigger>
<ModelPickerContent
open={modelDialogOpen}
models={models}
selectedModelName={selectedModel?.name}
onModelSelect={handleModelSelect}
/>
</ModelPicker>
<PromptInputSubmit
className="rounded-full"
disabled={composerLocked || stopDenied}

View File

@ -0,0 +1,292 @@
"use client";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { CheckIcon, StarIcon } from "lucide-react";
import {
type KeyboardEvent,
useCallback,
useLayoutEffect,
useMemo,
useRef,
} from "react";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/core/auth/AuthProvider";
import { useI18n } from "@/core/i18n/hooks";
import {
projectModelChoices,
type ModelChoiceProjection,
} from "@/core/models/favorites";
import { type Model } from "@/core/models/types";
import { useModelFavorites } from "@/core/models/use-model-favorites";
import { cn } from "@/lib/utils";
export const ModelPicker = PopoverPrimitive.Root;
export const ModelPickerTrigger = PopoverPrimitive.Trigger;
export interface ModelPickerContentProps {
open: boolean;
models: readonly Model[];
selectedModelName?: string;
onModelSelect: (name: string) => void;
}
type FocusedControl = {
modelName: string;
kind: "model" | "favorite";
};
function orderedModels(projection: ModelChoiceProjection): readonly Model[] {
return [...projection.favorites, ...projection.others];
}
function ModelDetails({ model }: { model: Model }) {
return (
<span className="flex min-w-0 flex-1 flex-col text-left">
<span className="truncate text-xs">{model.display_name}</span>
<span className="text-muted-foreground truncate text-[10px] leading-4">
{model.model}
</span>
</span>
);
}
export function ModelPickerContent({
open,
models,
selectedModelName,
onModelSelect,
}: ModelPickerContentProps) {
const { t } = useI18n();
const { user, isLoading } = useAuth();
const favorites = useModelFavorites(user?.id ?? null);
const modelButtonRefs = useRef(new Map<string, HTMLButtonElement>());
const favoriteButtonRefs = useRef(new Map<string, HTMLButtonElement>());
const pendingFavoriteFocusRef = useRef<string | null>(null);
const lastFocusedControlRef = useRef<FocusedControl | null>(null);
const wasOpenRef = useRef(false);
const projection = useMemo(
() => projectModelChoices(models, favorites.names),
[favorites.names, models],
);
const visibleModels = useMemo(() => orderedModels(projection), [projection]);
const focusInitialModel = useCallback(() => {
const preferredModel =
visibleModels.find((model) => model.name === selectedModelName) ??
visibleModels[0];
if (preferredModel) {
modelButtonRefs.current.get(preferredModel.name)?.focus();
}
}, [selectedModelName, visibleModels]);
useLayoutEffect(() => {
const opening = open && !wasOpenRef.current;
wasOpenRef.current = open;
if (opening) {
focusInitialModel();
}
}, [focusInitialModel, open]);
useLayoutEffect(() => {
if (!open) {
lastFocusedControlRef.current = null;
return;
}
const pendingFavorite = pendingFavoriteFocusRef.current;
if (pendingFavorite !== null) {
const button = favoriteButtonRefs.current.get(pendingFavorite);
if (button) {
button.focus();
pendingFavoriteFocusRef.current = null;
}
return;
}
const previousControl = lastFocusedControlRef.current;
if (previousControl === null || document.activeElement !== document.body) {
return;
}
const button =
previousControl.kind === "model"
? modelButtonRefs.current.get(previousControl.modelName)
: favoriteButtonRefs.current.get(previousControl.modelName);
if (button) {
button.focus();
}
}, [favorites.names, open]);
const handleFavorite = useCallback(
(modelName: string) => {
if (user === null || isLoading || !favorites.canEdit) {
return;
}
const stillVisible = models.some((model) => model.name === modelName);
if (!stillVisible) {
return;
}
pendingFavoriteFocusRef.current = modelName;
favorites.setFavorite(modelName, !favorites.names.includes(modelName));
},
[favorites, isLoading, models, user],
);
const focusModel = useCallback(
(currentName: string, direction: 1 | -1) => {
if (visibleModels.length === 0) {
return;
}
const currentIndex = visibleModels.findIndex(
(model) => model.name === currentName,
);
const nextIndex =
(Math.max(currentIndex, 0) + direction + visibleModels.length) %
visibleModels.length;
modelButtonRefs.current.get(visibleModels[nextIndex]!.name)?.focus();
},
[visibleModels],
);
const handleRowKeyDown = useCallback(
(event: KeyboardEvent<HTMLButtonElement>, modelName: string) => {
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") {
return;
}
event.preventDefault();
focusModel(modelName, event.key === "ArrowDown" ? 1 : -1);
},
[focusModel],
);
const renderGroup = (heading: string, groupModels: readonly Model[]) => {
if (groupModels.length === 0) {
return null;
}
return (
<section role="group" aria-label={heading}>
<h3 className="text-muted-foreground px-2 py-1.5 text-xs font-medium">
{heading}
</h3>
<ul className="px-1 pb-0.5">
{groupModels.map((model) => {
const isFavorite = favorites.names.includes(model.name);
const isCurrent = model.name === selectedModelName;
return (
<li
key={model.name}
className="hover:bg-accent focus-within:bg-accent flex min-h-9 min-w-0 items-stretch rounded-sm"
>
<button
ref={(node) => {
if (node) {
modelButtonRefs.current.set(model.name, node);
} else {
modelButtonRefs.current.delete(model.name);
}
}}
type="button"
className="focus-visible:ring-ring flex min-w-0 flex-1 items-center gap-2 rounded-l-sm px-2 py-1.5 outline-none focus-visible:ring-2"
aria-label={`${model.display_name} (${model.name})`}
aria-current={isCurrent ? "true" : undefined}
data-model-picker-option="true"
data-current-model={isCurrent ? "true" : undefined}
onFocus={() => {
lastFocusedControlRef.current = {
modelName: model.name,
kind: "model",
};
}}
onClick={() => onModelSelect(model.name)}
onKeyDown={(event) => handleRowKeyDown(event, model.name)}
>
<ModelDetails model={model} />
{isCurrent ? (
<CheckIcon aria-hidden="true" className="size-4 shrink-0" />
) : null}
</button>
{user !== null ? (
<Button
ref={(node) => {
if (node) {
favoriteButtonRefs.current.set(model.name, node);
} else {
favoriteButtonRefs.current.delete(model.name);
}
}}
type="button"
variant="ghost"
size="icon"
className={cn(
"size-8 shrink-0 rounded-l-none",
isFavorite && "text-amber-500",
)}
aria-label={t.modelPicker.favoriteModel(
model.display_name,
model.name,
)}
aria-pressed={isFavorite}
disabled={isLoading || !favorites.canEdit}
onFocus={() => {
lastFocusedControlRef.current = {
modelName: model.name,
kind: "favorite",
};
}}
onClick={() => handleFavorite(model.name)}
onKeyDown={(event) => handleRowKeyDown(event, model.name)}
>
<StarIcon
aria-hidden="true"
className={cn("size-4", isFavorite && "fill-current")}
/>
</Button>
) : null}
</li>
);
})}
</ul>
</section>
);
};
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
role="dialog"
aria-label={t.modelPicker.title}
side="top"
align="end"
sideOffset={8}
collisionPadding={8}
className="bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 z-50 flex max-h-[min(20rem,var(--radix-popover-content-available-height))] w-72 max-w-[calc(100vw-1rem)] origin-(--radix-popover-content-transform-origin) flex-col overflow-hidden rounded-md border shadow-md outline-none"
onOpenAutoFocus={(event) => {
event.preventDefault();
focusInitialModel();
}}
>
<div className="min-h-0 overflow-x-hidden overflow-y-auto py-0.5">
{visibleModels.length === 0 ? (
<div className="text-muted-foreground py-6 text-center text-sm">
{t.modelPicker.noModels}
</div>
) : (
<>
{renderGroup(t.modelPicker.favorites, projection.favorites)}
{renderGroup(t.modelPicker.otherModels, projection.others)}
</>
)}
</div>
{favorites.persistence === "memory" && user !== null ? (
<p
role="status"
className="text-muted-foreground shrink-0 border-t px-3 py-2 text-xs"
>
{t.modelPicker.sessionOnly}
</p>
) : null}
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
);
}

View File

@ -80,18 +80,14 @@ import {
import { env } from "@/env";
import { cn } from "@/lib/utils";
import {
ModelSelector,
ModelSelectorContent,
ModelSelectorInput,
ModelSelectorItem,
ModelSelectorList,
ModelSelectorName,
ModelSelectorTrigger,
} from "../../ai-elements/model-selector";
import { MessageList, MESSAGE_LIST_DEFAULT_PADDING_BOTTOM } from "../messages";
import { useThread as useParentThread } from "../messages/context";
import { ModeHoverGuide } from "../mode-hover-guide";
import {
ModelPicker,
ModelPickerContent,
ModelPickerTrigger,
} from "../model-picker-content";
import { Tooltip } from "../tooltip";
import { type SidecarReference, useSidecar } from "./context";
@ -646,7 +642,6 @@ export function SidecarPanel({ className }: { className?: string }) {
<PromptInputTools className="min-w-0 justify-end">
<SidecarModelSelector
className="max-w-40 min-w-0 sm:max-w-56 @max-[240px]:hidden"
context={sidecar.context}
models={models}
open={modelDialogOpen}
selectedModel={selectedModel}
@ -918,7 +913,6 @@ function SidecarModeMenu({
function SidecarModelSelector({
className,
context,
models,
open,
selectedModel,
@ -926,54 +920,33 @@ function SidecarModelSelector({
onOpenChange,
}: {
className?: string;
context: ThreadStreamOptions["context"];
models: Model[];
open: boolean;
selectedModel?: Model;
onModelSelect: (modelName: string) => void;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useI18n();
if (!selectedModel) {
return null;
}
return (
<ModelSelector open={open} onOpenChange={onOpenChange}>
<ModelSelectorTrigger asChild>
<ModelPicker open={open} onOpenChange={onOpenChange}>
<ModelPickerTrigger asChild>
<PromptInputButton className={cn("min-w-0 px-2!", className)}>
<div className="flex min-w-0 flex-col text-left">
<ModelSelectorName className="truncate text-xs font-normal">
<span className="flex-1 truncate text-left text-xs font-normal">
{selectedModel.display_name}
</ModelSelectorName>
</span>
</div>
</PromptInputButton>
</ModelSelectorTrigger>
<ModelSelectorContent>
<ModelSelectorInput placeholder={t.inputBox.searchModels} />
<ModelSelectorList>
{models.map((model) => (
<ModelSelectorItem
key={model.name}
value={model.name}
onSelect={() => onModelSelect(model.name)}
>
<div className="flex min-w-0 flex-1 flex-col">
<ModelSelectorName>{model.display_name}</ModelSelectorName>
<span className="text-muted-foreground truncate text-[10px]">
{model.model}
</span>
</div>
{model.name === context.model_name ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</ModelSelectorItem>
))}
</ModelSelectorList>
</ModelSelectorContent>
</ModelSelector>
</ModelPickerTrigger>
<ModelPickerContent
open={open}
models={models}
selectedModelName={selectedModel.name}
onModelSelect={onModelSelect}
/>
</ModelPicker>
);
}

View File

@ -206,6 +206,15 @@ export const enUS: Translations = {
downloadFailed: "Failed to download artifact archive.",
},
modelPicker: {
title: "Choose a model",
favorites: "Favorites",
otherModels: "Other models",
noModels: "No models available",
favoriteModel: (displayName, name) => `Favorite ${displayName} (${name})`,
sessionOnly: "Favorites are stored for this session only.",
},
// Citations
citations: {
sourcesSummary: (count) =>
@ -291,7 +300,6 @@ export const enUS: Translations = {
reasoningEffortHigh: "High",
reasoningEffortHighDescription:
"Full-dimensional Logic Deduction + Multi-path Verification + Backward Check",
searchModels: "Search models...",
surpriseMe: "Surprise",
surpriseMePrompt: "Surprise me",
followupLoading: "Generating follow-up questions...",

View File

@ -173,6 +173,15 @@ export interface Translations {
downloadFailed: string;
};
modelPicker: {
title: string;
favorites: string;
otherModels: string;
noModels: string;
favoriteModel: (displayName: string, name: string) => string;
sessionOnly: string;
};
// Citations
citations: {
sourcesSummary: (count: number) => string;
@ -244,7 +253,6 @@ export interface Translations {
reasoningEffortMediumDescription: string;
reasoningEffortHigh: string;
reasoningEffortHighDescription: string;
searchModels: string;
surpriseMe: string;
surpriseMePrompt: string;
followupLoading: string;

View File

@ -195,6 +195,15 @@ export const zhCN: Translations = {
downloadFailed: "文件压缩包下载失败。",
},
modelPicker: {
title: "选择模型",
favorites: "收藏",
otherModels: "其他模型",
noModels: "暂无可用模型",
favoriteModel: (displayName, name) => `收藏 ${displayName}${name}`,
sessionOnly: "收藏仅在本次会话中保留。",
},
// Citations
citations: {
sourcesSummary: (count) => `使用了 ${count} 个来源`,
@ -271,7 +280,6 @@ export const zhCN: Translations = {
reasoningEffortMediumDescription: "多层逻辑分析 + 基础验证",
reasoningEffortHigh: "高",
reasoningEffortHighDescription: "全维度逻辑推演 + 多路径验证 + 反推校验",
searchModels: "搜索模型...",
surpriseMe: "小惊喜",
surpriseMePrompt: "给我一个小惊喜吧",
followupLoading: "正在生成可能的后续问题...",

View File

@ -0,0 +1,219 @@
import { safeLocalStorage } from "@/core/settings/local";
import {
favoritesKey,
parseFavoriteNames,
serializeFavoriteNames,
updateFavoriteNames,
} from "./favorites";
export interface FavoritesSnapshot {
names: readonly string[];
persistence: "local" | "memory";
}
const EMPTY_NAMES: readonly string[] = Object.freeze([]);
export const EMPTY_FAVORITES: FavoritesSnapshot = Object.freeze({
names: EMPTY_NAMES,
persistence: "memory",
});
type Listener = () => void;
interface FavoritesEntry {
userId: string;
snapshot: FavoritesSnapshot;
listeners: Set<Listener>;
}
type StorageRead = { ok: true; raw: string | null } | { ok: false };
const entries = new Map<string, FavoritesEntry>();
let activeSubscriptionCount = 0;
let storageListenerRegistered = false;
function readPersistedFavorites(userId: string): StorageRead {
if (typeof window === "undefined") {
return { ok: false };
}
try {
return {
ok: true,
raw: window.localStorage.getItem(favoritesKey(userId)),
};
} catch {
return { ok: false };
}
}
function sameNames(left: readonly string[], right: readonly string[]): boolean {
return (
left.length === right.length &&
left.every((name, index) => name === right[index])
);
}
function emitChange(entry: FavoritesEntry) {
for (const listener of entry.listeners) {
listener();
}
}
function replaceSnapshot(
entry: FavoritesEntry,
names: readonly string[],
persistence: FavoritesSnapshot["persistence"],
): boolean {
if (
entry.snapshot.persistence === persistence &&
sameNames(entry.snapshot.names, names)
) {
return false;
}
entry.snapshot = { names, persistence };
return true;
}
function createEntry(userId: string): FavoritesEntry {
const persisted = readPersistedFavorites(userId);
const entry: FavoritesEntry = {
userId,
snapshot: {
names: persisted.ok ? parseFavoriteNames(persisted.raw) : EMPTY_NAMES,
persistence: "local",
},
listeners: new Set(),
};
entries.set(userId, entry);
return entry;
}
function getEntry(userId: string): FavoritesEntry {
return entries.get(userId) ?? createEntry(userId);
}
function reloadEntry(entry: FavoritesEntry) {
if (entry.snapshot.persistence === "memory") {
return;
}
const persisted = readPersistedFavorites(entry.userId);
if (!persisted.ok) {
return;
}
if (replaceSnapshot(entry, parseFavoriteNames(persisted.raw), "local")) {
emitChange(entry);
}
}
function handleStorage(event: StorageEvent) {
try {
if (event.storageArea !== window.localStorage) {
return;
}
} catch {
return;
}
if (event.key === null) {
for (const entry of entries.values()) {
reloadEntry(entry);
}
return;
}
for (const entry of entries.values()) {
if (
entry.snapshot.persistence === "local" &&
favoritesKey(entry.userId) === event.key
) {
reloadEntry(entry);
return;
}
}
}
function registerStorageListener() {
if (storageListenerRegistered || typeof window === "undefined") {
return;
}
window.addEventListener("storage", handleStorage);
storageListenerRegistered = true;
}
function unregisterStorageListener() {
if (!storageListenerRegistered || typeof window === "undefined") {
return;
}
window.removeEventListener("storage", handleStorage);
storageListenerRegistered = false;
}
export function getFavoritesSnapshot(userId: string | null): FavoritesSnapshot {
return userId === null ? EMPTY_FAVORITES : getEntry(userId).snapshot;
}
export function subscribeFavorites(
userId: string | null,
listener: Listener,
): () => void {
if (userId === null) {
return () => undefined;
}
const alreadyCached = entries.has(userId);
const entry = getEntry(userId);
if (alreadyCached) {
reloadEntry(entry);
}
entry.listeners.add(listener);
activeSubscriptionCount += 1;
registerStorageListener();
let subscribed = true;
return () => {
if (!subscribed) {
return;
}
subscribed = false;
entry.listeners.delete(listener);
activeSubscriptionCount -= 1;
if (activeSubscriptionCount === 0) {
unregisterStorageListener();
}
};
}
export function setModelFavorite(
userId: string | null,
name: string,
favorite: boolean,
) {
if (userId === null) {
return;
}
const entry = getEntry(userId);
const nextNames = updateFavoriteNames(entry.snapshot.names, name, favorite);
if (nextNames === entry.snapshot.names) {
return;
}
let persistence = entry.snapshot.persistence;
if (
persistence === "local" &&
!safeLocalStorage.setItem(
favoritesKey(userId),
serializeFavoriteNames(nextNames),
)
) {
persistence = "memory";
}
replaceSnapshot(entry, nextNames, persistence);
emitChange(entry);
}

View File

@ -0,0 +1,85 @@
import { type Model } from "./types";
const FAVORITES_KEY_PREFIX = "deerflow.model-favorites.v1:";
interface FavoriteNamesPayload {
version: 1;
names: readonly string[];
}
export interface ModelChoiceProjection {
favorites: readonly Model[];
others: readonly Model[];
}
export function favoritesKey(userId: string): string {
return `${FAVORITES_KEY_PREFIX}${encodeURIComponent(userId)}`;
}
export function parseFavoriteNames(value: string | null): readonly string[] {
if (value === null) {
return [];
}
let payload: unknown;
try {
payload = JSON.parse(value);
} catch {
return [];
}
if (
typeof payload !== "object" ||
payload === null ||
!("version" in payload) ||
payload.version !== 1 ||
!("names" in payload) ||
!Array.isArray(payload.names)
) {
return [];
}
const names: string[] = [];
const seen = new Set<string>();
for (const name of payload.names) {
if (typeof name !== "string" || name.trim() === "" || seen.has(name)) {
continue;
}
seen.add(name);
names.push(name);
}
return names;
}
export function serializeFavoriteNames(names: readonly string[]): string {
const payload: FavoriteNamesPayload = { version: 1, names };
return JSON.stringify(payload);
}
export function updateFavoriteNames(
names: readonly string[],
name: string,
favorite: boolean,
): readonly string[] {
if (name.trim() === "") {
return names;
}
const includesName = names.includes(name);
if (favorite) {
return includesName ? names : [...names, name];
}
return includesName ? names.filter((candidate) => candidate !== name) : names;
}
export function projectModelChoices(
models: readonly Model[],
favoriteNames: readonly string[],
): ModelChoiceProjection {
const favoriteNameSet = new Set(favoriteNames);
return {
favorites: models.filter((model) => favoriteNameSet.has(model.name)),
others: models.filter((model) => !favoriteNameSet.has(model.name)),
};
}

View File

@ -0,0 +1,37 @@
import { useCallback, useSyncExternalStore } from "react";
import {
EMPTY_FAVORITES,
getFavoritesSnapshot,
setModelFavorite,
subscribeFavorites,
} from "./favorites-store";
function getServerSnapshot() {
return EMPTY_FAVORITES;
}
export function useModelFavorites(userId: string | null) {
const subscribe = useCallback(
(listener: () => void) => subscribeFavorites(userId, listener),
[userId],
);
const getSnapshot = useCallback(() => getFavoritesSnapshot(userId), [userId]);
const snapshot = useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot,
);
const setFavorite = useCallback(
(name: string, favorite: boolean) =>
setModelFavorite(userId, name, favorite),
[userId],
);
return {
names: snapshot.names,
persistence: snapshot.persistence,
canEdit: userId !== null,
setFavorite,
};
}

View File

@ -0,0 +1,349 @@
import { expect, test, type Page } from "@playwright/test";
import { type Model } from "@/core/models/types";
import { mockLangGraphAPI, MOCK_THREAD_ID } from "./utils/mock-api";
const MODELS: Model[] = [
{
id: "alpha-api",
name: "alpha-api",
model: "alpha-api",
display_name: "Alpha",
supports_thinking: false,
supports_reasoning_effort: false,
},
{
id: "beta-api",
name: "beta-api",
model: "beta-api",
display_name: "Shared",
supports_thinking: true,
supports_reasoning_effort: true,
},
{
id: "beta-duplicate",
name: "beta-duplicate",
model: "beta-duplicate",
display_name: "Shared",
supports_thinking: true,
supports_reasoning_effort: true,
},
{
id: "very-long-model-name",
name: "very-long-model-name",
model: "provider/very-long-model-name-that-must-stay-inside-the-dialog",
display_name:
"A very long model display name that must truncate on narrow screens",
supports_thinking: true,
supports_reasoning_effort: true,
},
];
type InstallOptions = Parameters<typeof mockLangGraphAPI>[1];
async function installPageMocks(
page: Page,
options?: InstallOptions,
): Promise<{ setModels: (models: Model[]) => void }> {
mockLangGraphAPI(page, options);
let models = MODELS;
// Register this after the shared mock so every page explicitly supplies the
// model catalog exercised by this spec.
await page.route("**/api/models", (route) => {
if (route.request().method() !== "GET") {
return route.fallback();
}
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
models,
token_usage: { enabled: false },
}),
});
});
return {
setModels(nextModels) {
models = nextModels;
},
};
}
function picker(page: Page) {
return page.getByRole("dialog", { name: "Choose a model" });
}
function favoriteButton(page: Page, modelName: string) {
const model = MODELS.find((candidate) => candidate.name === modelName);
if (!model) {
throw new Error(`Unknown model fixture: ${modelName}`);
}
return picker(page).getByRole("button", {
name: `Favorite ${model.display_name} (${model.name})`,
});
}
function favoriteGroup(page: Page) {
return picker(page).getByRole("group", { name: "Favorites" });
}
function modelButton(page: Page, modelName: string) {
const model = MODELS.find((candidate) => candidate.name === modelName);
if (!model) {
throw new Error(`Unknown model fixture: ${modelName}`);
}
return picker(page).getByRole("button", {
name: `${model.display_name} (${model.name})`,
exact: true,
});
}
async function openMainModelPicker(page: Page, name = "Alpha") {
const trigger = page.getByRole("button", { name, exact: true }).first();
await expect(trigger).toBeVisible();
await trigger.click();
await expect(picker(page)).toBeVisible();
return trigger;
}
async function selectAssistantText(page: Page, text: string) {
await page.evaluate((targetText) => {
const root = document.querySelector('[data-testid="main-message-list"]');
if (!root) {
throw new Error("Main message list was not found");
}
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let node = walker.nextNode();
while (node) {
const value = node.textContent ?? "";
const start = value.indexOf(targetText);
if (start >= 0) {
const range = document.createRange();
range.setStart(node, start);
range.setEnd(node, start + targetText.length);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
node.parentElement?.dispatchEvent(
new MouseEvent("mouseup", { bubbles: true }),
);
return;
}
node = walker.nextNode();
}
throw new Error(`Unable to find assistant text: ${targetText}`);
}, text);
await expect(
page.getByRole("button", { name: "Ask in side chat" }),
).toBeVisible();
}
test("favorites a model without selecting it and persists the choice after refresh", async ({
page,
}) => {
await installPageMocks(page);
const runRequests: string[] = [];
page.on("request", (request) => {
if (request.method() === "POST" && request.url().includes("/runs/stream")) {
runRequests.push(request.url());
}
});
await page.goto("/workspace/chats/new");
const alphaTrigger = await openMainModelPicker(page);
await expect(page.locator('[data-slot="dialog-overlay"]')).toHaveCount(0);
const pickerBox = await picker(page).boundingBox();
const triggerBox = await alphaTrigger.boundingBox();
expect(pickerBox).not.toBeNull();
expect(triggerBox).not.toBeNull();
const pickerSide = await picker(page).getAttribute("data-side");
expect(["top", "bottom"]).toContain(pickerSide);
const anchorGap =
pickerSide === "top"
? triggerBox!.y - (pickerBox!.y + pickerBox!.height)
: pickerBox!.y - (triggerBox!.y + triggerBox!.height);
expect(anchorGap).toBeGreaterThanOrEqual(0);
expect(anchorGap).toBeLessThanOrEqual(12);
expect(pickerBox!.x).toBeGreaterThanOrEqual(0);
expect(pickerBox!.x + pickerBox!.width).toBeLessThanOrEqual(
await page.evaluate(() => document.documentElement.clientWidth),
);
const betaFavorite = favoriteButton(page, "beta-api");
await betaFavorite.click();
await expect(betaFavorite).toHaveAttribute("aria-pressed", "true");
await expect(alphaTrigger).toContainText("Alpha");
expect(runRequests).toEqual([]);
const favorites = favoriteGroup(page);
await expect(favorites).toBeVisible();
await expect(modelButton(page, "beta-api")).toBeVisible();
await modelButton(page, "beta-api").click();
await expect(picker(page)).toBeHidden();
const sharedTrigger = page.getByRole("button", {
name: "Shared",
exact: true,
});
await expect(sharedTrigger).toBeVisible();
expect(runRequests).toEqual([]);
await page.reload();
await expect(sharedTrigger).toBeVisible();
await sharedTrigger.click();
await expect(favoriteButton(page, "beta-api")).toHaveAttribute(
"aria-pressed",
"true",
);
await expect(
favoriteGroup(page).getByRole("button", {
name: "Shared (beta-api)",
exact: true,
}),
).toBeVisible();
});
test("synchronizes favorite additions and removals across real tabs", async ({
context,
page,
}) => {
await installPageMocks(page);
await page.goto("/workspace/chats/new");
await openMainModelPicker(page);
const secondPage = await context.newPage();
await installPageMocks(secondPage);
await secondPage.goto("/workspace/chats/new");
await openMainModelPicker(secondPage);
const firstTabFavorite = favoriteButton(page, "beta-api");
const secondTabFavorite = favoriteButton(secondPage, "beta-api");
await expect(firstTabFavorite).toHaveAttribute("aria-pressed", "false");
await expect(secondTabFavorite).toHaveAttribute("aria-pressed", "false");
await firstTabFavorite.click();
await expect(firstTabFavorite).toHaveAttribute("aria-pressed", "true");
await expect(secondTabFavorite).toHaveAttribute("aria-pressed", "true");
await secondTabFavorite.click();
await expect(secondTabFavorite).toHaveAttribute("aria-pressed", "false");
await expect(firstTabFavorite).toHaveAttribute("aria-pressed", "false");
});
test("shows main-chat favorites in the side chat without changing its current model", async ({
page,
}) => {
const assistantText = "Use this answer for a focused follow-up.";
await installPageMocks(page, {
threads: [
{
thread_id: MOCK_THREAD_ID,
title: "Favorites side chat",
messages: [
{ type: "human", id: "favorites-human", content: "Help me." },
{ type: "ai", id: "favorites-ai", content: assistantText },
],
},
],
});
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
await expect(page.getByText(assistantText)).toBeVisible();
await openMainModelPicker(page);
await favoriteButton(page, "beta-api").click();
await page.keyboard.press("Escape");
await selectAssistantText(page, assistantText);
await page.getByRole("button", { name: "Ask in side chat" }).click();
await expect(
page.getByRole("heading", { name: "Ask a follow-up" }),
).toBeVisible();
const sidecarForm = page.locator("form").filter({
has: page.getByPlaceholder(/deeper follow-up/i),
});
const sidecarTrigger = sidecarForm.getByRole("button", {
name: "Alpha",
exact: true,
});
await expect(sidecarTrigger).toBeVisible();
await sidecarTrigger.click();
await expect(modelButton(page, "beta-api")).toBeVisible();
await page.keyboard.press("Escape");
await expect(picker(page)).toBeHidden();
await expect(sidecarTrigger).toBeFocused();
await expect(sidecarTrigger).toHaveAccessibleName("Alpha");
});
test("restores a temporarily unavailable favorite and keeps the narrow picker usable", async ({
page,
}) => {
await page.setViewportSize({ width: 375, height: 720 });
const modelAPI = await installPageMocks(page);
await page.goto("/workspace/chats/new");
const trigger = await openMainModelPicker(page);
const longNameFavorite = favoriteButton(page, "very-long-model-name");
await expect(longNameFavorite).toBeVisible();
const narrowMetrics = await picker(page).evaluate((dialog) => {
const dialogBox = dialog.getBoundingClientRect();
const longRow = dialog
.querySelector(
'button[aria-label="Favorite A very long model display name that must truncate on narrow screens (very-long-model-name)"]',
)
?.closest("li")
?.getBoundingClientRect();
return {
viewportWidth: document.documentElement.clientWidth,
documentWidth: document.documentElement.scrollWidth,
dialogLeft: dialogBox.left,
dialogRight: dialogBox.right,
longRowRight: longRow?.right ?? Number.POSITIVE_INFINITY,
};
});
expect(narrowMetrics.documentWidth).toBeLessThanOrEqual(
narrowMetrics.viewportWidth,
);
expect(narrowMetrics.dialogLeft).toBeGreaterThanOrEqual(0);
expect(narrowMetrics.dialogRight).toBeLessThanOrEqual(
narrowMetrics.viewportWidth,
);
expect(narrowMetrics.longRowRight).toBeLessThanOrEqual(
narrowMetrics.dialogRight,
);
await longNameFavorite.click();
await expect(longNameFavorite).toHaveAttribute("aria-pressed", "true");
await page.keyboard.press("Escape");
await expect(picker(page)).toBeHidden();
await expect(trigger).toBeFocused();
modelAPI.setModels(
MODELS.filter((model) => model.name !== "very-long-model-name"),
);
await page.reload();
await openMainModelPicker(page);
await expect(
picker(page).getByText("very-long-model-name", { exact: true }),
).toHaveCount(0);
await page.keyboard.press("Escape");
modelAPI.setModels(MODELS);
await page.reload();
await openMainModelPicker(page);
await expect(favoriteButton(page, "very-long-model-name")).toHaveAttribute(
"aria-pressed",
"true",
);
await expect(
favoriteGroup(page).getByRole("button", {
name: `${MODELS[3]!.display_name} (very-long-model-name)`,
exact: true,
}),
).toBeVisible();
});

View File

@ -220,7 +220,9 @@ test("custom agent automatic default does not become an account preference", asy
expect(patches).toEqual([]);
// Explicit model selections on this same page must still be synchronized.
await page.getByRole("button", { name: "Agent Model", exact: true }).click();
await page.getByRole("option").filter({ hasText: "First Model" }).click();
await page
.getByRole("button", { name: "First Model (first-model)", exact: true })
.click();
await expect.poll(() => patches).toEqual([{ model_name: "first-model" }]);
});

View File

@ -0,0 +1,446 @@
import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { useState } from "react";
import {
ModelPicker,
ModelPickerContent,
ModelPickerTrigger,
} from "@/components/workspace/model-picker-content";
import { useAuth } from "@/core/auth/AuthProvider";
import { type Model } from "@/core/models/types";
import { useModelFavorites } from "@/core/models/use-model-favorites";
rs.mock("@/core/auth/AuthProvider", () => ({
useAuth: rs.fn(),
}));
rs.mock("@/core/models/use-model-favorites", () => ({
useModelFavorites: rs.fn(),
}));
rs.mock("@/core/i18n/hooks", () => ({
useI18n: () => ({
locale: "en-US",
changeLocale: rs.fn(),
t: {
modelPicker: {
title: "Choose a model",
favorites: "Favorites",
otherModels: "Other models",
noModels: "No models available",
favoriteModel: (displayName: string, name: string) =>
`Favorite ${displayName} (${name})`,
sessionOnly: "Favorites are stored for this session only.",
},
},
}),
}));
const MODELS: readonly Model[] = [
{
id: "one",
name: "provider/alpha",
model: "alpha-api",
display_name: "Shared label",
description: "Fast general model",
},
{
id: "two",
name: ' provider/"beta" ',
model: "beta-api",
display_name: "Shared label",
description: "Careful reasoning model",
},
{
id: "three",
name: "provider/gamma",
model: "gamma-api",
display_name: "Gamma",
description: null,
},
];
const mockedUseAuth = rs.mocked(useAuth);
const mockedUseModelFavorites = rs.mocked(useModelFavorites);
const setFavorite = rs.fn();
let authUser: { id: string } | null;
let authLoading: boolean;
let favoriteNames: readonly string[];
let persistence: "local" | "memory";
function installHookState() {
mockedUseAuth.mockImplementation(
() =>
({
user: authUser,
isAuthenticated: authUser !== null,
isLoading: authLoading,
logout: rs.fn(),
refreshUser: rs.fn(),
applyUser: rs.fn(),
}) as ReturnType<typeof useAuth>,
);
mockedUseModelFavorites.mockImplementation((userId) => ({
names: userId === null ? [] : favoriteNames,
persistence: userId === null ? "memory" : persistence,
canEdit: userId !== null,
setFavorite,
}));
}
interface PickerHarnessProps {
models?: readonly Model[];
selectedModelName?: string;
onModelSelect?: (name: string) => void;
initiallyOpen?: boolean;
}
function StatefulPicker({
models = MODELS,
selectedModelName = MODELS[0]?.name,
onModelSelect = () => undefined,
initiallyOpen = true,
}: PickerHarnessProps) {
const [open, setOpen] = useState(initiallyOpen);
return (
<ModelPicker open={open} onOpenChange={setOpen}>
<ModelPickerTrigger asChild>
<button type="button">Current model</button>
</ModelPickerTrigger>
<ModelPickerContent
open={open}
models={models}
selectedModelName={selectedModelName}
onModelSelect={onModelSelect}
/>
</ModelPicker>
);
}
function ControlledPicker({
open,
models = MODELS,
selectedModelName = MODELS[0]?.name,
onModelSelect = () => undefined,
}: PickerHarnessProps & { open: boolean }) {
return (
<ModelPicker open={open}>
<ModelPickerTrigger asChild>
<button type="button">Current model</button>
</ModelPickerTrigger>
<ModelPickerContent
open={open}
models={models}
selectedModelName={selectedModelName}
onModelSelect={onModelSelect}
/>
</ModelPicker>
);
}
function modelButtons() {
return Array.from(
document.querySelectorAll<HTMLButtonElement>(
'button[data-model-picker-option="true"]',
),
);
}
function favoriteButton(model: Model) {
return screen.getByRole("button", {
name: `Favorite ${model.display_name} (${model.name})`,
});
}
beforeEach(() => {
authUser = { id: "alice" };
authLoading = false;
favoriteNames = [MODELS[1]!.name];
persistence = "local";
setFavorite.mockReset();
installHookState();
});
afterEach(() => {
cleanup();
rs.restoreAllMocks();
});
describe("ModelPickerContent anchored selection", () => {
it("opens without a modal overlay and exposes inline favorite actions", async () => {
render(<StatefulPicker />);
const dialog = await screen.findByRole("dialog", {
name: "Choose a model",
});
expect(document.querySelector('[data-slot="dialog-overlay"]')).toBeNull();
expect(dialog.className).toContain("w-72");
expect(screen.queryByRole("searchbox")).toBeNull();
expect(screen.queryByText("Fast general model")).toBeNull();
expect(favoriteButton(MODELS[0]!)).not.toBeNull();
expect(
screen.queryByRole("button", { name: "Manage favorites" }),
).toBeNull();
expect(screen.queryByRole("button", { name: "Done" })).toBeNull();
});
it("renders favorites first and preserves API order inside each group", async () => {
render(<StatefulPicker />);
const favorites = await screen.findByRole("group", { name: "Favorites" });
const others = screen.getByRole("group", { name: "Other models" });
expect(
within(favorites).getByText("beta-api").closest("li"),
).not.toBeNull();
expect(
within(others)
.getAllByRole("listitem")
.map((row) => row.textContent),
).toEqual([
expect.stringContaining("alpha-api"),
expect.stringContaining("gamma-api"),
]);
});
it("omits the empty favorites heading without filtering the model list", async () => {
favoriteNames = [];
render(<StatefulPicker />);
await screen.findByRole("dialog");
expect(screen.queryByText("Favorites")).toBeNull();
expect(screen.getByText("Other models")).not.toBeNull();
expect(modelButtons()).toHaveLength(MODELS.length);
});
it("shows the no-model state", async () => {
render(<ControlledPicker open models={[]} />);
expect(await screen.findByText("No models available")).not.toBeNull();
});
it("selects duplicate-label models by their untouched names", async () => {
const onModelSelect = rs.fn();
render(<StatefulPicker onModelSelect={onModelSelect} />);
await screen.findByRole("dialog");
const shared = modelButtons().filter((button) =>
button.textContent?.includes("Shared label"),
);
fireEvent.click(shared[0]!);
fireEvent.click(shared[1]!);
expect(onModelSelect.mock.calls).toEqual([
[MODELS[1]!.name],
[MODELS[0]!.name],
]);
});
it("marks the current model without nesting the favorite button", async () => {
render(<StatefulPicker selectedModelName={MODELS[1]!.name} />);
const current = await screen.findByRole("button", {
name: `Shared label (${MODELS[1]!.name})`,
});
expect(current.getAttribute("aria-current")).toBe("true");
expect(current.getAttribute("data-current-model")).toBe("true");
expect(within(current).queryByRole("button")).toBeNull();
expect(current.parentElement?.contains(favoriteButton(MODELS[1]!))).toBe(
true,
);
});
it("focuses the current model and moves between rows with arrows", async () => {
render(<StatefulPicker />);
const current = await screen.findByRole("button", {
name: `Shared label (${MODELS[0]!.name})`,
});
const gamma = screen.getByRole("button", {
name: `Gamma (${MODELS[2]!.name})`,
});
await waitFor(() => expect(document.activeElement).toBe(current));
fireEvent.keyDown(current, { key: "ArrowDown" });
expect(document.activeElement).toBe(gamma);
fireEvent.keyDown(gamma, { key: "ArrowUp" });
expect(document.activeElement).toBe(current);
});
it("moves from favorite stars to adjacent model rows with arrows", async () => {
render(<StatefulPicker />);
const betaStar = await screen.findByRole("button", {
name: `Favorite ${MODELS[1]!.display_name} (${MODELS[1]!.name})`,
});
const alpha = screen.getByRole("button", {
name: `Shared label (${MODELS[0]!.name})`,
});
const gamma = screen.getByRole("button", {
name: `Gamma (${MODELS[2]!.name})`,
});
betaStar.focus();
fireEvent.keyDown(betaStar, { key: "ArrowDown" });
expect(document.activeElement).toBe(alpha);
betaStar.focus();
fireEvent.keyDown(betaStar, { key: "ArrowUp" });
expect(document.activeElement).toBe(gamma);
});
});
describe("ModelPickerContent favorite actions", () => {
it("updates a favorite without selecting a model or closing the picker", async () => {
const onModelSelect = rs.fn();
render(<StatefulPicker onModelSelect={onModelSelect} />);
const betaStar = await screen.findByRole("button", {
name: `Favorite ${MODELS[1]!.display_name} (${MODELS[1]!.name})`,
});
expect(betaStar.getAttribute("aria-pressed")).toBe("true");
expect(betaStar.className).toContain("size-8");
fireEvent.click(betaStar);
expect(setFavorite).toHaveBeenCalledWith(MODELS[1]!.name, false);
expect(onModelSelect).not.toHaveBeenCalled();
expect(screen.getByRole("dialog")).not.toBeNull();
});
it("restores focus to the same star after favorite regrouping", async () => {
favoriteNames = [];
const { rerender } = render(<ControlledPicker open />);
const betaStar = await screen.findByRole("button", {
name: `Favorite ${MODELS[1]!.display_name} (${MODELS[1]!.name})`,
});
betaStar.focus();
fireEvent.click(betaStar);
favoriteNames = [MODELS[1]!.name];
rerender(<ControlledPicker open />);
await waitFor(() =>
expect(document.activeElement).toBe(favoriteButton(MODELS[1]!)),
);
});
it("preserves the user's focus during an external favorite regroup", async () => {
favoriteNames = [];
const { rerender } = render(
<ControlledPicker open selectedModelName={MODELS[0]!.name} />,
);
const beta = await screen.findByRole("button", {
name: `Shared label (${MODELS[1]!.name})`,
});
beta.focus();
expect(document.activeElement).toBe(beta);
favoriteNames = [MODELS[1]!.name];
rerender(<ControlledPicker open selectedModelName={MODELS[0]!.name} />);
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole("button", {
name: `Shared label (${MODELS[1]!.name})`,
}),
),
);
favoriteButton(MODELS[1]!).focus();
favoriteNames = [];
rerender(<ControlledPicker open selectedModelName={MODELS[0]!.name} />);
await waitFor(() =>
expect(document.activeElement).toBe(favoriteButton(MODELS[1]!)),
);
});
it("hides stars when signed out and disables them during auth refresh", async () => {
authUser = null;
const { rerender } = render(<ControlledPicker open />);
await screen.findByRole("dialog");
expect(
screen.queryByRole("button", { name: /Favorite Shared label/ }),
).toBeNull();
authUser = { id: "alice" };
authLoading = true;
rerender(<ControlledPicker open />);
expect(favoriteButton(MODELS[0]!).hasAttribute("disabled")).toBe(true);
});
it("revalidates loading and a same-reference catalog in the latest handler", async () => {
const mutableModels = [...MODELS];
const { rerender } = render(
<ControlledPicker open models={mutableModels} />,
);
const alphaStar = await screen.findByRole("button", {
name: `Favorite ${MODELS[0]!.display_name} (${MODELS[0]!.name})`,
});
authLoading = true;
rerender(<ControlledPicker open models={mutableModels} />);
alphaStar.removeAttribute("disabled");
fireEvent.click(alphaStar);
expect(setFavorite).not.toHaveBeenCalled();
authLoading = false;
rerender(<ControlledPicker open models={mutableModels} />);
const connectedStar = favoriteButton(MODELS[0]!);
mutableModels.splice(0, 1);
fireEvent.click(connectedStar);
expect(setFavorite).not.toHaveBeenCalled();
});
it("only announces persistence when storage falls back to memory", async () => {
const { rerender } = render(<ControlledPicker open />);
await screen.findByRole("dialog");
expect(screen.queryByRole("status")).toBeNull();
persistence = "memory";
rerender(<ControlledPicker open />);
expect(screen.getByRole("status").textContent).toBe(
"Favorites are stored for this session only.",
);
});
});
describe("ModelPickerContent popover lifecycle", () => {
it("focuses the current model on every closed-to-open edge", async () => {
const { rerender } = render(
<ControlledPicker open selectedModelName={MODELS[2]!.name} />,
);
const current = await screen.findByRole("button", {
name: `Gamma (${MODELS[2]!.name})`,
});
await waitFor(() => expect(document.activeElement).toBe(current));
rerender(
<ControlledPicker open={false} selectedModelName={MODELS[2]!.name} />,
);
screen.getByRole("button", { name: "Current model" }).focus();
rerender(<ControlledPicker open selectedModelName={MODELS[2]!.name} />);
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole("button", {
name: `Gamma (${MODELS[2]!.name})`,
}),
),
);
});
it("closes on Escape and returns focus to the trigger", async () => {
render(<StatefulPicker />);
await screen.findByRole("dialog");
fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(document.activeElement).toBe(
screen.getByRole("button", { name: "Current model" }),
);
});
});

View File

@ -5,7 +5,7 @@ import { describe, expect, it } from "@rstest/core";
const FRONTEND_ROOT = path.resolve(__dirname, "../../../..");
const SELECTED_MODEL_WRAPPER_PATTERN =
/<ModelSelectorTrigger asChild>[\s\S]*?<div className="([^"]*)">\s*<ModelSelectorName/;
/<ModelPickerTrigger asChild>[\s\S]*?<div className="([^"]*)">\s*<span className="flex-1 truncate text-left text-xs font-normal">/;
function source(relativePath: string) {
return readFileSync(path.join(FRONTEND_ROOT, relativePath), "utf8");
@ -21,7 +21,7 @@ describe("selected model name truncation", () => {
it.each([
"src/components/workspace/input-box.tsx",
"src/components/workspace/sidecar/sidecar-panel.tsx",
])("lets ModelSelectorName stretch in %s", (relativePath) => {
])("lets the selected model name stretch in %s", (relativePath) => {
const classes = selectedModelWrapperClasses(relativePath);
expect(classes).toEqual(
@ -30,3 +30,51 @@ describe("selected model name truncation", () => {
expect(classes).not.toContain("items-start");
});
});
describe("model picker integration", () => {
it.each([
{
relativePath: "src/components/workspace/input-box.tsx",
open: "modelDialogOpen",
selectedModelName: "selectedModel?.name",
onModelSelect: "handleModelSelect",
},
{
relativePath: "src/components/workspace/sidecar/sidecar-panel.tsx",
open: "open",
selectedModelName: "selectedModel.name",
onModelSelect: "onModelSelect",
},
])(
"uses ModelPickerContent inside the anchored picker in $relativePath",
({ relativePath, open, selectedModelName, onModelSelect }) => {
const contents = source(relativePath);
const picker = /<ModelPickerContent[\s\S]*?\/>/.exec(contents)?.[0];
expect(contents).toMatch(/<ModelPicker\s/);
expect(contents).toContain("<ModelPickerTrigger asChild>");
expect(picker).toBeDefined();
expect(picker).toMatch(
new RegExp(`open=\\{${open.replace("?", "\\?")}\\}`),
);
expect(picker).toMatch(/models=\{models\}/);
expect(picker).toMatch(
new RegExp(
`selectedModelName=\\{${selectedModelName.replace("?", "\\?")}\\}`,
),
);
expect(picker).toMatch(
new RegExp(`onModelSelect=\\{${onModelSelect}\\}`),
);
for (const legacyComponent of [
"ModelSelectorName",
"ModelSelectorContent",
"ModelSelectorInput",
"ModelSelectorList",
"ModelSelectorItem",
]) {
expect(contents).not.toContain(`<${legacyComponent}`);
}
},
);
});

View File

@ -0,0 +1,383 @@
import {
afterEach,
beforeEach,
describe,
expect,
test,
rs,
} from "@rstest/core";
import { act, cleanup, renderHook } from "@testing-library/react";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { favoritesKey, serializeFavoriteNames } from "@/core/models/favorites";
import type * as FavoritesStoreModule from "@/core/models/favorites-store";
import type * as FavoritesHookModule from "@/core/models/use-model-favorites";
let store: typeof FavoritesStoreModule;
let hooks: typeof FavoritesHookModule;
function dispatchStorage(
key: string | null,
{
newValue = null,
storageArea = window.localStorage,
}: { newValue?: string | null; storageArea?: Storage | null } = {},
) {
const event = new Event("storage");
Object.defineProperties(event, {
key: { value: key },
newValue: { value: newValue },
storageArea: { value: storageArea },
});
window.dispatchEvent(event);
}
beforeEach(async () => {
cleanup();
rs.restoreAllMocks();
window.localStorage.clear();
window.sessionStorage.clear();
rs.resetModules();
store = await import("@/core/models/favorites-store");
hooks = await import("@/core/models/use-model-favorites");
});
afterEach(() => {
cleanup();
rs.restoreAllMocks();
window.localStorage.clear();
window.sessionStorage.clear();
});
describe("model favorites snapshots", () => {
test("keeps a stable empty snapshot for signed-out users without storage access", () => {
const getItem = rs.spyOn(window.localStorage, "getItem");
const setItem = rs.spyOn(window.localStorage, "setItem");
const first = store.getFavoritesSnapshot(null);
const second = store.getFavoritesSnapshot(null);
store.setModelFavorite(null, "openai/gpt-5", true);
expect(first).toBe(store.EMPTY_FAVORITES);
expect(second).toBe(first);
expect(first).toEqual({ names: [], persistence: "memory" });
expect(getItem).not.toHaveBeenCalled();
expect(setItem).not.toHaveBeenCalled();
});
test("lazily loads each user and retains the snapshot reference until data changes", () => {
window.localStorage.setItem(
favoritesKey("alice"),
serializeFavoriteNames(["openai/gpt-5"]),
);
const getItem = rs.spyOn(window.localStorage, "getItem");
const first = store.getFavoritesSnapshot("alice");
const second = store.getFavoritesSnapshot("alice");
expect(first).toEqual({
names: ["openai/gpt-5"],
persistence: "local",
});
expect(second).toBe(first);
expect(getItem).toHaveBeenCalledTimes(1);
});
test("treats malformed persisted JSON as an empty local snapshot", () => {
window.localStorage.setItem(favoritesKey("alice"), "not-json");
expect(store.getFavoritesSnapshot("alice")).toEqual({
names: [],
persistence: "local",
});
store.setModelFavorite("alice", "openai/gpt-5", true);
expect(window.localStorage.getItem(favoritesKey("alice"))).toBe(
serializeFavoriteNames(["openai/gpt-5"]),
);
});
test("distinguishes an initial read exception from a genuinely missing value", () => {
const getItem = rs
.spyOn(window.localStorage, "getItem")
.mockImplementation(() => {
throw new DOMException("blocked", "SecurityError");
});
expect(store.getFavoritesSnapshot("alice")).toEqual({
names: [],
persistence: "local",
});
getItem.mockRestore();
const setItem = rs.spyOn(window.localStorage, "setItem");
store.setModelFavorite("alice", "openai/gpt-5", true);
expect(setItem).toHaveBeenCalledWith(
favoritesKey("alice"),
serializeFavoriteNames(["openai/gpt-5"]),
);
expect(store.getFavoritesSnapshot("alice").persistence).toBe("local");
});
test("does not write or notify for idempotent updates", () => {
const listener = rs.fn();
const unsubscribe = store.subscribeFavorites("alice", listener);
const setItem = rs.spyOn(window.localStorage, "setItem");
const initial = store.getFavoritesSnapshot("alice");
store.setModelFavorite("alice", "openai/gpt-5", false);
store.setModelFavorite("alice", " ", true);
expect(store.getFavoritesSnapshot("alice")).toBe(initial);
expect(setItem).not.toHaveBeenCalled();
expect(listener).not.toHaveBeenCalled();
unsubscribe();
});
test("uses the latest in-memory value for consecutive changes", () => {
store.setModelFavorite("alice", "openai/gpt-5", true);
store.setModelFavorite("alice", "anthropic/claude", true);
store.setModelFavorite("alice", "openai/gpt-5", false);
expect(store.getFavoritesSnapshot("alice").names).toEqual([
"anthropic/claude",
]);
expect(window.localStorage.getItem(favoritesKey("alice"))).toBe(
serializeFavoriteNames(["anthropic/claude"]),
);
});
});
describe("useModelFavorites", () => {
test("synchronizes two mounted hooks immediately", () => {
const first = renderHook(() => hooks.useModelFavorites("alice"));
const second = renderHook(() => hooks.useModelFavorites("alice"));
act(() => first.result.current.setFavorite("openai/gpt-5", true));
expect(first.result.current.names).toEqual(["openai/gpt-5"]);
expect(second.result.current.names).toEqual(["openai/gpt-5"]);
expect(first.result.current.persistence).toBe("local");
expect(first.result.current.canEdit).toBe(true);
});
test("switches A to signed-out to B without flashing A and disables edits", () => {
window.localStorage.setItem(
favoritesKey("alice"),
serializeFavoriteNames(["alice/model"]),
);
window.localStorage.setItem(
favoritesKey("bob"),
serializeFavoriteNames(["bob/model"]),
);
const { result, rerender } = renderHook(
({ userId }: { userId: string | null }) =>
hooks.useModelFavorites(userId),
{ initialProps: { userId: "alice" as string | null } },
);
expect(result.current.names).toEqual(["alice/model"]);
rerender({ userId: null });
expect(result.current.names).toEqual([]);
expect(result.current.canEdit).toBe(false);
const setItem = rs.spyOn(window.localStorage, "setItem");
setItem.mockClear();
act(() => result.current.setFavorite("ignored/model", true));
expect(setItem).not.toHaveBeenCalled();
expect(result.current.names).toEqual([]);
rerender({ userId: "bob" });
expect(result.current.names).toEqual(["bob/model"]);
});
test("uses the fixed empty server snapshot during SSR", () => {
window.localStorage.setItem(
favoritesKey("alice"),
serializeFavoriteNames(["browser/model"]),
);
function Probe() {
const favorites = hooks.useModelFavorites("alice");
return createElement("span", null, favorites.names.join(","));
}
expect(() => renderToStaticMarkup(createElement(Probe))).not.toThrow();
expect(renderToStaticMarkup(createElement(Probe))).toBe("<span></span>");
});
});
describe("failed persistence", () => {
test("sticks to memory after a failed write and ignores later external events", () => {
const setItem = rs
.spyOn(window.localStorage, "setItem")
.mockImplementation(() => {
throw new DOMException("quota", "QuotaExceededError");
});
const { result } = renderHook(() => hooks.useModelFavorites("alice"));
act(() => result.current.setFavorite("openai/gpt-5", true));
expect(result.current).toMatchObject({
names: ["openai/gpt-5"],
persistence: "memory",
canEdit: true,
});
expect(setItem).toHaveBeenCalledTimes(1);
act(() => result.current.setFavorite("anthropic/claude", true));
expect(result.current.names).toEqual(["openai/gpt-5", "anthropic/claude"]);
expect(setItem).toHaveBeenCalledTimes(1);
setItem.mockRestore();
window.localStorage.setItem(
favoritesKey("alice"),
serializeFavoriteNames(["external/model"]),
);
act(() => dispatchStorage(favoritesKey("alice")));
expect(result.current.names).toEqual(["openai/gpt-5", "anthropic/claude"]);
});
});
describe("storage event synchronization", () => {
test("reloads the current local value for its user and ignores another user", () => {
const { result } = renderHook(() => hooks.useModelFavorites("alice"));
window.localStorage.setItem(
favoritesKey("bob"),
serializeFavoriteNames(["bob/model"]),
);
act(() => dispatchStorage(favoritesKey("bob")));
expect(result.current.names).toEqual([]);
window.localStorage.setItem(
favoritesKey("alice"),
serializeFavoriteNames(["alice/model"]),
);
act(() => dispatchStorage(favoritesKey("alice")));
expect(result.current.names).toEqual(["alice/model"]);
});
test("uses current storage instead of delayed event.newValue", () => {
const { result } = renderHook(() => hooks.useModelFavorites("alice"));
window.localStorage.setItem(
favoritesKey("alice"),
serializeFavoriteNames(["current/model"]),
);
act(() =>
dispatchStorage(favoritesKey("alice"), {
newValue: serializeFavoriteNames(["stale/model"]),
}),
);
expect(result.current.names).toEqual(["current/model"]);
});
test("accepts a real deletion and clear for all cached local users", () => {
window.localStorage.setItem(
favoritesKey("alice"),
serializeFavoriteNames(["alice/model"]),
);
window.localStorage.setItem(
favoritesKey("bob"),
serializeFavoriteNames(["bob/model"]),
);
const alice = renderHook(() => hooks.useModelFavorites("alice"));
const bob = renderHook(() => hooks.useModelFavorites("bob"));
window.localStorage.removeItem(favoritesKey("alice"));
act(() => dispatchStorage(favoritesKey("alice")));
expect(alice.result.current.names).toEqual([]);
expect(bob.result.current.names).toEqual(["bob/model"]);
window.localStorage.clear();
act(() => dispatchStorage(null));
expect(alice.result.current.names).toEqual([]);
expect(bob.result.current.names).toEqual([]);
});
test("ignores sessionStorage and a throwing storageArea getter", () => {
const { result } = renderHook(() => hooks.useModelFavorites("alice"));
window.localStorage.setItem(
favoritesKey("alice"),
serializeFavoriteNames(["external/model"]),
);
act(() =>
dispatchStorage(favoritesKey("alice"), {
storageArea: window.sessionStorage,
}),
);
expect(result.current.names).toEqual([]);
const event = new Event("storage");
Object.defineProperties(event, {
key: { value: favoritesKey("alice") },
storageArea: {
get() {
throw new DOMException("blocked", "SecurityError");
},
},
});
act(() => {
window.dispatchEvent(event);
});
expect(result.current.names).toEqual([]);
});
test("preserves the snapshot when an event-triggered read throws", () => {
window.localStorage.setItem(
favoritesKey("alice"),
serializeFavoriteNames(["initial/model"]),
);
const { result } = renderHook(() => hooks.useModelFavorites("alice"));
rs.spyOn(window.localStorage, "getItem").mockImplementation(() => {
throw new DOMException("blocked", "SecurityError");
});
act(() => dispatchStorage(favoritesKey("alice")));
expect(result.current.names).toEqual(["initial/model"]);
});
test("recalibrates a local entry when it is subscribed again", () => {
const first = renderHook(() => hooks.useModelFavorites("alice"));
expect(first.result.current.names).toEqual([]);
first.unmount();
window.localStorage.setItem(
favoritesKey("alice"),
serializeFavoriteNames(["while-unsubscribed/model"]),
);
const second = renderHook(() => hooks.useModelFavorites("alice"));
expect(second.result.current.names).toEqual(["while-unsubscribed/model"]);
});
test("registers the window listener only for non-null subscribers and cleans it up", () => {
const add = rs.spyOn(window, "addEventListener");
const remove = rs.spyOn(window, "removeEventListener");
const signedOut = renderHook(() => hooks.useModelFavorites(null));
expect(add.mock.calls.filter(([type]) => type === "storage")).toHaveLength(
0,
);
const alice = renderHook(() => hooks.useModelFavorites("alice"));
const bob = renderHook(() => hooks.useModelFavorites("bob"));
expect(add.mock.calls.filter(([type]) => type === "storage")).toHaveLength(
1,
);
alice.unmount();
expect(
remove.mock.calls.filter(([type]) => type === "storage"),
).toHaveLength(0);
bob.unmount();
expect(
remove.mock.calls.filter(([type]) => type === "storage"),
).toHaveLength(1);
signedOut.unmount();
});
});

View File

@ -0,0 +1,177 @@
import { describe, expect, test } from "@rstest/core";
import {
favoritesKey,
parseFavoriteNames,
projectModelChoices,
serializeFavoriteNames,
updateFavoriteNames,
} from "@/core/models/favorites";
import { type Model } from "@/core/models/types";
const models: Model[] = [
{
id: "model-1",
name: "openai/gpt-5",
model: "gpt-5-2025-08-07",
display_name: "GPT 5",
},
{
id: "model-2",
name: "azure/gpt-5",
model: "azure-gpt-5",
display_name: "GPT 5",
},
{
id: "model-3",
name: "anthropic/claude-sonnet",
model: "claude-sonnet-4-5-20250929",
display_name: "Claude Sonnet",
},
];
describe("favorite model persistence", () => {
test("encodes the user id in the versioned storage key", () => {
expect(favoritesKey("person+a/b@example.com")).toBe(
"deerflow.model-favorites.v1:person%2Ba%2Fb%40example.com",
);
});
test("round-trips favorite names without rewriting valid values", () => {
const names = ["openai/gpt-5", " spaced model "];
expect(parseFavoriteNames(serializeFavoriteNames(names))).toEqual(names);
});
test("parses only version 1 payloads and filters invalid or duplicate names", () => {
expect(
parseFavoriteNames(
JSON.stringify({
version: 1,
names: [
"openai/gpt-5",
42,
"",
" ",
"openai/gpt-5",
" openai/gpt-5 ",
],
}),
),
).toEqual(["openai/gpt-5", " openai/gpt-5 "]);
expect(
parseFavoriteNames(
JSON.stringify({ version: 2, names: ["openai/gpt-5"] }),
),
).toEqual([]);
});
test("returns an empty list for malformed JSON and invalid structures", () => {
expect(parseFavoriteNames(null)).toEqual([]);
expect(parseFavoriteNames("not json")).toEqual([]);
expect(parseFavoriteNames(JSON.stringify(null))).toEqual([]);
expect(parseFavoriteNames(JSON.stringify(["openai/gpt-5"]))).toEqual([]);
expect(
parseFavoriteNames(JSON.stringify({ version: 1, names: "gpt-5" })),
).toEqual([]);
expect(
parseFavoriteNames(JSON.stringify({ version: "1", names: [] })),
).toEqual([]);
});
});
describe("updating favorite model names", () => {
test("adds a new favorite at the end without changing the input", () => {
const names = ["openai/gpt-5"];
expect(updateFavoriteNames(names, "azure/gpt-5", true)).toEqual([
"openai/gpt-5",
"azure/gpt-5",
]);
expect(names).toEqual(["openai/gpt-5"]);
});
test("removes a favorite while preserving the remaining order", () => {
const names = ["openai/gpt-5", "azure/gpt-5", "anthropic/claude-sonnet"];
expect(updateFavoriteNames(names, "azure/gpt-5", false)).toEqual([
"openai/gpt-5",
"anthropic/claude-sonnet",
]);
});
test("returns the original array for blank names and idempotent updates", () => {
const names = ["openai/gpt-5"];
expect(updateFavoriteNames(names, " ", true)).toBe(names);
expect(updateFavoriteNames(names, "openai/gpt-5", true)).toBe(names);
expect(updateFavoriteNames(names, "azure/gpt-5", false)).toBe(names);
});
test("accepts readonly favorite names", () => {
const names = ["openai/gpt-5"] as const;
expect(serializeFavoriteNames(names)).toBe(
'{"version":1,"names":["openai/gpt-5"]}',
);
expect(updateFavoriteNames(names, "azure/gpt-5", true)).toEqual([
"openai/gpt-5",
"azure/gpt-5",
]);
});
});
describe("projecting model choices", () => {
test("keeps API order for empty, partial, and complete favorite sets", () => {
expect(projectModelChoices(models, [])).toEqual({
favorites: [],
others: models,
});
expect(
projectModelChoices(models, ["anthropic/claude-sonnet", "openai/gpt-5"]),
).toEqual({
favorites: [models[0], models[2]],
others: [models[1]],
});
expect(
projectModelChoices(
models,
models.map((model) => model.name),
),
).toEqual({
favorites: models,
others: [],
});
});
test("uses model name rather than a shared display name as favorite identity", () => {
const result = projectModelChoices(models, ["azure/gpt-5"]);
expect(result.favorites).toEqual([models[1]]);
expect(result.others).toEqual([models[0], models[2]]);
});
test("restores temporarily unavailable favorites without modifying inputs", () => {
const modelSnapshot = structuredClone(models);
const favoriteNames = ["missing/model", "anthropic/claude-sonnet"];
const favoriteSnapshot = [...favoriteNames];
const hidden = projectModelChoices(models.slice(0, 2), favoriteNames);
const visibleAgain = projectModelChoices(models, favoriteNames);
expect(hidden.favorites).toEqual([]);
expect(hidden.others).toEqual(models.slice(0, 2));
expect(visibleAgain.favorites).toEqual([models[2]]);
expect(models).toEqual(modelSnapshot);
expect(favoriteNames).toEqual(favoriteSnapshot);
});
test("accepts readonly model and favorite inputs", () => {
const readonlyModels = [models[0]!, models[1]!] as const;
const readonlyFavorites = ["azure/gpt-5"] as const;
expect(
projectModelChoices(readonlyModels, readonlyFavorites).favorites,
).toEqual([models[1]]);
});
});