mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-19 02:56:17 +00:00
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:
parent
a246c928e9
commit
6ca12c6c8f
@ -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>
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
39
frontend/pnpm-lock.yaml
generated
39
frontend/pnpm-lock.yaml
generated
@ -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)
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
@ -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}
|
||||
|
||||
292
frontend/src/components/workspace/model-picker-content.tsx
Normal file
292
frontend/src/components/workspace/model-picker-content.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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...",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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: "正在生成可能的后续问题...",
|
||||
|
||||
219
frontend/src/core/models/favorites-store.ts
Normal file
219
frontend/src/core/models/favorites-store.ts
Normal 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);
|
||||
}
|
||||
85
frontend/src/core/models/favorites.ts
Normal file
85
frontend/src/core/models/favorites.ts
Normal 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)),
|
||||
};
|
||||
}
|
||||
37
frontend/src/core/models/use-model-favorites.ts
Normal file
37
frontend/src/core/models/use-model-favorites.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
349
frontend/tests/e2e/model-favorites.spec.ts
Normal file
349
frontend/tests/e2e/model-favorites.spec.ts
Normal 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();
|
||||
});
|
||||
@ -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" }]);
|
||||
});
|
||||
|
||||
|
||||
@ -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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
383
frontend/tests/unit/core/models/favorites-store.dom.test.tsx
Normal file
383
frontend/tests/unit/core/models/favorites-store.dom.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
177
frontend/tests/unit/core/models/favorites.test.ts
Normal file
177
frontend/tests/unit/core/models/favorites.test.ts
Normal 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]]);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user