From 93e2bab531ab6f5aa5f05e9a8e7a725ae7bab853 Mon Sep 17 00:00:00 2001 From: Nick Esposito Date: Wed, 2 Sep 2026 14:02:57 -0400 Subject: [PATCH] feat(chat): add global starred models Co-authored-by: Goose --- .../chat/hooks/useAgentModelPickerState.ts | 1 + .../chat/hooks/useResolvedAgentModelPicker.ts | 28 +- src/features/chat/hooks/useStarredModels.ts | 62 +++++ src/features/chat/lib/starredModels.ts | 86 ++++++ src/features/chat/types.ts | 2 + src/features/chat/ui/AgentModelPicker.tsx | 17 +- .../chat/ui/AgentModelPickerLists.tsx | 251 +++++++++++++----- .../ui/__tests__/AgentModelPicker.test.tsx | 208 ++++++++++++++- .../design-system/ui/designSystemSections.ts | 2 +- src/shared/i18n/locales/en/chat.json | 4 +- src/shared/i18n/locales/es/chat.json | 4 +- src/shared/ui/GlobalComposerPill.tsx | 16 +- 12 files changed, 573 insertions(+), 108 deletions(-) create mode 100644 src/features/chat/hooks/useStarredModels.ts create mode 100644 src/features/chat/lib/starredModels.ts diff --git a/src/features/chat/hooks/useAgentModelPickerState.ts b/src/features/chat/hooks/useAgentModelPickerState.ts index 6293edcf1..878a4fc48 100644 --- a/src/features/chat/hooks/useAgentModelPickerState.ts +++ b/src/features/chat/hooks/useAgentModelPickerState.ts @@ -175,6 +175,7 @@ export function useAgentModelPickerState({ availableModels.find((model) => model.id === modelId); onModelSelected?.({ id: modelId, + agentId: selectedModelOverride?.agentId, name: selectedModel?.name ?? modelId, displayName: selectedModel?.displayName ?? modelId, provider: selectedModel?.provider, diff --git a/src/features/chat/hooks/useResolvedAgentModelPicker.ts b/src/features/chat/hooks/useResolvedAgentModelPicker.ts index d21d8a4b9..6311e7903 100644 --- a/src/features/chat/hooks/useResolvedAgentModelPicker.ts +++ b/src/features/chat/hooks/useResolvedAgentModelPicker.ts @@ -506,20 +506,21 @@ export function useResolvedAgentModelPicker({ }); }, onModelSelected: (model) => { + const targetAgentId = model.agentId ?? selectedAgentId; const modelId = model.id; const modelName = model.displayName ?? model.name ?? model.id; const nextModelProviderId = model.providerId ?? session?.executionTarget?.modelProviderId ?? - (selectedAgentId === "goose" ? undefined : selectedAgentId); + (targetAgentId === "goose" ? undefined : targetAgentId); if (!nextModelProviderId) { console.warn("Dropped model selection without a model provider", { - harnessId: selectedAgentId, + harnessId: targetAgentId, modelId, }); return; } - const nextTarget = targetFromAgentModelSelection(selectedAgentId, { + const nextTarget = targetFromAgentModelSelection(targetAgentId, { modelProviderId: nextModelProviderId, modelId, modelName, @@ -541,7 +542,7 @@ export function useResolvedAgentModelPicker({ if (!sessionId) { setPendingExecutionTarget(nextTarget); - setGlobalSelectedProvider(selectedAgentId); + setGlobalSelectedProvider(targetAgentId); setPendingModelSelection(nextModelSelection); return; } @@ -562,7 +563,7 @@ export function useResolvedAgentModelPicker({ const requestId = createModelSelectionRequestId(); const previousStoredModelPreference = - getStoredModelPreference(selectedAgentId); + getStoredModelPreference(targetAgentId); const previousTarget = session.executionTarget; const providerChanged = nextTarget.modelProviderId !== previousTarget?.modelProviderId; @@ -571,13 +572,13 @@ export function useResolvedAgentModelPicker({ // the draft and let draft promotion configure the real backend session. if (session.creationState === "pending") { if (providerChanged && !sessionHasStarted) { - setGlobalSelectedProvider(selectedAgentId); + setGlobalSelectedProvider(targetAgentId); } beginModelSelectionIntent(sessionId, { requestId, target: nextTarget, previousTarget, - preferenceAgentId: selectedAgentId, + preferenceAgentId: targetAgentId, }); return; } @@ -588,7 +589,7 @@ export function useResolvedAgentModelPicker({ previousTarget, }); if (providerChanged && !sessionHasStarted) { - setGlobalSelectedProvider(selectedAgentId); + setGlobalSelectedProvider(targetAgentId); } void (async () => { @@ -609,10 +610,7 @@ export function useResolvedAgentModelPicker({ return; } if (!sessionHasStarted) { - setStoredModelPreference( - selectedAgentId, - nextStoredModelPreference, - ); + setStoredModelPreference(targetAgentId, nextStoredModelPreference); } } catch (error) { const intentStillMatches = clearCurrentModelSelectionIntent( @@ -635,7 +633,7 @@ export function useResolvedAgentModelPicker({ ? undefined : () => setStoredModelPreference( - selectedAgentId, + targetAgentId, nextStoredModelPreference, ), ) @@ -649,11 +647,11 @@ export function useResolvedAgentModelPicker({ if (!sessionHasStarted) { if (previousStoredModelPreference) { setStoredModelPreference( - selectedAgentId, + targetAgentId, previousStoredModelPreference, ); } else { - clearStoredModelPreference(selectedAgentId); + clearStoredModelPreference(targetAgentId); } } rollbackToPreviousModel({ diff --git a/src/features/chat/hooks/useStarredModels.ts b/src/features/chat/hooks/useStarredModels.ts new file mode 100644 index 000000000..204ae5757 --- /dev/null +++ b/src/features/chat/hooks/useStarredModels.ts @@ -0,0 +1,62 @@ +import { useCallback, useSyncExternalStore } from "react"; +import type { ModelOption } from "../types"; +import { + getStarredModels, + modelStarKey, + STARRED_MODELS_CHANGED_EVENT, + STARRED_MODELS_STORAGE_KEY, + starredModelKey, + toggleModelStar, + type StarredModelRecord, +} from "../lib/starredModels"; + +const EMPTY_RECORDS: StarredModelRecord[] = []; +let cachedSnapshot: StarredModelRecord[] | null = null; + +export function __resetStarredModelsCacheForTests(): void { + cachedSnapshot = null; +} + +function getSnapshot(): StarredModelRecord[] { + cachedSnapshot ??= getStarredModels(); + return cachedSnapshot; +} + +function subscribe(callback: () => void): () => void { + const update = () => { + cachedSnapshot = null; + callback(); + }; + const onStorage = (event: StorageEvent) => { + if (event.key === null || event.key === STARRED_MODELS_STORAGE_KEY) + update(); + }; + window.addEventListener(STARRED_MODELS_CHANGED_EVENT, update); + window.addEventListener("storage", onStorage); + return () => { + window.removeEventListener(STARRED_MODELS_CHANGED_EVENT, update); + window.removeEventListener("storage", onStorage); + }; +} + +export function useStarredModels() { + const starredModels = useSyncExternalStore( + subscribe, + getSnapshot, + () => EMPTY_RECORDS, + ); + const starredKeys = new Set(starredModels.map(starredModelKey)); + const isStarred = useCallback( + (agentId: string, model: ModelOption) => + starredKeys.has(modelStarKey(agentId, model.providerId, model.id)), + [starredKeys], + ); + + return { + starredModels, + isStarred, + toggleStar: useCallback((agentId: string, model: ModelOption) => { + toggleModelStar(agentId, model); + }, []), + }; +} diff --git a/src/features/chat/lib/starredModels.ts b/src/features/chat/lib/starredModels.ts new file mode 100644 index 000000000..0bc6c6a73 --- /dev/null +++ b/src/features/chat/lib/starredModels.ts @@ -0,0 +1,86 @@ +import type { ModelOption } from "../types"; + +export const STARRED_MODELS_STORAGE_KEY = "berd:starred-models-v2"; +export const STARRED_MODELS_CHANGED_EVENT = "berd:starred-models-v2-changed"; + +export interface StarredModelRecord { + agentId: string; + model: ModelOption; +} + +export function modelStarKey( + agentId: string, + modelProviderId: string | undefined, + modelId: string, +): string { + return JSON.stringify([agentId, modelProviderId ?? "", modelId]); +} + +export function starredModelKey(record: StarredModelRecord): string { + return modelStarKey(record.agentId, record.model.providerId, record.model.id); +} + +function isModelOption(value: unknown): value is ModelOption { + if (!value || typeof value !== "object") return false; + const model = value as Partial; + return typeof model.id === "string" && typeof model.name === "string"; +} + +function isStarredModelRecord(value: unknown): value is StarredModelRecord { + if (!value || typeof value !== "object") return false; + const record = value as Partial; + return typeof record.agentId === "string" && isModelOption(record.model); +} + +export function getStarredModels(): StarredModelRecord[] { + if (typeof window === "undefined") return []; + + try { + const parsed: unknown = JSON.parse( + window.localStorage.getItem(STARRED_MODELS_STORAGE_KEY) ?? "[]", + ); + if (!Array.isArray(parsed)) return []; + + const seen = new Set(); + return parsed.filter((value): value is StarredModelRecord => { + if (!isStarredModelRecord(value)) return false; + const key = starredModelKey(value); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + } catch { + return []; + } +} + +function persistStarredModels(records: StarredModelRecord[]): void { + try { + if (records.length === 0) { + window.localStorage.removeItem(STARRED_MODELS_STORAGE_KEY); + } else { + window.localStorage.setItem( + STARRED_MODELS_STORAGE_KEY, + JSON.stringify(records), + ); + } + } catch { + // localStorage may be unavailable. + } + window.dispatchEvent(new CustomEvent(STARRED_MODELS_CHANGED_EVENT)); +} + +export function toggleModelStar(agentId: string, model: ModelOption): void { + const records = getStarredModels(); + const key = modelStarKey(agentId, model.providerId, model.id); + const existingIndex = records.findIndex( + (record) => starredModelKey(record) === key, + ); + + if (existingIndex >= 0) { + records.splice(existingIndex, 1); + } else { + records.push({ agentId, model }); + } + persistStarredModels(records); +} diff --git a/src/features/chat/types.ts b/src/features/chat/types.ts index 4313411a1..9a11e0cbb 100644 --- a/src/features/chat/types.ts +++ b/src/features/chat/types.ts @@ -14,6 +14,8 @@ import type { SessionExecutionTarget } from "./lib/sessionExecutionTarget"; export interface ModelOption { id: string; + /** Agent that owns this row when it comes from the global starred section. */ + agentId?: string; name: string; displayName?: string; provider?: string; diff --git a/src/features/chat/ui/AgentModelPicker.tsx b/src/features/chat/ui/AgentModelPicker.tsx index 2adfbb91d..d7ac52a82 100644 --- a/src/features/chat/ui/AgentModelPicker.tsx +++ b/src/features/chat/ui/AgentModelPicker.tsx @@ -79,8 +79,8 @@ type PopoverContentAlign = NonNullable< ComponentProps["align"] >; const REASONING_EFFORT_COLUMN_TRANSITION_MS = 240; -const PICKER_WIDTH_COMPACT_PX = 420; -const PICKER_WIDTH_EXPANDED_PX = 596; +const PICKER_WIDTH_COMPACT_PX = 452; +const PICKER_WIDTH_EXPANDED_PX = 628; function toSentenceCaseLabel(value: string | undefined): string { const trimmed = value?.trim(); @@ -367,8 +367,13 @@ export function AgentModelPicker({ }; const handleModelSelect = (model: ModelOption) => { - recordModelSelection(selectedAgentId, model); - onModelChange?.(model.id, model); + const targetAgentId = model.agentId ?? selectedAgentId; + const selectedModel = { ...model, agentId: undefined }; + recordModelSelection(targetAgentId, selectedModel); + onModelChange?.(selectedModel.id, { + ...selectedModel, + agentId: targetAgentId, + }); }; // Re-gate the provider column when the popover closes, so every reopen @@ -500,7 +505,7 @@ 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-[39.25rem]" : "w-[28.25rem]", )} onInteractOutside={(event) => { classifyOutsideInteraction(event.target); @@ -672,7 +677,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 ? ( diff --git a/src/features/chat/ui/AgentModelPickerLists.tsx b/src/features/chat/ui/AgentModelPickerLists.tsx index 7ad2902bd..50771b109 100644 --- a/src/features/chat/ui/AgentModelPickerLists.tsx +++ b/src/features/chat/ui/AgentModelPickerLists.tsx @@ -7,15 +7,25 @@ import { useRef, useState, } from "react"; -import { IconCheck, IconDots, IconSearch, IconX } from "@tabler/icons-react"; +import { + IconCheck, + IconDots, + IconSearch, + IconStar, + IconStarFilled, + IconX, +} from "@tabler/icons-react"; import { SearchBar } from "@/shared/ui/SearchBar"; import { Button } from "@/shared/ui/button"; import { ScrollArea } from "@/shared/ui/scroll-area"; +import { Separator } from "@/shared/ui/separator"; import { formatProviderLabel, getProviderIcon, } from "@/shared/ui/icons/ProviderIcons"; import type { ModelOption } from "../types"; +import { useStarredModels } from "../hooks/useStarredModels"; +import { modelStarKey } from "../lib/starredModels"; import { getModelRecencyRank, type ModelRecencyMap, @@ -48,6 +58,25 @@ function getGooseModelProviderLabel(model: ModelOption) { return null; } +function compareStarredModels(left: ModelOption, right: ModelOption): number { + const nameComparison = getModelDisplayName(left).localeCompare( + getModelDisplayName(right), + ); + if (nameComparison !== 0) return nameComparison; + + const agentComparison = (left.agentId ?? "").localeCompare( + right.agentId ?? "", + ); + if (agentComparison !== 0) return agentComparison; + + const providerComparison = (left.providerId ?? "").localeCompare( + right.providerId ?? "", + ); + if (providerComparison !== 0) return providerComparison; + + return left.id.localeCompare(right.id); +} + function compareModelsByProviderOrderAndName( left: ModelOption, right: ModelOption, @@ -92,12 +121,17 @@ 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); const rightRank = getModelRecencyRank(recency.map, recency.agentId, right); @@ -127,7 +161,7 @@ interface ModelListProps { * would interrupt browsing. */ onBrowseChange?: (browsing: boolean) => void; - t: (key: string) => string; + t: (key: string, options?: Record) => string; } export interface RecommendedModelListHandle { @@ -149,6 +183,7 @@ export const RecommendedModelList = forwardRef< }, ref, ) { + const { starredModels, isStarred, toggleStar } = useStarredModels(); const [searchOpen, setSearchOpen] = useState(false); const [showAll, setShowAll] = useState(false); const [query, setQuery] = useState(""); @@ -248,39 +283,82 @@ export const RecommendedModelList = forwardRef< }, [onBrowseChange]); const visibleModels = useMemo(() => { - if (!searchOpen && !showAll) { - return recommended; - } + const baseModels = !searchOpen && !showAll ? recommended : models; const normalizedQuery = query.trim().toLowerCase(); - if (!normalizedQuery) { - return models; - } - return models.filter( - (model) => - model.name.toLowerCase().includes(normalizedQuery) || - model.id.toLowerCase().includes(normalizedQuery) || - model.displayName?.toLowerCase().includes(normalizedQuery) || - model.providerName?.toLowerCase().includes(normalizedQuery) || - model.providerId?.toLowerCase().includes(normalizedQuery), + const matchesQuery = (model: ModelOption) => + !normalizedQuery || + model.name.toLowerCase().includes(normalizedQuery) || + model.id.toLowerCase().includes(normalizedQuery) || + model.displayName?.toLowerCase().includes(normalizedQuery) || + model.providerName?.toLowerCase().includes(normalizedQuery) || + model.providerId?.toLowerCase().includes(normalizedQuery); + const globalStarred = starredModels.map(({ agentId, model }) => ({ + ...model, + agentId, + })); + const starredKeys = new Set( + globalStarred.map((model) => + modelStarKey( + model.agentId ?? selectedAgentId, + model.providerId, + model.id, + ), + ), ); - }, [models, query, recommended, searchOpen, showAll]); + return [ + // The global starred section is always visible, even while browsing or + // filtering another agent's inventory. + ...globalStarred, + ...baseModels.filter( + (model) => + !starredKeys.has( + modelStarKey(selectedAgentId, model.providerId, model.id), + ) && matchesQuery(model), + ), + ]; + }, [ + models, + query, + recommended, + searchOpen, + showAll, + starredModels, + selectedAgentId, + ]); - const sorted = useMemo( - () => - sortModels(visibleModels, currentModelId, currentModelProviderId, { + const grouped = useMemo(() => { + const starred: ModelOption[] = []; + const unstarred: ModelOption[] = []; + for (const model of visibleModels) { + (model.agentId ? starred : unstarred).push(model); + } + return { + starred: [...starred].sort(compareStarredModels), + unstarred: sortModels(unstarred, currentModelId, currentModelProviderId, { map: recencyMap, agentId: selectedAgentId, }), - [ - visibleModels, - currentModelId, - currentModelProviderId, - recencyMap, - selectedAgentId, - ], - ); + }; + }, [ + visibleModels, + currentModelId, + currentModelProviderId, + recencyMap, + selectedAgentId, + ]); + const sorted = [...grouped.starred, ...grouped.unstarred]; - const hasMore = models.length > recommended.length; + const recommendedKeys = new Set( + recommended.map((model) => + modelStarKey(selectedAgentId, model.providerId, model.id), + ), + ); + const hasMore = models.some( + (model) => + !recommendedKeys.has( + modelStarKey(selectedAgentId, model.providerId, model.id), + ), + ); const showSearchButton = hasMore || recommended.length > SEARCHABLE_LIST_THRESHOLD; const closeSearch = useCallback(() => { @@ -369,44 +447,85 @@ export const RecommendedModelList = forwardRef< className="min-h-0 min-w-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:!block" >
- {sorted.map((model) => { + {sorted.map((model, index) => { const providerLabel = getGooseModelProviderLabel(model); - const providerIcon = - selectedAgentId === "goose" && model.providerId + const rowAgentId = model.agentId ?? selectedAgentId; + const providerIcon = model.agentId + ? getProviderIcon(model.agentId, "size-3.5") + : selectedAgentId === "goose" && model.providerId ? getProviderIcon(model.providerId, "size-3.5") : null; - const isSelected = modelMatchesSelection( - model, - currentModelId, - currentModelProviderId, - ); + const isSelected = + rowAgentId === selectedAgentId && + modelMatchesSelection( + model, + currentModelId, + currentModelProviderId, + ); + const starred = isStarred(rowAgentId, model); + const showDivider = + index === grouped.starred.length - 1 && + grouped.unstarred.length > 0; return ( - { - onModelSelect(model); - resetView(); - }} - selected={isSelected} - className="justify-between" - > -
- {providerIcon ? ( - - {providerIcon} - - ) : null} -
- {getModelDisplayName(model)} -
+
+
+ { + onModelSelect({ ...model, agentId: rowAgentId }); + resetView(); + }} + selected={isSelected} + className="w-auto flex-1 justify-between" + > +
+ {providerIcon ? ( + + {providerIcon} + + ) : null} +
+ {getModelDisplayName(model)} +
+
+ {isSelected ? ( + + ) : null} +
+
- {isSelected ? ( - + {showDivider ? ( + ) : null} - +
); })} {hasMore && !searchOpen && !showAll ? ( diff --git a/src/features/chat/ui/__tests__/AgentModelPicker.test.tsx b/src/features/chat/ui/__tests__/AgentModelPicker.test.tsx index 056f71e6f..f7e78cf92 100644 --- a/src/features/chat/ui/__tests__/AgentModelPicker.test.tsx +++ b/src/features/chat/ui/__tests__/AgentModelPicker.test.tsx @@ -2,6 +2,8 @@ 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 { __resetStarredModelsCacheForTests } from "../../hooks/useStarredModels"; +import { STARRED_MODELS_STORAGE_KEY } from "../../lib/starredModels"; import { AgentModelPicker } from "../AgentModelPicker"; import { getModelRecencyMap, @@ -232,7 +234,7 @@ 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( @@ -269,7 +271,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(); }); @@ -878,7 +880,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-[28.25rem]"); expect(within(picker).getByText("Claude Sonnet 4")).toBeInTheDocument(); expect(within(picker).queryByText("GPT-4o mini")).not.toBeInTheDocument(); expect( @@ -940,7 +942,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-[28.25rem]"); if (modelViewport) { modelViewport.scrollTop = 120; @@ -967,7 +969,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 @@ -1411,11 +1413,11 @@ describe("AgentModelPicker", () => { await openPicker(user); const content = document.querySelector('[data-slot="popover-content"]'); - expect(content).toHaveClass("w-[26.25rem]"); + expect(content).toHaveClass("w-[28.25rem]"); await user.click(screen.getByRole("button", { name: /switch agent/i })); - expect(content).toHaveClass("w-[37.25rem]"); + expect(content).toHaveClass("w-[39.25rem]"); }); it("hides the switch-agent button when the only agent is ready", async () => { @@ -1742,5 +1744,197 @@ describe("AgentModelPicker", () => { "Zeta Model", ]); }); + + describe("starred models", () => { + const models = [ + { id: "current", name: "Current", recommended: true }, + { id: "preferred", name: "Preferred", recommended: true }, + { id: "recent", name: "Recent" }, + { id: "starred", name: "Starred" }, + { id: "other", name: "Other" }, + ]; + + const openStarredPicker = async () => { + const user = userEvent.setup(); + render( + , + ); + await user.click( + screen.getByRole("button", { name: /choose agent and model/i }), + ); + return { user, picker: screen.getByRole("dialog") }; + }; + + afterEach(() => __resetStarredModelsCacheForTests()); + + it("shows star actions on compact model rows", async () => { + const { picker } = await openStarredPicker(); + expect( + within(picker).getByRole("button", { name: "Star Current" }), + ).toBeInTheDocument(); + expect( + within(picker).getByRole("button", { name: "Star Preferred" }), + ).toBeInTheDocument(); + }); + + it("puts all stars above recent and preferred models", async () => { + recordModelSelection("goose", { id: "recent" }); + localStorage.setItem( + STARRED_MODELS_STORAGE_KEY, + JSON.stringify([ + { + agentId: "goose", + model: { id: "starred", name: "Starred" }, + }, + ]), + ); + __resetStarredModelsCacheForTests(); + const { picker } = await openStarredPicker(); + const rows = Array.from( + picker.querySelectorAll( + '[data-col="model"] button[data-picker-nav-item]', + ), + ).map((button) => button.textContent); + + expect(rows.slice(0, 4)).toEqual([ + "Starred", + "Current", + "Recent", + "Preferred", + ]); + expect( + screen.getByTestId("starred-models-divider"), + ).toBeInTheDocument(); + }); + + it("keeps global stars in alphabetical order when selection changes", async () => { + localStorage.setItem( + STARRED_MODELS_STORAGE_KEY, + JSON.stringify([ + { + agentId: "goose", + model: { id: "zulu", name: "Zulu" }, + }, + { + agentId: "claude-acp", + model: { id: "alpha", name: "Alpha" }, + }, + { + agentId: "codex-acp", + model: { id: "middle", name: "Middle" }, + }, + ]), + ); + __resetStarredModelsCacheForTests(); + const user = userEvent.setup(); + const { rerender } = render( + , + ); + await user.click( + screen.getByRole("button", { name: /choose agent and model/i }), + ); + const starredNames = () => + Array.from( + document.querySelectorAll( + '[data-col="model"] [data-starred="true"] button[data-picker-nav-item]', + ), + ).map((button) => button.querySelector(".truncate")?.textContent); + + expect(starredNames()).toEqual(["Alpha", "Middle", "Zulu"]); + + rerender( + , + ); + + expect(starredNames()).toEqual(["Alpha", "Middle", "Zulu"]); + }); + + it("stars a model without selecting it", async () => { + const onModelChange = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + await user.click( + screen.getByRole("button", { name: /choose agent and model/i }), + ); + await user.click( + screen.getByRole("button", { name: "Star Preferred" }), + ); + expect(onModelChange).not.toHaveBeenCalled(); + expect( + screen.getByTestId("starred-models-divider"), + ).toBeInTheDocument(); + }); + + it("shows and selects global stars from another agent", async () => { + localStorage.setItem( + STARRED_MODELS_STORAGE_KEY, + JSON.stringify([ + { + agentId: "claude-acp", + model: { id: "haiku", name: "Haiku" }, + }, + ]), + ); + __resetStarredModelsCacheForTests(); + const onAgentChange = vi.fn(); + const onModelChange = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + await user.click( + screen.getByRole("button", { name: /choose agent and model/i }), + ); + await user.click(screen.getByRole("button", { name: "Haiku" })); + + expect(onAgentChange).not.toHaveBeenCalled(); + expect(onModelChange).toHaveBeenCalledWith( + "haiku", + expect.objectContaining({ agentId: "claude-acp", id: "haiku" }), + ); + }); + }); }); }); 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 103dc0536..0624dc3aa 100644 --- a/src/shared/i18n/locales/en/chat.json +++ b/src/shared/i18n/locales/en/chat.json @@ -624,7 +624,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..83bd07df3 100644 --- a/src/shared/i18n/locales/es/chat.json +++ b/src/shared/i18n/locales/es/chat.json @@ -621,7 +621,9 @@ "agent-speaking": "El agente está hablando…", "error": "Error de conversación de voz: {{error}}" } - } + }, + "starModel": "Destacar {{model}}", + "unstarModel": "Quitar destaque de {{model}}" }, "tools": { "content": "Contenido", diff --git a/src/shared/ui/GlobalComposerPill.tsx b/src/shared/ui/GlobalComposerPill.tsx index b115ac188..4fe8cf87d 100644 --- a/src/shared/ui/GlobalComposerPill.tsx +++ b/src/shared/ui/GlobalComposerPill.tsx @@ -484,22 +484,16 @@ export function GlobalComposerPill({ setSelectedProvider(providerId); }, onModelSelected: (model) => { - const selection = modelOptionToSelection( - model, - selectedProviderForPicker, - ); + const targetAgentId = model.agentId ?? selectedAgentId; + const selection = modelOptionToSelection(model, targetAgentId); personaOverrideUserOverrideForRef.current = selectedPersonaId; personaOverrideActiveRef.current = false; onExecutionTargetChange?.( - executionTargetForSelection( - selectedAgentId, - selection, - selectedProviderForPicker, - ), + executionTargetForSelection(targetAgentId, selection, targetAgentId), ); - setProviderOverride(selectedAgentId); + setProviderOverride(targetAgentId); setModelOverride(selection); - setSelectedProvider(selectedAgentId); + setSelectedProvider(targetAgentId); }, });