diff --git a/src/features/chat/capabilities/ConversationComposerCapability.tsx b/src/features/chat/capabilities/ConversationComposerCapability.tsx index cadeb606a..fe1f21242 100644 --- a/src/features/chat/capabilities/ConversationComposerCapability.tsx +++ b/src/features/chat/capabilities/ConversationComposerCapability.tsx @@ -361,6 +361,7 @@ export function ConversationComposerCapability({ currentModel: controller.currentModelName ?? undefined, currentExecutionTarget: controller.currentExecutionTarget, availableModels: controller.availableModels, + favoriteModels: controller.favoriteModels, modelsLoading: controller.modelsLoading, modelStatusMessage: controller.modelStatusMessage, onModelChange: controller.handleModelChange, diff --git a/src/features/chat/hooks/useAgentModelPickerState.ts b/src/features/chat/hooks/useAgentModelPickerState.ts index 6293edcf1..d16c37463 100644 --- a/src/features/chat/hooks/useAgentModelPickerState.ts +++ b/src/features/chat/hooks/useAgentModelPickerState.ts @@ -114,6 +114,16 @@ export function useAgentModelPickerState({ () => getModelsForAgent(selectedAgentId), [getModelsForAgent, selectedAgentId], ); + const favoriteModels = useMemo( + () => + pickerAgents.flatMap((agent) => + getModelsForAgent(agent.id).map((model) => ({ + agentId: agent.id, + model, + })), + ), + [getModelsForAgent, pickerAgents], + ); const providerIdsForSelectedAgent = useMemo( () => @@ -211,6 +221,7 @@ export function useAgentModelPickerState({ selectedAgentId, pickerAgents, availableModels, + favoriteModels, getModelsForAgent, isModelInventoryAuthoritative, modelsLoading, diff --git a/src/features/chat/hooks/useChatSessionController.ts b/src/features/chat/hooks/useChatSessionController.ts index 47d95e529..b8c973c72 100644 --- a/src/features/chat/hooks/useChatSessionController.ts +++ b/src/features/chat/hooks/useChatSessionController.ts @@ -1162,6 +1162,7 @@ export function useChatSessionController({ selectedAgentId, pickerAgents, availableModels, + favoriteModels, getModelsForAgent, modelsLoading, modelStatusMessage, @@ -3556,6 +3557,7 @@ export function useChatSessionController({ currentModelName: effectiveModelSelection?.name ?? null, currentExecutionTarget: session?.executionTarget, availableModels, + favoriteModels, modelsLoading, modelStatusMessage, handleModelChange: handleModelChangeWithContextReset, diff --git a/src/features/chat/hooks/useResolvedAgentModelPicker.ts b/src/features/chat/hooks/useResolvedAgentModelPicker.ts index d21d8a4b9..d2c16b841 100644 --- a/src/features/chat/hooks/useResolvedAgentModelPicker.ts +++ b/src/features/chat/hooks/useResolvedAgentModelPicker.ts @@ -336,6 +336,7 @@ export function useResolvedAgentModelPicker({ const { pickerAgents, availableModels, + favoriteModels, getModelsForAgent, isModelInventoryAuthoritative, modelsLoading, @@ -816,6 +817,7 @@ export function useResolvedAgentModelPicker({ selectedAgentId, pickerAgents, availableModels, + favoriteModels, getModelsForAgent, modelsLoading, modelStatusMessage, diff --git a/src/features/chat/hooks/useStarredModels.ts b/src/features/chat/hooks/useStarredModels.ts new file mode 100644 index 000000000..f08f3b73a --- /dev/null +++ b/src/features/chat/hooks/useStarredModels.ts @@ -0,0 +1,70 @@ +import { useCallback, useSyncExternalStore } from "react"; +import { + getStarredModelKeys, + modelStarKey, + STARRED_MODELS_ENTRY_PREFIX, + STARRED_MODELS_EVENT, + toggleModelStar, +} from "../lib/starredModels"; + +let cachedSnapshot: Set | null = null; +const serverSnapshot = new Set(); + +/** Invalidate the in-memory snapshot cache. Intended for tests. */ +export function __resetStarredModelsCacheForTests(): void { + cachedSnapshot = null; +} + +function getSnapshot(): Set { + if (cachedSnapshot === null) { + cachedSnapshot = getStarredModelKeys(); + } + return cachedSnapshot; +} + +function subscribe(callback: () => void): () => void { + const handleChange = () => { + cachedSnapshot = null; + callback(); + }; + const handleStorage = (event: StorageEvent) => { + // Star entries live under per-key storage, so any entry write or removal + // in another window changes the set. `key === null` covers localStorage + // clears. + if ( + event.key === null || + event.key.startsWith(STARRED_MODELS_ENTRY_PREFIX) + ) { + handleChange(); + } + }; + + window.addEventListener(STARRED_MODELS_EVENT, handleChange); + window.addEventListener("storage", handleStorage); + // No listener exists while the store has no subscribers. Invalidate after + // attaching both listeners so a remount rereads changes made during that gap. + cachedSnapshot = null; + return () => { + window.removeEventListener(STARRED_MODELS_EVENT, handleChange); + window.removeEventListener("storage", handleStorage); + }; +} + +export function useStarredModels() { + const starredKeys = useSyncExternalStore( + subscribe, + getSnapshot, + () => serverSnapshot, + ); + const isStarred = useCallback( + (scopeId: string, modelId: string) => + starredKeys.has(modelStarKey(scopeId, modelId)), + [starredKeys], + ); + const toggleStar = useCallback( + (scopeId: string, modelId: string) => toggleModelStar(scopeId, modelId), + [], + ); + + return { isStarred, toggleStar, starredKeys }; +} diff --git a/src/features/chat/lib/starredModels.ts b/src/features/chat/lib/starredModels.ts new file mode 100644 index 000000000..8879afe08 --- /dev/null +++ b/src/features/chat/lib/starredModels.ts @@ -0,0 +1,102 @@ +import { toast } from "sonner"; +import { i18n } from "@/shared/i18n"; + +export const STARRED_MODELS_ENTRY_PREFIX = "goose:starredModels:v1:entry:"; +const STARRED_MODELS_ENTRY_VALUE = "1"; +const STARRED_MODELS_CHANGED_EVENT = "goose:starred-models-changed"; + +type StarredModelSet = Set; + +export function modelStarKey(scopeId: string, modelId: string): string { + return JSON.stringify([scopeId, modelId]); +} + +/** localStorage key of the single entry that records one starred model. */ +export function starredModelStorageKey(starKey: string): string { + return STARRED_MODELS_ENTRY_PREFIX + encodeURIComponent(starKey); +} + +function readStarredModels(): StarredModelSet { + if (typeof window === "undefined") { + return new Set(); + } + + try { + const storage = window.localStorage; + const starred = new Set(); + for (let i = 0; i < storage.length; i += 1) { + const storageKey = storage.key(i); + if (!storageKey?.startsWith(STARRED_MODELS_ENTRY_PREFIX)) { + continue; + } + try { + starred.add( + decodeURIComponent( + storageKey.slice(STARRED_MODELS_ENTRY_PREFIX.length), + ), + ); + } catch { + // Skip a malformed entry rather than dropping every star. + } + } + return starred; + } catch { + return new Set(); + } +} + +/** + * Write or clear exactly one star entry. Touching a single key (instead of + * rewriting an aggregate array) removes the cross-window read-modify-write + * race where concurrent toggles from different windows could drop each + * other's stars. Note that two windows toggling the same model at the same + * instant can still interleave; per-model state stays consistent either way. + */ +function persistStarEntry(starKey: string, starred: boolean): boolean { + if (typeof window === "undefined") { + return false; + } + + try { + const storageKey = starredModelStorageKey(starKey); + if (starred) { + window.localStorage.setItem(storageKey, STARRED_MODELS_ENTRY_VALUE); + } else { + window.localStorage.removeItem(storageKey); + } + } catch { + // The write did not land (storage unavailable or over quota). Tell the + // user instead of letting the toggle silently bounce back. + toast.error(i18n.t("chat:notifications.starredModelsPersistError")); + window.dispatchEvent(new CustomEvent(STARRED_MODELS_CHANGED_EVENT)); + return false; + } + + window.dispatchEvent(new CustomEvent(STARRED_MODELS_CHANGED_EVENT)); + return true; +} + +export function getStarredModelKeys(): StarredModelSet { + return readStarredModels(); +} + +export function toggleModelStar(scopeId: string, modelId: string): boolean { + if (typeof window === "undefined") { + return false; + } + + const starKey = modelStarKey(scopeId, modelId); + + try { + const starred = + window.localStorage.getItem(starredModelStorageKey(starKey)) !== null; + return persistStarEntry(starKey, !starred); + } catch { + // Storage is unavailable, so the toggle cannot be applied at all. The + // write path reports its own failures; report this one too. + toast.error(i18n.t("chat:notifications.starredModelsPersistError")); + return false; + } +} + +export const STARRED_MODELS_EVENT = STARRED_MODELS_CHANGED_EVENT; diff --git a/src/features/chat/types.ts b/src/features/chat/types.ts index 4313411a1..eb12a47fe 100644 --- a/src/features/chat/types.ts +++ b/src/features/chat/types.ts @@ -150,6 +150,7 @@ export interface ChatInputAgentModelPicker { currentModel?: string; currentExecutionTarget?: SessionExecutionTarget; availableModels?: ModelOption[]; + favoriteModels?: Array<{ agentId: string; model: ModelOption }>; modelsLoading?: boolean; modelStatusMessage?: string | null; onModelChange?: (modelId: string, model?: ModelOption) => void; diff --git a/src/features/chat/ui/AgentModelPicker.tsx b/src/features/chat/ui/AgentModelPicker.tsx index 2adfbb91d..0a01ad877 100644 --- a/src/features/chat/ui/AgentModelPicker.tsx +++ b/src/features/chat/ui/AgentModelPicker.tsx @@ -50,6 +50,7 @@ interface AgentModelPickerProps { currentModelProviderId?: string | null; currentModelName?: string | null; availableModels: ModelOption[]; + favoriteModels?: Array<{ agentId: string; model: ModelOption }>; modelsLoading?: boolean; modelStatusMessage?: string | null; onModelChange?: (modelId: string, model?: ModelOption) => void; @@ -79,8 +80,6 @@ type PopoverContentAlign = NonNullable< ComponentProps["align"] >; const REASONING_EFFORT_COLUMN_TRANSITION_MS = 240; -const PICKER_WIDTH_COMPACT_PX = 420; -const PICKER_WIDTH_EXPANDED_PX = 596; function toSentenceCaseLabel(value: string | undefined): string { const trimmed = value?.trim(); @@ -170,6 +169,7 @@ export function AgentModelPicker({ currentModelProviderId = null, currentModelName = null, availableModels, + favoriteModels, modelsLoading = false, modelStatusMessage = null, onModelChange, @@ -185,7 +185,7 @@ export function AgentModelPicker({ reasoningEffort, contentAlign = "start", contentCollisionPadding = 16, - providerColumnMode = "visible", + providerColumnMode = "gated", }: AgentModelPickerProps) { const { t } = useTranslation("chat"); const [uncontrolledOpen, setUncontrolledOpen] = useState(false); @@ -209,7 +209,6 @@ export function AgentModelPicker({ }); const modelListRef = useRef(null); const [providerRevealed, setProviderRevealed] = useState(false); - const [modelBrowsing, setModelBrowsing] = useState(false); const [resolvedContentAlign, setResolvedContentAlign] = useState("start"); const [latchedReasoningEffortConfig, setLatchedReasoningEffortConfig] = @@ -366,39 +365,47 @@ export function AgentModelPicker({ } }; - const handleModelSelect = (model: ModelOption) => { - recordModelSelection(selectedAgentId, model); + const pendingCrossAgentModelRef = useRef<{ + agentId: string; + model: ModelOption; + } | null>(null); + const handleModelSelect = (model: ModelOption, agentId: string) => { + if (agentId !== selectedAgentId) { + pendingCrossAgentModelRef.current = { agentId, model }; + onAgentChange(agentId); + return; + } + recordModelSelection(agentId, model); onModelChange?.(model.id, model); }; + useEffect(() => { + const pending = pendingCrossAgentModelRef.current; + if (!pending || pending.agentId !== selectedAgentId) { + return; + } + pendingCrossAgentModelRef.current = null; + recordModelSelection(pending.agentId, pending.model); + onModelChange?.(pending.model.id, pending.model); + }, [onModelChange, selectedAgentId]); // Re-gate the provider column when the popover closes, so every reopen // starts from the compact layout. useEffect(() => { if (!open) { setProviderRevealed(false); - setModelBrowsing(false); } }, [open]); const showAgentColumn = providerColumnMode === "visible" || providerRevealed; - // A sole ready agent leaves nothing to reveal, but a sole not-ready agent - // still needs the footer: the hidden column's Connect/Install row is the - // only setup path from this picker. - const hasAgentNeedingSetup = agents.some( - (agent) => agent.readiness && agent.readiness !== "ready", - ); - // Browsing the full model list (search or "View more") is a model-picking - // task; the reveal button would swap the whole popover out from under it. + // Keep the reveal action anchored below the scrolling model area whenever + // the panel is gated. Agent discovery may temporarily report one agent; it + // must not remove the user's route to the full agent panel. const showSwitchProviderFooter = - providerColumnMode === "gated" && - !providerRevealed && - !modelBrowsing && - (agents.length > 1 || hasAgentNeedingSetup); + providerColumnMode === "gated" && !providerRevealed; const showReasoningEffortColumn = showReasoningEffort; - const isWidePicker = showReasoningEffortColumn && showAgentColumn; - const pickerWidth = isWidePicker - ? PICKER_WIDTH_EXPANDED_PX - : PICKER_WIDTH_COMPACT_PX; + // Model changes can add or remove reasoning controls. Keep the popover width + // fixed for the current panel mode so those changes do not resize it. + const isWidePicker = showAgentColumn; // Land keyboard focus in the revealed column, since the reveal button that // held focus unmounts with it. @@ -415,20 +422,12 @@ export function AgentModelPicker({ }, [providerRevealed]); const resolveContentAlign = useCallback((): PopoverContentAlign => { - if (contentAlign !== "smart") { - return contentAlign; - } - - const triggerRect = triggerRef.current?.getBoundingClientRect(); - if (!triggerRect) { - return "start"; - } - - const leftAlignedRightEdge = triggerRect.left + pickerWidth; - return leftAlignedRightEdge <= window.innerWidth - contentCollisionPadding - ? "start" - : "center"; - }, [contentAlign, contentCollisionPadding, pickerWidth]); + // Center alignment follows the trigger's center, so a model label changing + // the trigger width makes the open popover jump left or right. Anchor smart + // placement to the trigger's stable leading edge instead; Radix still + // shifts the content when needed to keep it inside the viewport. + return contentAlign === "smart" ? "start" : contentAlign; + }, [contentAlign]); useEffect(() => { if (open) { @@ -500,7 +499,9 @@ export function AgentModelPicker({ // gated single-column layout has no dead vertical space below the // model list. "flex max-h-[min(24rem,50vh)] flex-col overflow-hidden p-1 transition-[width] duration-[240ms] ease-[cubic-bezier(0.2,0,0,1)]", - isWidePicker ? "w-[37.25rem]" : "w-[26.25rem]", + isWidePicker + ? "w-[min(39.25rem,calc(100vw-1.5rem))]" + : "w-[min(28.25rem,calc(100vw-1.5rem))]", )} onInteractOutside={(event) => { classifyOutsideInteraction(event.target); @@ -672,7 +673,7 @@ export function AgentModelPicker({ data-col="model" className={cn( "flex min-h-0 min-w-0 overflow-hidden p-1", - showAgentColumn ? "ml-1 w-56 shrink-0" : "flex-1", + showAgentColumn ? "ml-1 w-64 shrink-0" : "flex-1", )} > {modelsLoading ? ( @@ -701,11 +702,15 @@ export function AgentModelPicker({ key={selectedAgentId} ref={modelListRef} models={displayedModels} + favoriteModels={favoriteModels} + catalogModels={availableModels} currentModelId={currentModelId} currentModelProviderId={currentModelProviderId} selectedAgentId={selectedAgentId} + agentLabels={ + new Map(agents.map((agent) => [agent.id, agent.label])) + } onModelSelect={handleModelSelect} - onBrowseChange={setModelBrowsing} t={t} /> ) : ( diff --git a/src/features/chat/ui/AgentModelPickerLists.tsx b/src/features/chat/ui/AgentModelPickerLists.tsx index 7ad2902bd..fd58503d2 100644 --- a/src/features/chat/ui/AgentModelPickerLists.tsx +++ b/src/features/chat/ui/AgentModelPickerLists.tsx @@ -7,10 +7,21 @@ import { useRef, useState, } from "react"; -import { IconCheck, IconDots, IconSearch, IconX } from "@tabler/icons-react"; +import { + IconDots, + IconSearch, + IconStar, + IconStarFilled, + IconX, +} from "@tabler/icons-react"; +import { motion, useReducedMotion } from "motion/react"; +import { useStarredModels } from "../hooks/useStarredModels"; +import { modelStarKey } from "../lib/starredModels"; import { SearchBar } from "@/shared/ui/SearchBar"; import { Button } from "@/shared/ui/button"; +import { cn } from "@/shared/lib/cn"; import { ScrollArea } from "@/shared/ui/scroll-area"; +import { Separator } from "@/shared/ui/separator"; import { formatProviderLabel, getProviderIcon, @@ -48,6 +59,24 @@ function getGooseModelProviderLabel(model: ModelOption) { return null; } +function compareModelsAlphabetically(left: ModelOption, right: ModelOption) { + const byName = getModelDisplayName(left).localeCompare( + getModelDisplayName(right), + undefined, + { sensitivity: "base" }, + ); + if (byName !== 0) { + return byName; + } + + const byId = left.id.localeCompare(right.id); + if (byId !== 0) { + return byId; + } + + return (left.providerId ?? "").localeCompare(right.providerId ?? ""); +} + function compareModelsByProviderOrderAndName( left: ModelOption, right: ModelOption, @@ -92,11 +121,18 @@ function sortModels( recency: { map: ModelRecencyMap; agentId: string }, ) { return [...models].sort((left, right) => { - if (modelMatchesSelection(left, currentModelId, currentModelProviderId)) { - return -1; - } - if (modelMatchesSelection(right, currentModelId, currentModelProviderId)) { - return 1; + const leftSelected = modelMatchesSelection( + left, + currentModelId, + currentModelProviderId, + ); + const rightSelected = modelMatchesSelection( + right, + currentModelId, + currentModelProviderId, + ); + if (leftSelected !== rightSelected) { + return leftSelected ? -1 : 1; } const leftRank = getModelRecencyRank(recency.map, recency.agentId, left); @@ -115,43 +151,212 @@ function sortModels( }); } +export interface FavoriteModelOption { + agentId: string; + model: ModelOption; +} + interface ModelListProps { models: ModelOption[]; + favoriteModels?: FavoriteModelOption[]; + /** + * The authoritative catalog the rows were built from, without any + * synthesized rows for the current selection. Starred state is only + * honored for models present here, so favorited models a provider no + * longer serves stop rendering as starred. Omit to treat every row as + * existing. + */ + catalogModels?: ModelOption[]; currentModelId: string | null; currentModelProviderId: string | null; selectedAgentId: string; - onModelSelect: (model: ModelOption) => void; + agentLabels?: ReadonlyMap; + onModelSelect: (model: ModelOption, agentId: string) => void; /** * Reports whether the list has left the recommended view for the full model * list (search or "View more"), so the picker can hide affordances that * would interrupt browsing. */ onBrowseChange?: (browsing: boolean) => void; - t: (key: string) => string; + t: (key: string, options?: Record) => string; } export interface RecommendedModelListHandle { closeSearch: () => boolean; } +type StarAnimation = { + phase: "out" | "moving" | "in"; + targetStarred: boolean; +}; + +const STAR_SPIN_TRANSITION = { + duration: 0.24, + ease: "easeInOut" as const, + times: [0, 0.18, 0.82, 1], + opacity: { + duration: 0.24, + ease: "easeIn" as const, + times: [0, 0.18, 0.82, 1], + }, +}; + export const RecommendedModelList = forwardRef< RecommendedModelListHandle, ModelListProps >(function RecommendedModelList( { models, + favoriteModels, + catalogModels, currentModelId, currentModelProviderId, selectedAgentId, + agentLabels, onModelSelect, onBrowseChange, t, }, ref, ) { + const { toggleStar, starredKeys } = useStarredModels(); + const prefersReducedMotion = useReducedMotion(); + const modelAgentIds = useMemo( + () => + new Map( + (favoriteModels ?? []).map(({ agentId, model }) => [model, agentId]), + ), + [favoriteModels], + ); + const getModelScopeId = useCallback( + (model: ModelOption) => + model.providerId ?? modelAgentIds.get(model) ?? selectedAgentId, + [modelAgentIds, selectedAgentId], + ); + // Rows include a synthesized entry for the current selection when the + // catalog no longer serves it. Honoring starred state only for catalog + // models keeps a favorited model a provider dropped from rendering as + // starred; the stored entry survives so the star returns if the model does. + const existingModelKeys = useMemo(() => { + if (!catalogModels) { + return null; + } + return new Set( + catalogModels.map((model) => + modelStarKey(getModelScopeId(model), model.id), + ), + ); + }, [catalogModels, getModelScopeId]); + const favoriteModelKeys = useMemo( + () => + favoriteModels + ? new Set( + favoriteModels.map(({ agentId, model }) => + modelStarKey(model.providerId ?? agentId, model.id), + ), + ) + : existingModelKeys, + [existingModelKeys, favoriteModels], + ); + const [starAnimation, setStarAnimation] = useState<{ + modelKey: string; + scopeId: string; + modelId: string; + hasSelectedAgentDestination: boolean; + state: StarAnimation; + } | null>(null); + const liveStarredKeys = useMemo(() => { + if (!favoriteModelKeys) { + return starredKeys; + } + const live = new Set(); + for (const key of starredKeys) { + if (favoriteModelKeys.has(key)) { + live.add(key); + } + } + return live; + }, [favoriteModelKeys, starredKeys]); + const starredModels = useMemo(() => { + const candidates = + favoriteModels ?? + models.map((model) => ({ agentId: selectedAgentId, model })); + return candidates.filter(({ agentId, model }) => { + const modelKey = modelStarKey(model.providerId ?? agentId, model.id); + return ( + liveStarredKeys.has(modelKey) || + (starAnimation?.modelKey === modelKey && + !starAnimation.hasSelectedAgentDestination && + starAnimation.state.phase === "moving" && + !starAnimation.state.targetStarred) + ); + }); + }, [favoriteModels, liveStarredKeys, models, selectedAgentId, starAnimation]); const [searchOpen, setSearchOpen] = useState(false); const [showAll, setShowAll] = useState(false); + const [hoveredModelKey, setHoveredModelKey] = useState(null); + const [focusedModelKey, setFocusedModelKey] = useState(null); const [query, setQuery] = useState(""); + const pointerPositionRef = useRef<{ x: number; y: number } | null>(null); + const rowElementsRef = useRef(new Map()); + const reconcileRowHover = useCallback(() => { + const pointer = pointerPositionRef.current; + if (!pointer) { + setHoveredModelKey(null); + return; + } + const hoveredEntry = Array.from(rowElementsRef.current).find(([, row]) => { + const bounds = row.getBoundingClientRect(); + return ( + pointer.x >= bounds.left && + pointer.x <= bounds.right && + pointer.y >= bounds.top && + pointer.y <= bounds.bottom + ); + }); + setHoveredModelKey(hoveredEntry?.[0] ?? null); + }, []); + useEffect(() => { + if (!starAnimation || prefersReducedMotion) { + return; + } + if (starAnimation.state.phase === "out") { + const timer = window.setTimeout(() => { + const changed = toggleStar( + starAnimation.scopeId, + starAnimation.modelId, + ); + setStarAnimation( + changed + ? { + ...starAnimation, + state: { ...starAnimation.state, phase: "moving" }, + } + : null, + ); + }, 240); + return () => window.clearTimeout(timer); + } + if (starAnimation.state.phase === "moving") { + const timer = window.setTimeout(() => { + if (starAnimation.state.targetStarred) { + setStarAnimation({ + ...starAnimation, + state: { ...starAnimation.state, phase: "in" }, + }); + } else { + reconcileRowHover(); + setStarAnimation(null); + } + }, 240); + return () => window.clearTimeout(timer); + } + const timer = window.setTimeout(() => { + reconcileRowHover(); + setStarAnimation(null); + }, 240); + return () => window.clearTimeout(timer); + }, [prefersReducedMotion, reconcileRowHover, starAnimation, toggleStar]); const inputRef = useRef(null); const searchButtonRef = useRef(null); const restoreSearchButtonFocusRef = useRef(false); @@ -168,10 +373,13 @@ export const RecommendedModelList = forwardRef< setQuery(""); setSearchOpen(false); setShowAll(false); + setHoveredModelKey(null); + setFocusedModelKey(null); resetScroll(); }, [resetScroll]); const recencyMap = useModelRecency(); const recommended = useMemo(() => { + const starred = starredModels.map(({ model }) => model); const recent = models .map((m) => ({ model: m, @@ -184,6 +392,9 @@ export const RecommendedModelList = forwardRef< entry.model, currentModelId, currentModelProviderId, + ) && + !liveStarredKeys.has( + modelStarKey(getModelScopeId(entry.model), entry.model.id), ), ) .sort((left, right) => { @@ -199,30 +410,44 @@ export const RecommendedModelList = forwardRef< .filter((m) => m.recommended) .filter( (m) => - !recent.some((r) => r.id === m.id && r.providerId === m.providerId), + !recent.some((r) => r.id === m.id && r.providerId === m.providerId) && + !liveStarredKeys.has(modelStarKey(getModelScopeId(m), m.id)), ); const shortlist = [...recent, ...rec]; if ( currentModelId && - shortlist.length > 0 && + starred.length + shortlist.length > 0 && + !starred.some((m) => + modelMatchesSelection(m, currentModelId, currentModelProviderId), + ) && !shortlist.some((m) => modelMatchesSelection(m, currentModelId, currentModelProviderId), ) ) { - const current = models.find((m) => - modelMatchesSelection(m, currentModelId, currentModelProviderId), + const current = models.find((model) => + modelMatchesSelection(model, currentModelId, currentModelProviderId), ); if (current) { - return [current, ...shortlist]; + return [...starred, current, ...shortlist]; } } - return shortlist.length > 0 ? shortlist : models; + const unstarredFallback = models.filter( + (model) => + !liveStarredKeys.has(modelStarKey(getModelScopeId(model), model.id)), + ); + return [ + ...starred, + ...(shortlist.length > 0 ? shortlist : unstarredFallback), + ]; }, [ models, currentModelId, currentModelProviderId, recencyMap, selectedAgentId, + liveStarredKeys, + starredModels, + getModelScopeId, ]); useEffect(() => { @@ -251,11 +476,17 @@ export const RecommendedModelList = forwardRef< if (!searchOpen && !showAll) { return recommended; } + const favoriteRows = starredModels.map(({ model }) => model); + const regularRows = models.filter( + (model) => + !liveStarredKeys.has(modelStarKey(getModelScopeId(model), model.id)), + ); + const browsableModels = [...favoriteRows, ...regularRows]; const normalizedQuery = query.trim().toLowerCase(); if (!normalizedQuery) { - return models; + return browsableModels; } - return models.filter( + return browsableModels.filter( (model) => model.name.toLowerCase().includes(normalizedQuery) || model.id.toLowerCase().includes(normalizedQuery) || @@ -263,24 +494,70 @@ export const RecommendedModelList = forwardRef< model.providerName?.toLowerCase().includes(normalizedQuery) || model.providerId?.toLowerCase().includes(normalizedQuery), ); - }, [models, query, recommended, searchOpen, showAll]); + }, [ + liveStarredKeys, + models, + query, + recommended, + searchOpen, + showAll, + starredModels, + getModelScopeId, + ]); - const sorted = useMemo( - () => - sortModels(visibleModels, currentModelId, currentModelProviderId, { + const grouped = useMemo(() => { + const starred: ModelOption[] = []; + const unstarred: ModelOption[] = []; + for (const model of visibleModels) { + const scopeId = getModelScopeId(model); + const modelKey = modelStarKey(scopeId, model.id); + const retainedForeignFavorite = + starAnimation?.modelKey === modelKey && + !starAnimation.hasSelectedAgentDestination && + starAnimation.state.phase === "moving" && + !starAnimation.state.targetStarred; + (liveStarredKeys.has(modelKey) || retainedForeignFavorite + ? starred + : unstarred + ).push(model); + } + return { + starred: [...starred].sort(compareModelsAlphabetically), + unstarred: sortModels(unstarred, currentModelId, currentModelProviderId, { map: recencyMap, agentId: selectedAgentId, }), - [ - visibleModels, - currentModelId, - currentModelProviderId, - recencyMap, - selectedAgentId, - ], + }; + }, [ + visibleModels, + currentModelId, + currentModelProviderId, + recencyMap, + selectedAgentId, + liveStarredKeys, + getModelScopeId, + starAnimation, + ]); + const sorted = [...grouped.starred, ...grouped.unstarred]; + const layoutItems: Array< + { type: "model"; model: ModelOption } | { type: "favorites-divider" } + > = [ + ...grouped.starred.map((model) => ({ type: "model" as const, model })), + ...(grouped.starred.length > 0 && grouped.unstarred.length > 0 + ? ([{ type: "favorites-divider" }] as const) + : []), + ...grouped.unstarred.map((model) => ({ type: "model" as const, model })), + ]; + const layoutTransition = prefersReducedMotion + ? { duration: 0 } + : { type: "spring" as const, duration: 0.24, bounce: 0 }; + const recommendedKeys = new Set( + recommended.map((model) => modelStarKey(getModelScopeId(model), model.id)), + ); + const hasMore = models.some( + (model) => + !recommendedKeys.has(modelStarKey(getModelScopeId(model), model.id)), ); - - const hasMore = models.length > recommended.length; const showSearchButton = hasMore || recommended.length > SEARCHABLE_LIST_THRESHOLD; const closeSearch = useCallback(() => { @@ -312,7 +589,10 @@ export const RecommendedModelList = forwardRef< }; return ( -
+
setHoveredModelKey(null)} + >
{searchOpen ? (
@@ -368,45 +648,253 @@ export const RecommendedModelList = forwardRef< ref={scrollAreaRef} className="min-h-0 min-w-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:!block" > -
- {sorted.map((model) => { - const providerLabel = getGooseModelProviderLabel(model); +
+ {layoutItems.map((item) => { + if (item.type === "favorites-divider") { + return ( + + + + ); + } + + const { model } = item; + const modelAgentId = modelAgentIds.get(model) ?? selectedAgentId; + const iconProviderId = + modelAgentId === "goose" && model.providerId + ? model.providerId + : modelAgentId; + const providerLabel = + modelAgentId === "goose" + ? getGooseModelProviderLabel(model) + : formatProviderLabel(modelAgentId); const providerIcon = - selectedAgentId === "goose" && model.providerId - ? getProviderIcon(model.providerId, "size-3.5") + modelAgentId !== "goose" || model.providerId + ? getProviderIcon(iconProviderId, "size-3.5") : null; - const isSelected = modelMatchesSelection( - model, - currentModelId, - currentModelProviderId, - ); + const isSelected = + modelAgentId === selectedAgentId && + modelMatchesSelection( + model, + currentModelId, + currentModelProviderId, + ); + const foreignAgentLabel = + modelAgentId !== selectedAgentId + ? (agentLabels?.get(modelAgentId) ?? + formatProviderLabel(modelAgentId)) + : null; + const scopeId = getModelScopeId(model); + const modelKey = modelStarKey(scopeId, model.id); + const starred = liveStarredKeys.has(modelKey); + const existsInCatalog = + !favoriteModelKeys || favoriteModelKeys.has(modelKey); + const activeStarAnimation = + starAnimation?.modelKey === modelKey + ? starAnimation.state + : null; + const idleStarVisible = + starred || + hoveredModelKey === modelKey || + focusedModelKey === modelKey; + const handleStarClick = () => { + if (starAnimation) { + return; + } + if (prefersReducedMotion) { + toggleStar(scopeId, model.id); + return; + } + setStarAnimation({ + modelKey, + scopeId, + modelId: model.id, + hasSelectedAgentDestination: + existingModelKeys?.has(modelKey) ?? true, + state: { phase: "out", targetStarred: !starred }, + }); + }; return ( - { - onModelSelect(model); - resetView(); + -
- {providerIcon ? ( - { + if (element) { + rowElementsRef.current.set(modelKey, element); + } else { + rowElementsRef.current.delete(modelKey); + } + }} + className={cn( + "flex min-w-0 items-center gap-1 rounded-sm", + isSelected && "bg-accent", + )} + data-model-key={modelKey} + data-selected={isSelected || undefined} + data-starred={starred || undefined} + onPointerMove={(event) => { + pointerPositionRef.current = { + x: event.clientX, + y: event.clientY, + }; + }} + onPointerEnter={(event) => { + pointerPositionRef.current = { + x: event.clientX, + y: event.clientY, + }; + if (starAnimation?.modelKey !== modelKey) { + setHoveredModelKey(modelKey); + } + }} + onPointerLeave={(event) => { + pointerPositionRef.current = { + x: event.clientX, + y: event.clientY, + }; + if (starAnimation?.modelKey !== modelKey) { + setHoveredModelKey((current) => + current === modelKey ? null : current, + ); + } + }} + onFocusCapture={() => setFocusedModelKey(modelKey)} + onBlurCapture={(event) => { + if (!event.currentTarget.contains(event.relatedTarget)) { + setFocusedModelKey((current) => + current === modelKey ? null : current, + ); + } + }} + > + { + onModelSelect(model, modelAgentId); + resetView(); + }} + selected={isSelected} + aria-label={ + foreignAgentLabel + ? `${getModelDisplayName(model)}, ${foreignAgentLabel}` + : undefined + } + className="w-auto flex-1 justify-between" + > +
+ {providerIcon ? ( + + {providerIcon} + + ) : null} +
+ + {getModelDisplayName(model)} + + {foreignAgentLabel ? ( + + {foreignAgentLabel} + + ) : null} +
+
+
+ {existsInCatalog ? ( + ) : null} -
- {getModelDisplayName(model)} -
- {isSelected ? ( - - ) : null} -
+ ); })} {hasMore && !searchOpen && !showAll ? ( diff --git a/src/features/chat/ui/ChatInput.tsx b/src/features/chat/ui/ChatInput.tsx index 752d378c4..0f7761abb 100644 --- a/src/features/chat/ui/ChatInput.tsx +++ b/src/features/chat/ui/ChatInput.tsx @@ -341,6 +341,7 @@ export function ChatInput({ currentModelProviderId = null, currentModel, availableModels = [], + favoriteModels, modelsLoading = false, modelStatusMessage = null, onModelChange, @@ -1966,6 +1967,7 @@ export function ChatInput({ currentModelProviderId, currentModel: resolvedCurrentModel, availableModels, + favoriteModels, modelsLoading, modelStatusMessage, onModelChange, diff --git a/src/features/chat/ui/ChatInputToolbar.tsx b/src/features/chat/ui/ChatInputToolbar.tsx index 62cf71e43..aefafe045 100644 --- a/src/features/chat/ui/ChatInputToolbar.tsx +++ b/src/features/chat/ui/ChatInputToolbar.tsx @@ -114,6 +114,7 @@ export function ChatInputToolbar({ currentModelProviderId, currentModel, availableModels = [], + favoriteModels, modelsLoading = false, modelStatusMessage = null, onModelChange, @@ -357,6 +358,7 @@ export function ChatInputToolbar({ currentModelProviderId={currentModelProviderId} currentModelName={currentModel ?? null} availableModels={availableModels} + favoriteModels={favoriteModels} modelsLoading={modelsLoading} modelStatusMessage={modelStatusMessage} onModelChange={onModelChange} diff --git a/src/features/chat/ui/__tests__/AgentModelPicker.test.tsx b/src/features/chat/ui/__tests__/AgentModelPicker.test.tsx index 056f71e6f..0ee0ae3e6 100644 --- a/src/features/chat/ui/__tests__/AgentModelPicker.test.tsx +++ b/src/features/chat/ui/__tests__/AgentModelPicker.test.tsx @@ -1,7 +1,9 @@ import type { ComponentProps } from "react"; import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { __resetStarredModelsCacheForTests } from "../../hooks/useStarredModels"; +import { modelStarKey, starredModelStorageKey } from "../../lib/starredModels"; import { AgentModelPicker } from "../AgentModelPicker"; import { getModelRecencyMap, @@ -10,6 +12,18 @@ import { recordModelSelection, } from "../../lib/modelRecency"; import { OPEN_SETTINGS_EVENT } from "@/features/settings/lib/settingsEvents"; +import { toast } from "sonner"; + +vi.mock("sonner", () => ({ + toast: { + error: vi.fn(), + success: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + message: vi.fn(), + dismiss: vi.fn(), + }, +})); class ResizeObserverStub { observe() {} @@ -76,6 +90,7 @@ describe("AgentModelPicker", () => { onAgentChange={onAgentChange} availableModels={[]} onModelChange={vi.fn()} + providerColumnMode="visible" onRequestComposerFocus={onRequestComposerFocus} />, ); @@ -120,6 +135,7 @@ describe("AgentModelPicker", () => { onAgentChange={onAgentChange} availableModels={[]} onModelChange={vi.fn()} + providerColumnMode="visible" />, ); @@ -232,12 +248,12 @@ describe("AgentModelPicker", () => { await user.click(trigger); const explicitModel = screen.getByRole("button", { - name: /Claude Opus 4\.8/, + name: /^Claude Opus 4\.8$/, }); expect(explicitModel).toHaveClass("bg-accent"); expect( explicitModel.querySelector(".tabler-icon-check"), - ).toBeInTheDocument(); + ).not.toBeInTheDocument(); }); it("does not synthesize an external harness model into Goose", async () => { @@ -269,7 +285,7 @@ describe("AgentModelPicker", () => { screen.queryByRole("button", { name: /synthetic-model/i }), ).not.toBeInTheDocument(); expect( - screen.getByRole("button", { name: /GPT-5\.5/i }), + screen.getByRole("button", { name: /^GPT-5\.5$/i }), ).toBeInTheDocument(); }); @@ -509,6 +525,10 @@ describe("AgentModelPicker", () => { ); expect(screen.getByText("Reasoning effort")).toBeInTheDocument(); + const picker = screen.getByRole("dialog"); + const initialWidthClass = Array.from(picker.classList).find((className) => + className.startsWith("w-[min("), + ); rerender( { }, { timeout: 500 }, ); + expect( + Array.from(picker.classList).find((className) => + className.startsWith("w-[min("), + ), + ).toBe(initialWidthClass); }); it("passes the clicked model option through for duplicate model ids", async () => { @@ -878,7 +903,7 @@ describe("AgentModelPicker", () => { const picker = screen.getByRole("dialog"); expect(searchButton.parentElement).toHaveTextContent("Model"); expect(searchButton).toHaveClass("mr-3", "h-6", "w-6"); - expect(picker).toHaveClass("w-[26.25rem]"); + expect(picker).toHaveClass("w-[min(28.25rem,calc(100vw-1.5rem))]"); expect(within(picker).getByText("Claude Sonnet 4")).toBeInTheDocument(); expect(within(picker).queryByText("GPT-4o mini")).not.toBeInTheDocument(); expect( @@ -940,7 +965,7 @@ describe("AgentModelPicker", () => { expect( within(picker).queryByText("gpt-4o-mini-2024-07-18"), ).not.toBeInTheDocument(); - expect(picker).toHaveClass("w-[26.25rem]"); + expect(picker).toHaveClass("w-[min(28.25rem,calc(100vw-1.5rem))]"); if (modelViewport) { modelViewport.scrollTop = 120; @@ -967,7 +992,7 @@ describe("AgentModelPicker", () => { ).not.toBeInTheDocument(); await user.click( - within(picker).getByRole("button", { name: /GPT-4o mini/ }), + within(picker).getByRole("button", { name: /^GPT-4o mini$/ }), ); // The selection is recorded as recently used, so it joins the compact @@ -993,6 +1018,7 @@ describe("AgentModelPicker", () => { { id: "gpt-4o-mini", name: "GPT-4o mini" }, ]} onModelChange={vi.fn()} + providerColumnMode="visible" />, ); @@ -1063,6 +1089,7 @@ describe("AgentModelPicker", () => { { id: "gpt-4o-mini", name: "GPT-4o mini" }, ]} onModelChange={vi.fn()} + providerColumnMode="visible" />, ); @@ -1289,7 +1316,7 @@ describe("AgentModelPicker", () => { ).toHaveFocus(); }); - it("hides the switch-agent button while searching models", async () => { + it("keeps the switch-agent footer while searching models", async () => { const user = userEvent.setup(); renderGated({ availableModels: BROWSABLE_MODELS }); @@ -1297,8 +1324,8 @@ describe("AgentModelPicker", () => { await user.click(screen.getByRole("button", { name: /search models/i })); expect( - screen.queryByRole("button", { name: /switch agent/i }), - ).toBeNull(); + screen.getByRole("button", { name: /switch agent/i }), + ).toBeInTheDocument(); await user.keyboard("{Escape}"); @@ -1307,7 +1334,7 @@ describe("AgentModelPicker", () => { ).toBeInTheDocument(); }); - it("hides the switch-agent button while browsing all models", async () => { + it("keeps the switch-agent footer while browsing all models", async () => { const user = userEvent.setup(); renderGated({ availableModels: BROWSABLE_MODELS }); @@ -1315,8 +1342,8 @@ describe("AgentModelPicker", () => { await user.click(screen.getByRole("button", { name: /view more/i })); expect( - screen.queryByRole("button", { name: /switch agent/i }), - ).toBeNull(); + screen.getByRole("button", { name: /switch agent/i }), + ).toBeInTheDocument(); await user.keyboard("{Escape}"); await waitFor(() => { @@ -1411,22 +1438,22 @@ describe("AgentModelPicker", () => { await openPicker(user); const content = document.querySelector('[data-slot="popover-content"]'); - expect(content).toHaveClass("w-[26.25rem]"); + expect(content).toHaveClass("w-[min(28.25rem,calc(100vw-1.5rem))]"); await user.click(screen.getByRole("button", { name: /switch agent/i })); - expect(content).toHaveClass("w-[37.25rem]"); + expect(content).toHaveClass("w-[min(39.25rem,calc(100vw-1.5rem))]"); }); - it("hides the switch-agent button when the only agent is ready", async () => { + it("keeps the switch-agent footer during partial agent discovery", async () => { const user = userEvent.setup(); renderGated({ agents: [{ id: "goose", label: "Goose" }] }); await openPicker(user); expect( - screen.queryByRole("button", { name: /switch agent/i }), - ).toBeNull(); + screen.getByRole("button", { name: /switch agent/i }), + ).toBeInTheDocument(); }); it("keeps the switch-agent button when the only agent needs setup", async () => { @@ -1462,7 +1489,7 @@ describe("AgentModelPicker", () => { window.removeEventListener(OPEN_SETTINGS_EVENT, openSettings); }); - it("keeps the agent column visible by default", async () => { + it("hides the agent column behind Switch agent by default", async () => { const user = userEvent.setup(); render( @@ -1479,13 +1506,18 @@ describe("AgentModelPicker", () => { await openPicker(user); + expect(document.querySelector('[data-col="agent"]')).toHaveAttribute( + "data-hidden", + "true", + ); + expect(screen.queryByRole("button", { name: "Claude Code" })).toBeNull(); + + await user.click(screen.getByRole("button", { name: /switch agent/i })); + expect(document.querySelector('[data-col="agent"]')).toHaveAttribute( "data-hidden", "false", ); - expect( - screen.queryByRole("button", { name: /switch agent/i }), - ).toBeNull(); expect( screen.getByRole("button", { name: "Claude Code" }), ).toBeInTheDocument(); @@ -1744,3 +1776,679 @@ describe("AgentModelPicker", () => { }); }); }); + +describe("AgentModelPicker starred models", () => { + beforeEach(() => { + localStorage.clear(); + __resetStarredModelsCacheForTests(); + vi.mocked(toast.error).mockClear(); + }); + + afterEach(() => { + localStorage.clear(); + __resetStarredModelsCacheForTests(); + }); + + const models = [ + { id: "preferred", name: "Preferred", recommended: true }, + { id: "also-preferred", name: "Also Preferred", recommended: true }, + { id: "other", name: "Other" }, + { id: "another", name: "Another" }, + ]; + + const seedStar = (scopeId: string, modelId: string) => { + localStorage.setItem( + starredModelStorageKey(modelStarKey(scopeId, modelId)), + "1", + ); + }; + + it("shows star actions on the preferred shortlist", async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click( + screen.getByRole("button", { name: /choose agent and model/i }), + ); + const picker = screen.getByRole("dialog"); + + expect( + within(picker).getByRole("button", { name: "Star Preferred" }), + ).toBeInTheDocument(); + expect( + within(picker).getByRole("button", { name: "Star Also Preferred" }), + ).toBeInTheDocument(); + expect(within(picker).queryByText("Other")).not.toBeInTheDocument(); + }); + + it("always shows a non-recommended star above the preferred shortlist", async () => { + seedStar("goose", "other"); + __resetStarredModelsCacheForTests(); + const user = userEvent.setup(); + render( + , + ); + + await user.click( + screen.getByRole("button", { name: /choose agent and model/i }), + ); + const starredRow = document.querySelector( + `[data-model-key='${modelStarKey("goose", "other")}']`, + ); + const divider = screen.getByTestId("starred-models-divider"); + const preferredRow = document.querySelector( + `[data-model-key='${modelStarKey("goose", "preferred")}']`, + ); + + expect(starredRow).toBeInTheDocument(); + expect(preferredRow).toBeInTheDocument(); + expect(starredRow?.compareDocumentPosition(divider)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + expect(divider.compareDocumentPosition(preferredRow as Element)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + expect(screen.queryByText("Another")).not.toBeInTheDocument(); + }); + + it("groups stars in View more without selecting the model", async () => { + const user = userEvent.setup(); + const onModelChange = vi.fn(); + render( + , + ); + + await user.click( + screen.getByRole("button", { name: /choose agent and model/i }), + ); + const picker = screen.getByRole("dialog"); + await user.click(within(picker).getByRole("button", { name: "View more" })); + await user.click( + within(picker).getByRole("button", { name: "Star Other" }), + ); + + expect(onModelChange).not.toHaveBeenCalled(); + await waitFor(() => + expect(screen.getByTestId("starred-models-divider")).toBeInTheDocument(), + ); + }); + + it("renders star actions through the shared Button contract with a ≥3:1 idle treatment", async () => { + seedStar("goose", "other"); + __resetStarredModelsCacheForTests(); + const user = userEvent.setup(); + render( + , + ); + + await user.click( + screen.getByRole("button", { name: /choose agent and model/i }), + ); + const picker = screen.getByRole("dialog"); + + // Unstarred rows idle on the ghost icon contract's muted-foreground; the + // pairing against the popover surface is enforced in globals.test.ts. + const idleStar = within(picker).getByRole("button", { + name: "Star Preferred", + }); + expect(idleStar).toHaveAttribute("data-slot", "button"); + expect(idleStar).toHaveAttribute("aria-pressed", "false"); + expect(idleStar).toHaveClass("text-muted-foreground"); + expect(idleStar).toHaveClass("hover:text-muted-foreground"); + expect(idleStar).not.toHaveClass("text-foreground/80"); + expect(idleStar).not.toHaveClass( + "opacity-0", + "opacity-100", + "transition-opacity", + "animate-in", + "fade-in", + ); + + const preferredRow = idleStar.closest("[data-model-key]"); + expect(preferredRow).not.toBeNull(); + const idleStarIcon = idleStar.firstElementChild; + expect(idleStarIcon).toBeInTheDocument(); + await user.hover(preferredRow as HTMLElement); + expect(idleStar.firstElementChild).toBe(idleStarIcon); + expect(idleStar).not.toHaveClass("animate-in", "fade-in"); + await user.unhover(preferredRow as HTMLElement); + expect(idleStar.firstElementChild).toBe(idleStarIcon); + + const starredToggle = within(picker).getByRole("button", { + name: "Unstar Other", + }); + expect(starredToggle).toHaveAttribute("data-slot", "button"); + expect(starredToggle).toHaveAttribute("aria-pressed", "true"); + expect(starredToggle).toHaveClass("text-foreground/80"); + expect(starredToggle).toHaveClass("hover:text-foreground/80"); + expect(starredToggle).not.toHaveClass( + "opacity-0", + "opacity-100", + "animate-in", + "fade-in", + ); + expect(starredToggle).not.toHaveClass("text-muted-foreground"); + }); + + it("does not restart the hover fade during a star click animation", async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click( + screen.getByRole("button", { name: /choose agent and model/i }), + ); + const star = screen.getByRole("button", { name: "Star Preferred" }); + const row = star.closest("[data-model-key]"); + expect(row).not.toBeNull(); + const starIcon = star.firstElementChild; + await user.hover(row as HTMLElement); + expect(star.firstElementChild).toBe(starIcon); + + await user.click(star); + expect(star).toHaveAttribute("data-star-animation-phase", "out"); + expect(star.firstElementChild).toBe(starIcon); + expect(star).not.toHaveClass( + "opacity-0", + "opacity-100", + "animate-in", + "fade-in", + ); + }); + + it("stores each star as its own entry so one toggle cannot drop another", async () => { + seedStar("goose", "other"); + __resetStarredModelsCacheForTests(); + const user = userEvent.setup(); + render( + , + ); + + await user.click( + screen.getByRole("button", { name: /choose agent and model/i }), + ); + const picker = screen.getByRole("dialog"); + await user.click(within(picker).getByRole("button", { name: "View more" })); + await user.click( + within(picker).getByRole("button", { name: "Star Another" }), + ); + + await waitFor(() => + expect( + localStorage.getItem( + starredModelStorageKey(modelStarKey("goose", "another")), + ), + ).toBe("1"), + ); + // Starring one model must leave every other star entry untouched; an + // aggregate rewrite from a stale snapshot would drop "other" here. + expect( + localStorage.getItem( + starredModelStorageKey(modelStarKey("goose", "other")), + ), + ).toBe("1"); + expect( + localStorage.getItem( + starredModelStorageKey(modelStarKey("goose", "another")), + ), + ).toBe("1"); + + await waitFor(() => + expect( + picker.querySelector("[data-star-animation-phase]"), + ).not.toBeInTheDocument(), + ); + await user.click( + within(picker).getByRole("button", { name: "Unstar Other" }), + ); + + await waitFor(() => + expect( + localStorage.getItem( + starredModelStorageKey(modelStarKey("goose", "other")), + ), + ).toBeNull(), + ); + expect( + localStorage.getItem( + starredModelStorageKey(modelStarKey("goose", "another")), + ), + ).toBe("1"); + }); + + it("surfaces a persist failure when starring cannot be saved", async () => { + const setItemSpy = vi + .spyOn(Storage.prototype, "setItem") + .mockImplementation(() => { + throw new DOMException("quota exceeded", "QuotaExceededError"); + }); + const user = userEvent.setup(); + render( + , + ); + + try { + await user.click( + screen.getByRole("button", { name: /choose agent and model/i }), + ); + const picker = screen.getByRole("dialog"); + await user.click( + within(picker).getByRole("button", { name: "View more" }), + ); + await user.click( + within(picker).getByRole("button", { name: "Star Another" }), + ); + + await waitFor(() => + expect(vi.mocked(toast.error)).toHaveBeenCalledTimes(1), + ); + expect(vi.mocked(toast.error)).toHaveBeenCalledWith( + expect.stringMatching(/starred/i), + ); + expect( + localStorage.getItem( + starredModelStorageKey(modelStarKey("goose", "another")), + ), + ).toBeNull(); + // The optimistic toggle must not stick when the write failed. + expect( + within(picker).getByRole("button", { name: "Star Another" }), + ).toHaveAttribute("aria-pressed", "false"); + } finally { + setItemSpy.mockRestore(); + } + }); + + it("surfaces a persist failure when unstarring cannot be saved", async () => { + seedStar("goose", "other"); + __resetStarredModelsCacheForTests(); + const removeItemSpy = vi + .spyOn(Storage.prototype, "removeItem") + .mockImplementation(() => { + throw new DOMException("quota exceeded", "QuotaExceededError"); + }); + const user = userEvent.setup(); + render( + , + ); + + try { + await user.click( + screen.getByRole("button", { name: /choose agent and model/i }), + ); + const picker = screen.getByRole("dialog"); + await user.click( + within(picker).getByRole("button", { name: "Unstar Other" }), + ); + + await waitFor(() => + expect(vi.mocked(toast.error)).toHaveBeenCalledTimes(1), + ); + expect(vi.mocked(toast.error)).toHaveBeenCalledWith( + expect.stringMatching(/starred/i), + ); + expect( + localStorage.getItem( + starredModelStorageKey(modelStarKey("goose", "other")), + ), + ).toBe("1"); + expect( + within(picker).getByRole("button", { name: "Unstar Other" }), + ).toHaveAttribute("aria-pressed", "true"); + } finally { + removeItemSpy.mockRestore(); + } + }); + it("renders a starred current model unstarred once its provider drops it", async () => { + seedStar("prov-a", "ghost"); + __resetStarredModelsCacheForTests(); + const user = userEvent.setup(); + render( + , + ); + + await user.click( + screen.getByRole("button", { name: /choose agent and model/i }), + ); + screen.getByRole("dialog"); + + // The dropped selection stays visible so the user can see what is in use... + const ghostRow = document.querySelector( + '[data-model-key=\'["prov-a","ghost"]\']', + ); + expect(ghostRow).toBeInTheDocument(); + // ...but it is no longer a favorite: no star state, no toggle, no divider. + expect(ghostRow).not.toHaveAttribute("data-starred"); + expect( + within(ghostRow as HTMLElement).queryByRole("button", { + name: /star ghost/i, + }), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("starred-models-divider"), + ).not.toBeInTheDocument(); + // The stored entry survives so the star returns if the model does. + expect( + localStorage.getItem( + starredModelStorageKey(modelStarKey("prov-a", "ghost")), + ), + ).toBe("1"); + }); + + it("hides a starred model that is no longer in the available list", async () => { + seedStar("goose", "ghost"); + __resetStarredModelsCacheForTests(); + const user = userEvent.setup(); + render( + , + ); + + await user.click( + screen.getByRole("button", { name: /choose agent and model/i }), + ); + + expect( + document.querySelector('[data-model-key=\'["goose","ghost"]\']'), + ).not.toBeInTheDocument(); + expect( + localStorage.getItem( + starredModelStorageKey(modelStarKey("goose", "ghost")), + ), + ).toBe("1"); + }); + + it("sorts favorites alphabetically across agents and providers", async () => { + seedStar("claude-acp", "zebra"); + seedStar("goose", "alpha"); + seedStar("codex-acp", "middle"); + __resetStarredModelsCacheForTests(); + const user = userEvent.setup(); + const favoriteModels = [ + { + agentId: "claude-acp", + model: { id: "zebra", name: "zebra" }, + }, + { + agentId: "goose", + model: { id: "alpha", name: "Alpha", providerId: "goose" }, + }, + { + agentId: "codex-acp", + model: { id: "middle", name: "Middle" }, + }, + ]; + render( + , + ); + + await user.click( + screen.getByRole("button", { name: /choose agent and model/i }), + ); + const picker = screen.getByRole("dialog"); + const favoriteKeys = Array.from( + picker.querySelectorAll('[data-starred="true"]'), + ).map((row) => row.getAttribute("data-model-key")); + expect(favoriteKeys).toEqual([ + modelStarKey("goose", "alpha"), + modelStarKey("codex-acp", "middle"), + modelStarKey("claude-acp", "zebra"), + ]); + }); + + it("keeps one stable row when unstarring Claude default", async () => { + seedStar("claude-acp", "default"); + __resetStarredModelsCacheForTests(); + const user = userEvent.setup(); + const selectedModels = [ + { id: "default", name: "Default" }, + { id: "sonnet", name: "Sonnet" }, + ]; + const favoriteModels = [ + { + agentId: "claude-acp", + // A distinct object mirrors the combined-catalog copy used by the app. + model: { id: "default", name: "Default" }, + }, + ]; + render( + , + ); + + await user.click( + screen.getByRole("button", { name: /choose agent and model/i }), + ); + const picker = screen.getByRole("dialog"); + await user.click( + within(picker).getByRole("button", { name: "Unstar Default" }), + ); + await waitFor(() => + expect( + localStorage.getItem( + starredModelStorageKey(modelStarKey("claude-acp", "default")), + ), + ).toBeNull(), + ); + + expect( + Array.from(picker.querySelectorAll("[data-model-key]")).filter( + (row) => + row.getAttribute("data-model-key") === + modelStarKey("claude-acp", "default"), + ), + ).toHaveLength(1); + }); + + it("scopes same-ID selected state to the active agent", async () => { + seedStar("claude-acp", "shared"); + __resetStarredModelsCacheForTests(); + const user = userEvent.setup(); + render( + , + ); + + await user.click( + screen.getByRole("button", { name: /choose agent and model/i }), + ); + const selectedRows = screen + .getByRole("dialog") + .querySelectorAll('[data-model-key][data-selected="true"]'); + expect(selectedRows).toHaveLength(1); + expect(selectedRows[0]).toHaveAttribute( + "data-model-key", + modelStarKey("goose", "shared"), + ); + expect( + screen.getByRole("button", { name: "Shared, Claude Code" }), + ).not.toHaveAttribute("data-selected"); + }); + + it("keeps favorites from other agents visible and switches before selecting", async () => { + seedStar("claude-acp", "opus"); + __resetStarredModelsCacheForTests(); + const user = userEvent.setup(); + const onAgentChange = vi.fn(); + const onModelChange = vi.fn(); + const favoriteModels = [ + ...models.map((model) => ({ agentId: "goose", model })), + { + agentId: "claude-acp", + model: { id: "opus", name: "Claude Opus" }, + }, + ]; + const { rerender } = render( + , + ); + + await user.click( + screen.getByRole("button", { name: /choose agent and model/i }), + ); + const picker = screen.getByRole("dialog"); + const claudeFavorite = within(picker) + .getByText("Claude Opus") + .closest("[data-model-key]"); + expect(claudeFavorite).toBeInTheDocument(); + expect( + within(claudeFavorite as HTMLElement).getByTitle("Claude"), + ).toBeInTheDocument(); + expect( + within(claudeFavorite as HTMLElement).getByText("Claude Code"), + ).toBeInTheDocument(); + const claudeModelButton = within(claudeFavorite as HTMLElement).getByRole( + "button", + { name: "Claude Opus, Claude Code" }, + ); + await user.click(claudeModelButton); + expect(onAgentChange).toHaveBeenCalledWith("claude-acp"); + expect(onModelChange).not.toHaveBeenCalled(); + + rerender( + , + ); + expect(onModelChange).toHaveBeenCalledWith( + "opus", + expect.objectContaining({ id: "opus" }), + ); + }); +}); diff --git a/src/features/design-system/generated/componentManifest.ts b/src/features/design-system/generated/componentManifest.ts index 9943ca385..2e70a3f88 100644 --- a/src/features/design-system/generated/componentManifest.ts +++ b/src/features/design-system/generated/componentManifest.ts @@ -595,12 +595,13 @@ export const designSystemComponentManifest = [ ], destructive: ["true", "false"], flush: ["true", "false"], + selected: ["true", "false"], }, defaultVariants: { variant: "primary", size: "default", }, - compoundVariantCount: 11, + compoundVariantCount: 12, tokenClasses: [ "active:text-foreground", "aria-expanded:text-foreground", @@ -622,9 +623,11 @@ export const designSystemComponentManifest = [ "hover:text-accent-foreground", "hover:text-destructive", "hover:text-foreground", + "hover:text-foreground/80", "text-accent-foreground", "text-destructive", "text-destructive-foreground", + "text-foreground/80", "text-muted-foreground", "text-primary", "text-primary-foreground", @@ -654,6 +657,7 @@ export const designSystemComponentManifest = [ "hover:text-current", "hover:text-destructive", "hover:text-foreground", + "hover:text-foreground/80", "hover:underline", ], sourceTokenClasses: [ @@ -677,9 +681,11 @@ export const designSystemComponentManifest = [ "hover:text-accent-foreground", "hover:text-destructive", "hover:text-foreground", + "hover:text-foreground/80", "text-accent-foreground", "text-destructive", "text-destructive-foreground", + "text-foreground/80", "text-muted-foreground", "text-primary", "text-primary-foreground", @@ -707,9 +713,12 @@ export const designSystemComponentManifest = [ "hover:text-accent-foreground", "hover:text-destructive", "hover:text-foreground", + "hover:text-foreground/80", + "hover:text-muted-foreground", "text-accent-foreground", "text-destructive", "text-destructive-foreground", + "text-foreground/80", "text-muted-foreground", "text-primary", "text-primary-foreground", @@ -721,6 +730,7 @@ export const designSystemComponentManifest = [ "aria-disabled:opacity-50", "aria-expanded:bg-transparent", "aria-expanded:text-foreground", + "aria-pressed", "data-[disabled=true]:opacity-50", "data-[state=open]:bg-transparent", "data-[state=open]:text-foreground", @@ -740,6 +750,8 @@ export const designSystemComponentManifest = [ "hover:text-current", "hover:text-destructive", "hover:text-foreground", + "hover:text-foreground/80", + "hover:text-muted-foreground", "hover:underline", ], sourceTokenClasses: [ @@ -763,9 +775,12 @@ export const designSystemComponentManifest = [ "hover:text-accent-foreground", "hover:text-destructive", "hover:text-foreground", + "hover:text-foreground/80", + "hover:text-muted-foreground", "text-accent-foreground", "text-destructive", "text-destructive-foreground", + "text-foreground/80", "text-muted-foreground", "text-primary", "text-primary-foreground", diff --git a/src/features/design-system/ui/designSystemSections.ts b/src/features/design-system/ui/designSystemSections.ts index 1a1729e85..d3aaeced5 100644 --- a/src/features/design-system/ui/designSystemSections.ts +++ b/src/features/design-system/ui/designSystemSections.ts @@ -124,6 +124,7 @@ export const DESIGN_SYSTEM_COMPONENT_SECTIONS: Array<{ { id: "component-progress", label: "Progress" }, { id: "component-radio-group", label: "Radio Group" }, { id: "component-scroll-area", label: "Scroll Area" }, + { id: "component-separator", label: "Separator" }, { id: "component-searchable-select", label: "Searchable Select" }, { id: "component-search-bar", label: "Search Bar" }, { @@ -168,7 +169,6 @@ export const DESIGN_SYSTEM_UNUSED_COMPONENT_SECTIONS: Array<{ { id: "component-page-columns", label: "Page Columns" }, { id: "component-pagination", label: "Pagination" }, { id: "component-resizable-handle", label: "Resizable Handle" }, - { id: "component-separator", label: "Separator" }, { id: "component-sidebar", label: "Sidebar" }, { id: "component-table", label: "Table" }, { id: "component-toggle", label: "Toggle" }, diff --git a/src/shared/i18n/locales/en/chat.json b/src/shared/i18n/locales/en/chat.json index a1a783d40..2cf1f4fa4 100644 --- a/src/shared/i18n/locales/en/chat.json +++ b/src/shared/i18n/locales/en/chat.json @@ -368,6 +368,7 @@ "voiceStopBeforeArchiveError": "Couldn't stop voice, so the chat wasn't archived", "gitInspectionError": "Couldn't inspect the worktrees or branches. The chat wasn't archived.", "gitCleanupError": "Chat archived, but Git cleanup couldn't finish", + "starredModelsPersistError": "Couldn't update your starred models. Device storage may be full or unavailable.", "moveError": "Failed to move chat", "renameError": "Failed to rename chat" }, @@ -636,7 +637,9 @@ "agent-speaking": "Agent is speaking…", "error": "Voice conversation error: {{error}}" } - } + }, + "starModel": "Star {{model}}", + "unstarModel": "Unstar {{model}}" }, "tools": { "content": "Content", diff --git a/src/shared/i18n/locales/es/chat.json b/src/shared/i18n/locales/es/chat.json index 720104f25..a5758a784 100644 --- a/src/shared/i18n/locales/es/chat.json +++ b/src/shared/i18n/locales/es/chat.json @@ -367,6 +367,7 @@ "voiceStopBeforeArchiveError": "No se pudo detener la conversación de voz, así que el chat no se archivó", "gitInspectionError": "No se pudieron inspeccionar los worktrees o las ramas. El chat no se archivó.", "gitCleanupError": "El chat se archivó, pero no se pudo completar la limpieza de Git", + "starredModelsPersistError": "No se pudieron actualizar tus modelos favoritos. El almacenamiento del dispositivo puede estar lleno o no disponible.", "moveError": "No se pudo mover el chat", "renameError": "No se pudo cambiar el nombre del chat" }, @@ -621,7 +622,9 @@ "agent-speaking": "El agente está hablando…", "error": "Error de conversación de voz: {{error}}" } - } + }, + "starModel": "Destacar {{model}}", + "unstarModel": "Quitar {{model}} de destacados" }, "tools": { "content": "Contenido", diff --git a/src/shared/styles/globals.test.ts b/src/shared/styles/globals.test.ts index f7e888cf5..20dc6bc04 100644 --- a/src/shared/styles/globals.test.ts +++ b/src/shared/styles/globals.test.ts @@ -93,3 +93,82 @@ describe("background token", () => { ); }); }); + +type TokenDeclarations = Map; + +function declarationsMap(selector: string): TokenDeclarations { + const map: TokenDeclarations = new Map(); + for (const match of declarationsFor(selector).matchAll( + /^\s*(--[\w-]+):\s*([^;]+);/gm, + )) { + map.set(match[1], match[2].trim()); + } + return map; +} + +/** Resolve a custom property to a literal color, following var() chains. */ +function resolveToken( + token: string, + theme: TokenDeclarations, + palette: TokenDeclarations, +): string { + let value: string = theme.get(token) ?? palette.get(token) ?? ""; + if (value === "") { + throw new Error(`Missing ${token}`); + } + const seen = new Set(); + for (;;) { + const ref = value.match(/^var\((--[\w-]+)(?:,\s*([^)]+))?\)$/); + if (!ref) { + return value; + } + if (seen.has(ref[1])) { + throw new Error(`Circular var() reference at ${ref[1]}`); + } + seen.add(ref[1]); + const next: string = + theme.get(ref[1]) ?? palette.get(ref[1]) ?? ref[2]?.trim() ?? ""; + if (next === "") { + throw new Error(`Unresolved var() reference ${ref[1]} in ${token}`); + } + value = next; + } +} + +function srgbChannelToLinear(channel: number): number { + return channel <= 0.04045 + ? channel / 12.92 + : ((channel + 0.055) / 1.055) ** 2.4; +} + +/** WCAG 2.x relative luminance of a #rrggbb color. */ +function relativeLuminance(hex: string): number { + const digits = hex.replace(/^#/, ""); + if (!/^[0-9a-fA-F]{6}$/.test(digits)) { + throw new Error(`Unsupported color for contrast math: ${hex}`); + } + const [red, green, blue] = [0, 2, 4].map((offset) => + srgbChannelToLinear(parseInt(digits.slice(offset, offset + 2), 16) / 255), + ); + return 0.2126 * red + 0.7152 * green + 0.0722 * blue; +} + +function contrastRatio(foreground: string, background: string): number { + const [lighter, darker] = [ + relativeLuminance(foreground), + relativeLuminance(background), + ].sort((left, right) => right - left); + return (lighter + 0.05) / (darker + 0.05); +} + +describe("muted-foreground on popover", () => { + it("clears 3:1 non-text contrast in both themes for icon-only controls (model picker star)", () => { + const palette = declarationsMap("@theme {"); + for (const selector of [":root {", '[data-theme="dark"],']) { + const theme = declarationsMap(selector); + const foreground = resolveToken("--muted-foreground", theme, palette); + const background = resolveToken("--popover", theme, palette); + expect(contrastRatio(foreground, background)).toBeGreaterThanOrEqual(3); + } + }); +}); diff --git a/src/shared/ui/GlobalComposerPill.tsx b/src/shared/ui/GlobalComposerPill.tsx index b115ac188..3d7a526d7 100644 --- a/src/shared/ui/GlobalComposerPill.tsx +++ b/src/shared/ui/GlobalComposerPill.tsx @@ -1513,6 +1513,12 @@ export function GlobalComposerPill({ } currentModelName={effectiveModelSelection?.modelName ?? null} availableModels={availableModels} + favoriteModels={pickerAgents.flatMap((agent) => + getModelsForAgent(agent.id).map((model) => ({ + agentId: agent.id, + model, + })), + )} modelsLoading={modelsLoading} modelStatusMessage={modelStatusMessage} onModelChange={handleModelChange} diff --git a/src/shared/ui/button.test.tsx b/src/shared/ui/button.test.tsx index 16a4a3e1c..4bb193e53 100644 --- a/src/shared/ui/button.test.tsx +++ b/src/shared/ui/button.test.tsx @@ -546,6 +546,28 @@ describe("preserveWidth duplicate label layers (BOT-1466)", () => { } }); + it("derives ghost toggle semantics from selected", () => { + const { rerender } = render( +