From 6ca12c6c8f143f22b78ab360b441848f4fb6e85b Mon Sep 17 00:00:00 2001 From: Ryker_Feng <90562015+RykerFeng@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:42:13 +0800 Subject: [PATCH] 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 --- README.md | 8 + frontend/package.json | 1 + frontend/pnpm-lock.yaml | 39 ++ frontend/src/AGENTS.md | 12 + .../components/ai-elements/model-selector.tsx | 208 -------- .../src/components/workspace/input-box.tsx | 56 +-- .../workspace/model-picker-content.tsx | 292 ++++++++++++ .../workspace/sidecar/sidecar-panel.tsx | 61 +-- frontend/src/core/i18n/locales/en-US.ts | 10 +- frontend/src/core/i18n/locales/types.ts | 10 +- frontend/src/core/i18n/locales/zh-CN.ts | 10 +- frontend/src/core/models/favorites-store.ts | 219 +++++++++ frontend/src/core/models/favorites.ts | 85 ++++ .../src/core/models/use-model-favorites.ts | 37 ++ frontend/tests/e2e/model-favorites.spec.ts | 349 ++++++++++++++ .../tests/e2e/settings-model-sync.spec.ts | 4 +- .../model-picker-content.dom.test.tsx | 446 ++++++++++++++++++ .../workspace/model-selector-overflow.test.ts | 52 +- .../core/models/favorites-store.dom.test.tsx | 383 +++++++++++++++ .../tests/unit/core/models/favorites.test.ts | 177 +++++++ 20 files changed, 2162 insertions(+), 297 deletions(-) delete mode 100644 frontend/src/components/ai-elements/model-selector.tsx create mode 100644 frontend/src/components/workspace/model-picker-content.tsx create mode 100644 frontend/src/core/models/favorites-store.ts create mode 100644 frontend/src/core/models/favorites.ts create mode 100644 frontend/src/core/models/use-model-favorites.ts create mode 100644 frontend/tests/e2e/model-favorites.spec.ts create mode 100644 frontend/tests/unit/components/workspace/model-picker-content.dom.test.tsx create mode 100644 frontend/tests/unit/core/models/favorites-store.dom.test.tsx create mode 100644 frontend/tests/unit/core/models/favorites.test.ts diff --git a/README.md b/README.md index bf6cfa201..e93eb624e 100644 --- a/README.md +++ b/README.md @@ -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. +
Manual model configuration examples diff --git a/frontend/package.json b/frontend/package.json index 1e3562195..626efa6ce 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 929b79d7e..196d7b3ad 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -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) diff --git a/frontend/src/AGENTS.md b/frontend/src/AGENTS.md index 2fd0a5067..df5bddd2e 100644 --- a/frontend/src/AGENTS.md +++ b/frontend/src/AGENTS.md @@ -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. diff --git a/frontend/src/components/ai-elements/model-selector.tsx b/frontend/src/components/ai-elements/model-selector.tsx deleted file mode 100644 index dc8682bba..000000000 --- a/frontend/src/components/ai-elements/model-selector.tsx +++ /dev/null @@ -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; - -export const ModelSelector = (props: ModelSelectorProps) => ( - -); - -export type ModelSelectorTriggerProps = ComponentProps; - -export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => ( - -); - -export type ModelSelectorContentProps = ComponentProps & { - title?: ReactNode; -}; - -export const ModelSelectorContent = ({ - className, - children, - title = "Model Selector", - ...props -}: ModelSelectorContentProps) => ( - - {title} - - {children} - - -); - -export type ModelSelectorDialogProps = ComponentProps; - -export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => ( - -); - -export type ModelSelectorInputProps = ComponentProps; - -export const ModelSelectorInput = ({ - className, - ...props -}: ModelSelectorInputProps) => ( - -); - -export type ModelSelectorListProps = ComponentProps; - -export const ModelSelectorList = (props: ModelSelectorListProps) => ( - -); - -export type ModelSelectorEmptyProps = ComponentProps; - -export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => ( - -); - -export type ModelSelectorGroupProps = ComponentProps; - -export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => ( - -); - -export type ModelSelectorItemProps = ComponentProps; - -export const ModelSelectorItem = (props: ModelSelectorItemProps) => ( - -); - -export type ModelSelectorShortcutProps = ComponentProps; - -export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => ( - -); - -export type ModelSelectorSeparatorProps = ComponentProps< - typeof CommandSeparator ->; - -export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => ( - -); - -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) => ( - {`${provider} -); - -export type ModelSelectorLogoGroupProps = ComponentProps<"div">; - -export const ModelSelectorLogoGroup = ({ - className, - ...props -}: ModelSelectorLogoGroupProps) => ( -
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) => ( - -); diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index 005abba8a..9ea366205 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -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} )} - - +
- + {selectedModel?.display_name} - +
-
- - - - {models.map((m) => ( - handleModelSelect(m.name)} - > -
- {m.display_name} - - {m.model} - -
- {m.name === context.model_name ? ( - - ) : ( -
- )} - - ))} - - - + + + 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 ( + + {model.display_name} + + {model.model} + + + ); +} + +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()); + const favoriteButtonRefs = useRef(new Map()); + const pendingFavoriteFocusRef = useRef(null); + const lastFocusedControlRef = useRef(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, 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 ( +
+

+ {heading} +

+
    + {groupModels.map((model) => { + const isFavorite = favorites.names.includes(model.name); + const isCurrent = model.name === selectedModelName; + return ( +
  • + + {user !== null ? ( + + ) : null} +
  • + ); + })} +
+
+ ); + }; + + return ( + + { + event.preventDefault(); + focusInitialModel(); + }} + > +
+ {visibleModels.length === 0 ? ( +
+ {t.modelPicker.noModels} +
+ ) : ( + <> + {renderGroup(t.modelPicker.favorites, projection.favorites)} + {renderGroup(t.modelPicker.otherModels, projection.others)} + + )} +
+ {favorites.persistence === "memory" && user !== null ? ( +

+ {t.modelPicker.sessionOnly} +

+ ) : null} +
+
+ ); +} diff --git a/frontend/src/components/workspace/sidecar/sidecar-panel.tsx b/frontend/src/components/workspace/sidecar/sidecar-panel.tsx index 4dcb85068..e52509b27 100644 --- a/frontend/src/components/workspace/sidecar/sidecar-panel.tsx +++ b/frontend/src/components/workspace/sidecar/sidecar-panel.tsx @@ -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 }) { void; onOpenChange: (open: boolean) => void; }) { - const { t } = useI18n(); - if (!selectedModel) { return null; } return ( - - + +
- + {selectedModel.display_name} - +
-
- - - - {models.map((model) => ( - onModelSelect(model.name)} - > -
- {model.display_name} - - {model.model} - -
- {model.name === context.model_name ? ( - - ) : ( -
- )} - - ))} - - - + + + ); } diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index 56ce0338a..70ff8a3fa 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -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...", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index 00c1ae022..dfed18c3d 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -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; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index ffedbabce..8e4a273de 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -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: "正在生成可能的后续问题...", diff --git a/frontend/src/core/models/favorites-store.ts b/frontend/src/core/models/favorites-store.ts new file mode 100644 index 000000000..2b39ce6c0 --- /dev/null +++ b/frontend/src/core/models/favorites-store.ts @@ -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; +} + +type StorageRead = { ok: true; raw: string | null } | { ok: false }; + +const entries = new Map(); +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); +} diff --git a/frontend/src/core/models/favorites.ts b/frontend/src/core/models/favorites.ts new file mode 100644 index 000000000..c54e864bf --- /dev/null +++ b/frontend/src/core/models/favorites.ts @@ -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(); + 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)), + }; +} diff --git a/frontend/src/core/models/use-model-favorites.ts b/frontend/src/core/models/use-model-favorites.ts new file mode 100644 index 000000000..aa48b79b6 --- /dev/null +++ b/frontend/src/core/models/use-model-favorites.ts @@ -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, + }; +} diff --git a/frontend/tests/e2e/model-favorites.spec.ts b/frontend/tests/e2e/model-favorites.spec.ts new file mode 100644 index 000000000..046e85a70 --- /dev/null +++ b/frontend/tests/e2e/model-favorites.spec.ts @@ -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[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(); +}); diff --git a/frontend/tests/e2e/settings-model-sync.spec.ts b/frontend/tests/e2e/settings-model-sync.spec.ts index 4f415abec..a91dd0881 100644 --- a/frontend/tests/e2e/settings-model-sync.spec.ts +++ b/frontend/tests/e2e/settings-model-sync.spec.ts @@ -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" }]); }); diff --git a/frontend/tests/unit/components/workspace/model-picker-content.dom.test.tsx b/frontend/tests/unit/components/workspace/model-picker-content.dom.test.tsx new file mode 100644 index 000000000..3c1141696 --- /dev/null +++ b/frontend/tests/unit/components/workspace/model-picker-content.dom.test.tsx @@ -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, + ); + 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 ( + + + + + + + ); +} + +function ControlledPicker({ + open, + models = MODELS, + selectedModelName = MODELS[0]?.name, + onModelSelect = () => undefined, +}: PickerHarnessProps & { open: boolean }) { + return ( + + + + + + + ); +} + +function modelButtons() { + return Array.from( + document.querySelectorAll( + '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(); + + 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(); + + 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(); + + 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(); + expect(await screen.findByText("No models available")).not.toBeNull(); + }); + + it("selects duplicate-label models by their untouched names", async () => { + const onModelSelect = rs.fn(); + render(); + + 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(); + + 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(); + 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(); + 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(); + 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(); + 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(); + 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( + , + ); + const beta = await screen.findByRole("button", { + name: `Shared label (${MODELS[1]!.name})`, + }); + beta.focus(); + expect(document.activeElement).toBe(beta); + + favoriteNames = [MODELS[1]!.name]; + rerender(); + + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole("button", { + name: `Shared label (${MODELS[1]!.name})`, + }), + ), + ); + + favoriteButton(MODELS[1]!).focus(); + favoriteNames = []; + rerender(); + + 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(); + await screen.findByRole("dialog"); + expect( + screen.queryByRole("button", { name: /Favorite Shared label/ }), + ).toBeNull(); + + authUser = { id: "alice" }; + authLoading = true; + rerender(); + 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( + , + ); + const alphaStar = await screen.findByRole("button", { + name: `Favorite ${MODELS[0]!.display_name} (${MODELS[0]!.name})`, + }); + + authLoading = true; + rerender(); + alphaStar.removeAttribute("disabled"); + fireEvent.click(alphaStar); + expect(setFavorite).not.toHaveBeenCalled(); + + authLoading = false; + rerender(); + 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(); + await screen.findByRole("dialog"); + expect(screen.queryByRole("status")).toBeNull(); + + persistence = "memory"; + rerender(); + 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( + , + ); + const current = await screen.findByRole("button", { + name: `Gamma (${MODELS[2]!.name})`, + }); + await waitFor(() => expect(document.activeElement).toBe(current)); + + rerender( + , + ); + screen.getByRole("button", { name: "Current model" }).focus(); + rerender(); + + 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(); + 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" }), + ); + }); +}); diff --git a/frontend/tests/unit/components/workspace/model-selector-overflow.test.ts b/frontend/tests/unit/components/workspace/model-selector-overflow.test.ts index e8985c96c..80d745680 100644 --- a/frontend/tests/unit/components/workspace/model-selector-overflow.test.ts +++ b/frontend/tests/unit/components/workspace/model-selector-overflow.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from "@rstest/core"; const FRONTEND_ROOT = path.resolve(__dirname, "../../../.."); const SELECTED_MODEL_WRAPPER_PATTERN = - /[\s\S]*?
\s*[\s\S]*?
\s*/; 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 = //.exec(contents)?.[0]; + + expect(contents).toMatch(/"); + 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}`); + } + }, + ); +}); diff --git a/frontend/tests/unit/core/models/favorites-store.dom.test.tsx b/frontend/tests/unit/core/models/favorites-store.dom.test.tsx new file mode 100644 index 000000000..4e7b60bcf --- /dev/null +++ b/frontend/tests/unit/core/models/favorites-store.dom.test.tsx @@ -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(""); + }); +}); + +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(); + }); +}); diff --git a/frontend/tests/unit/core/models/favorites.test.ts b/frontend/tests/unit/core/models/favorites.test.ts new file mode 100644 index 000000000..9903c6afe --- /dev/null +++ b/frontend/tests/unit/core/models/favorites.test.ts @@ -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]]); + }); +});