diff --git a/apps/web/src/components/chat/ModelSelector.tsx b/apps/web/src/components/chat/ModelSelector.tsx index 624fbf12c..b6cce16dc 100644 --- a/apps/web/src/components/chat/ModelSelector.tsx +++ b/apps/web/src/components/chat/ModelSelector.tsx @@ -613,6 +613,7 @@ export function ModelSelector({ className={cn(PICKER_PANEL_CLASS, "max-h-(--available-height) overflow-y-auto")} > ({ @@ -63,10 +65,10 @@ async function openPicker() { } function optionNames(): string[] { - // Hidden description spans are part of textContent, so drop them to read only what the row shows. + // Hidden description spans and shortcut keycaps are part of textContent, so drop them to read only the row name. return screen.getAllByRole("option").map((option) => { const visible = option.cloneNode(true) as HTMLElement; - visible.querySelectorAll("[hidden]").forEach((node) => node.remove()); + visible.querySelectorAll("[hidden], [data-slot=kbd]").forEach((node) => node.remove()); return visible.textContent ?? ""; }); } @@ -310,4 +312,43 @@ describe("ModelSelector", () => { expect(within(dialog).queryByRole("option")).toBeNull(); expect(screen.getByLabelText("Model locked while the agent runs")).toBeInTheDocument(); }); + + async function openCodexTab(onSelect: () => void) { + setProviders({ claude: true, codex: true }); + listProviderModels.mockResolvedValue([ + { id: "gpt-9-zeta", name: "GPT-9 Zeta", group: "OpenAI" }, + { id: "gpt-9-alpha", name: "GPT-9 Alpha", group: "OpenAI" }, + ]); + render(); + await openPicker(); + await userEvent.click(screen.getByRole("radio", { name: "Codex" })); + await waitFor(() => expect(optionNames()).toEqual(["GPT-9 Zeta", "GPT-9 Alpha"])); + } + + it("picks the second model with Ctrl+2 and closes", async () => { + const onSelect = vi.fn(); + await openCodexTab(onSelect); + + await userEvent.keyboard("{Control>}2{/Control}"); + + expect(onSelect).toHaveBeenCalledWith("gpt-9-alpha", "codex"); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + }); + + it("picks a model with Ctrl+1 instead of switching threads", async () => { + onTestFinished(initShortcuts()); + const goTo1 = vi.fn(); + onTestFinished(registerCommand({ id: "thread.goTo1", title: "Go to thread 1", category: "Navigation", handler: goTo1 })); + await userEvent.keyboard("{Control>}1{/Control}"); + expect(goTo1).toHaveBeenCalledTimes(1); + goTo1.mockClear(); + + const onSelect = vi.fn(); + await openCodexTab(onSelect); + await userEvent.keyboard("{Control>}1{/Control}"); + + expect(goTo1).not.toHaveBeenCalled(); + expect(onSelect).toHaveBeenCalledWith("gpt-9-zeta", "codex"); + }); }); diff --git a/apps/web/src/components/ui/__tests__/picker.test.tsx b/apps/web/src/components/ui/__tests__/picker.test.tsx index c6d40d0d2..2ecbb9365 100644 --- a/apps/web/src/components/ui/__tests__/picker.test.tsx +++ b/apps/web/src/components/ui/__tests__/picker.test.tsx @@ -455,3 +455,59 @@ describe("Picker", () => { expect(screen.getByRole("option", { name: "branch-008" })).not.toHaveAttribute("data-selected"); }); }); + +function NumberedPicker({ onSelect, numbered = true }: { readonly onSelect: (name: string) => void; readonly numbered?: boolean }) { + const [query, setQuery] = useState(""); + const names = ["alpha", "bravo", "charlie", "delta"].filter((name) => name.includes(query)); + return ( + + numberedShortcuts={numbered} + query={query} + onQueryChange={setQuery} + items={names} + total={names.length} + status="ready" + renderItem={(name) => ({ key: name, name, disabled: name === "bravo" })} + onSelect={onSelect} + /> + ); +} + +function chipOf(name: string): string | null { + return screen.getByRole("option", { name: new RegExp(name) }).querySelector("[data-slot=kbd]")?.textContent ?? null; +} + +describe("Picker numbered shortcuts", () => { + it("numbers only the selectable rows", () => { + render( {}} />); + expect(["alpha", "bravo", "charlie", "delta"].map(chipOf)).toEqual(["Ctrl+1", null, "Ctrl+2", "Ctrl+3"]); + expect(screen.getByRole("option", { name: /charlie/ })).toHaveAttribute("aria-keyshortcuts", "Control+2"); + }); + + it("picks the Nth selectable row with Ctrl or Meta, skipping disabled rows", () => { + const onSelect = vi.fn(); + render(); + const search = screen.getByRole("combobox"); + fireEvent.keyDown(search, { key: "2", ctrlKey: true }); + fireEvent.keyDown(search, { key: "2", metaKey: true }); + expect(onSelect.mock.calls).toEqual([["charlie"], ["charlie"]]); + }); + + it("renumbers the rows a search leaves", async () => { + const onSelect = vi.fn(); + render(); + const search = screen.getByRole("combobox"); + await userEvent.type(search, "del"); + expect(chipOf("delta")).toBe("Ctrl+1"); + fireEvent.keyDown(search, { key: "1", ctrlKey: true }); + expect(onSelect).toHaveBeenCalledWith("delta"); + }); + + it("does nothing without the prop", () => { + const onSelect = vi.fn(); + render(); + fireEvent.keyDown(screen.getByRole("combobox"), { key: "1", ctrlKey: true }); + expect(onSelect).not.toHaveBeenCalled(); + expect(chipOf("alpha")).toBeNull(); + }); +}); diff --git a/apps/web/src/components/ui/picker.tsx b/apps/web/src/components/ui/picker.tsx index 635a862e2..fb1dcc387 100644 --- a/apps/web/src/components/ui/picker.tsx +++ b/apps/web/src/components/ui/picker.tsx @@ -13,7 +13,10 @@ import { } from "react"; import { CheckIcon, SearchIcon } from "lucide-react"; +import { formatKeybinding } from "@/lib/keybinding-manager"; +import { isMac } from "@/lib/platform"; import { cn } from "@/lib/utils"; +import { Kbd } from "./kbd"; import { Button } from "./button"; import { SegmentedControl } from "./segmented-control"; import { Spinner } from "./spinner"; @@ -109,6 +112,8 @@ export interface PickerProps { readonly hairlines?: boolean; /** Set false to mark the selected row by its fill alone, without the trailing check. Defaults to true. */ readonly selectedCheck?: boolean; + /** Ctrl/Cmd+1..9 pick the Nth selectable row, and each such row shows its keycap. Defaults to false. */ + readonly numberedShortcuts?: boolean; readonly onSelect: (item: T) => void; } @@ -128,12 +133,14 @@ interface ListRow { * scroll fades, and a count footer. It renders the panel body only, so callers choose the surface * (popover, dialog or inline); put the search field first so the host's initial focus lands on it. * Focus stays in the search field; the arrow keys move a highlight through the listbox via - * `aria-activedescendant`, Home and End reach its ends, and Enter picks the highlighted row. + * `aria-activedescendant`, Home and End reach its ends, and Enter picks the highlighted row. With + * `numberedShortcuts`, Ctrl/Cmd+1..9 pick the first nine selectable rows directly. */ export function Picker(props: PickerProps) { const { query, onQueryChange, searchPlaceholder = "Search", items, renderItem, selectedKey, status, hairlines = true, selectedCheck = true } = props; const listId = useId(); const rows = useMemo[]>(() => items.map((item) => ({ item, row: renderItem(item) })), [items, renderItem]); + const shortcutRows = useShortcutRows(rows, props.numberedShortcuts === true); const listKey = `${props.activeTab ?? ""}\u0000${query}`; const { activeIndex, setHighlight } = useActiveRow(rows, listKey, selectedKey); const loadMore = useLoadMoreOnce(props, listKey); @@ -162,6 +169,17 @@ export function Picker(props: PickerProps) { moveHighlight(target); }; + // Bound on the panel, not the search field: clicking a tab moves focus onto it, and the chord must still pick a row. + const handleNumberShortcut = (event: KeyboardEvent) => { + const digit = props.numberedShortcuts && !event.nativeEvent.isComposing ? numberShortcutDigit(event) : null; + if (digit === null) return; + // Stopping here keeps the chord from the browser's tab switch and the global Ctrl+N thread switch. + event.preventDefault(); + event.stopPropagation(); + const index = shortcutRows[digit - 1]; + if (index !== undefined) select(rows[index]); + }; + const runRowAction = (index: number) => { const action = rows[index]?.row.action; if (!action) return; @@ -185,7 +203,7 @@ export function Picker(props: PickerProps) { const hasRows = rows.length > 0; return ( -
+
(props: PickerProps) { listId={listId} listKey={listKey} rows={rows} + shortcutRows={shortcutRows} activeIndex={activeIndex} selectedKey={selectedKey} onHighlight={highlightRow} @@ -379,11 +398,23 @@ function useLoadMoreOnce({ onLoadMore, items, total, status }: PickerProps }, [onLoadMore, status, total, items.length, visit.count]); } +function useShortcutRows(rows: readonly ListRow[], enabled: boolean): readonly number[] { + return useMemo(() => { + if (!enabled) return []; + const indexes: number[] = []; + for (let index = 0; index < rows.length && indexes.length < 9; index += 1) { + if (!rows[index]?.row.disabled) indexes.push(index); + } + return indexes; + }, [rows, enabled]); +} + interface PickerListProps { readonly listId: string; /** Changes when the tab or query starts a new list, which scrolls back to the top. */ readonly listKey: string; readonly rows: readonly ListRow[]; + readonly shortcutRows: readonly number[]; readonly activeIndex: number; readonly selectedKey?: string; readonly onHighlight: (index: number) => void; @@ -395,7 +426,7 @@ interface PickerListProps { } function PickerList(props: PickerListProps) { - const { listId, listKey, rows, activeIndex, selectedKey, onHighlight, onPick, onRunAction, onNearEnd, hairlines, selectedCheck } = props; + const { listId, listKey, rows, shortcutRows, activeIndex, selectedKey, onHighlight, onPick, onRunAction, onNearEnd, hairlines, selectedCheck } = props; const listRef = useRef(null); const measure = useCallback(() => { @@ -434,6 +465,7 @@ function PickerList(props: PickerListProps) { {reason ? : null} @@ -505,8 +543,13 @@ function optionDescriptions(id: string, row: PickerRow, reason: string | undefin }; } -function PickerOptionItem({ id, row, active, selected, selectedCheck, onHighlight, onPick, onRunAction, reason, ...triggerProps }: PickerOptionProps & { readonly reason: string | undefined }) { - const descriptions = optionDescriptions(id, row, reason); +function optionKeyShortcuts(digit: number | undefined, hasAction: boolean): string | undefined { + const pick = digit === undefined ? undefined : `${isMac ? "Meta" : "Control"}+${digit}`; + return [pick, hasAction ? ROW_ACTION_SHORTCUT.aria : undefined].filter(Boolean).join(" ") || undefined; +} + +function PickerOptionItem({ id, row, digit, active, selected, selectedCheck, onHighlight, onPick, onRunAction, reason, ...triggerProps }: PickerOptionProps & { readonly reason: string | undefined }) { + const descriptions = optionDescriptions(id, row, reason, digit); return (
  • {/* A disabled row keeps its action for the pointer, so a starred row whose model went away can be unstarred. */} - + {descriptions.spans}
  • ); } -function PickerOptionContent({ row, selected, selectedCheck, onRunAction }: { readonly row: PickerRow; readonly selected: boolean; readonly selectedCheck: boolean; readonly onRunAction: () => void }) { +function PickerOptionContent({ row, digit, selected, selectedCheck, onRunAction }: { readonly row: PickerRow; readonly digit: number | undefined; readonly selected: boolean; readonly selectedCheck: boolean; readonly onRunAction: () => void }) { return ( <> {row.icon != null ? ( @@ -553,6 +596,11 @@ function PickerOptionContent({ row, selected, selectedCheck, onRunAction }: { re ) : null} {row.tag ? {row.tag} : null} + {digit !== undefined ? ( + + {formatKeybinding(`mod+${digit}`, isMac)} + + ) : null} {row.action ? : null} {selectedCheck ? ( @@ -591,6 +639,11 @@ function isRowActionShortcut(event: KeyboardEvent): boolean { return (event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "d"; } +function numberShortcutDigit(event: KeyboardEvent): number | null { + if (!(event.ctrlKey || event.metaKey) || event.altKey || event.shiftKey) return null; + return /^[1-9]$/.test(event.key) ? Number(event.key) : null; +} + function PickerFailure({ status, onRetry }: { readonly status: { readonly failed: string; readonly detail?: string }; readonly onRetry?: () => void }) { return (
    diff --git a/apps/web/src/stores/__tests__/modelFavoritesStore.test.ts b/apps/web/src/stores/__tests__/modelFavoritesStore.test.ts new file mode 100644 index 000000000..c8ba17e2a --- /dev/null +++ b/apps/web/src/stores/__tests__/modelFavoritesStore.test.ts @@ -0,0 +1,48 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach } from "vitest"; +import { useModelFavoritesStore, type ModelFavoriteEntry } from "../modelFavoritesStore"; + +function fav(modelId: string): ModelFavoriteEntry { + return { providerId: "claude", modelId, label: modelId }; +} + +const ids = () => useModelFavoritesStore.getState().entries.map((e) => e.modelId); +const toggle = (modelId: string) => useModelFavoritesStore.getState().toggleFavorite(fav(modelId)); + +describe("modelFavoritesStore", () => { + beforeEach(() => { + localStorage.clear(); + useModelFavoritesStore.setState({ entries: [] }); + }); + + it("appends a new favourite so existing ones keep their positions", () => { + toggle("a"); + toggle("b"); + toggle("c"); + expect(ids()).toEqual(["a", "b", "c"]); + }); + + it("unstarring removes only that entry", () => { + ["a", "b", "c"].forEach(toggle); + toggle("b"); + expect(ids()).toEqual(["a", "c"]); + }); + + it("keeps a persisted order as loaded", async () => { + localStorage.setItem( + "mcode-model-favorites", + JSON.stringify({ state: { entries: [fav("z"), fav("a"), fav("m")] }, version: 0 }), + ); + await useModelFavoritesStore.persist.rehydrate(); + expect(ids()).toEqual(["z", "a", "m"]); + toggle("n"); + expect(ids()).toEqual(["z", "a", "m", "n"]); + }); + + it("evicts the oldest favourite past the 40 cap", () => { + const forty = Array.from({ length: 40 }, (_, i) => `m${i}`); + useModelFavoritesStore.setState({ entries: forty.map(fav) }); + toggle("new"); + expect(ids()).toEqual([...forty.slice(1), "new"]); + }); +}); diff --git a/apps/web/src/stores/modelFavoritesStore.ts b/apps/web/src/stores/modelFavoritesStore.ts index 22a997489..1e1572d81 100644 --- a/apps/web/src/stores/modelFavoritesStore.ts +++ b/apps/web/src/stores/modelFavoritesStore.ts @@ -14,7 +14,7 @@ export interface ModelFavoriteEntry { /** Persisted favorite models for {@link ModelSelector}. */ interface ModelFavoritesState { entries: ModelFavoriteEntry[]; - /** Adds or removes a favorite keyed by provider and model id. */ + /** Adds or removes a favorite keyed by provider and model id. New favorites append; the oldest drops at the cap. */ toggleFavorite: (entry: ModelFavoriteEntry) => void; isFavorite: (providerId: string, modelId: string) => boolean; } @@ -40,7 +40,7 @@ export const useModelFavoritesStore = create()( modelId: entry.modelId, label, }; - return { entries: [row, ...s.entries].slice(0, MAX_FAVORITES) }; + return { entries: [...s.entries, row].slice(-MAX_FAVORITES) }; }); }, isFavorite: (providerId, modelId) =>