Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/src/components/chat/ModelSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,7 @@ export function ModelSelector({
className={cn(PICKER_PANEL_CLASS, "max-h-(--available-height) overflow-y-auto")}
>
<Picker
numberedShortcuts
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
Expand Down
45 changes: 43 additions & 2 deletions apps/web/src/components/chat/__tests__/ModelSelector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { useProviderAvailabilityStore } from "@/stores/providerAvailabilityStore
import { useModelFavoritesStore } from "@/stores/modelFavoritesStore";
import { MODEL_PROVIDERS } from "@/lib/model-registry";
import React from "react";
import { initShortcuts } from "@/lib/shortcuts";
import { registerCommand } from "@/lib/command-registry";

// @base-ui/react (used by Button) does not work in jsdom; stub with a native button.
vi.mock("@/components/ui/button", () => ({
Expand Down Expand Up @@ -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 ?? "";
});
}
Expand Down Expand Up @@ -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(<ModelSelector selectedModelId="claude-sonnet-4-6" selectedProviderId="claude"
onSelect={onSelect} locked={false} />);
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");
});
});
56 changes: 56 additions & 0 deletions apps/web/src/components/ui/__tests__/picker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Picker<string>
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(<NumberedPicker onSelect={() => {}} />);
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(<NumberedPicker onSelect={onSelect} />);
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(<NumberedPicker onSelect={onSelect} />);
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(<NumberedPicker onSelect={onSelect} numbered={false} />);
fireEvent.keyDown(screen.getByRole("combobox"), { key: "1", ctrlKey: true });
expect(onSelect).not.toHaveBeenCalled();
expect(chipOf("alpha")).toBeNull();
});
});
71 changes: 62 additions & 9 deletions apps/web/src/components/ui/picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -109,6 +112,8 @@ export interface PickerProps<T> {
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;
}

Expand All @@ -128,12 +133,14 @@ interface ListRow<T> {
* 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<T>(props: PickerProps<T>) {
const { query, onQueryChange, searchPlaceholder = "Search", items, renderItem, selectedKey, status, hairlines = true, selectedCheck = true } = props;
const listId = useId();
const rows = useMemo<ListRow<T>[]>(() => 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);
Expand Down Expand Up @@ -162,6 +169,17 @@ export function Picker<T>(props: PickerProps<T>) {
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<HTMLElement>) => {
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;
Expand All @@ -185,7 +203,7 @@ export function Picker<T>(props: PickerProps<T>) {

const hasRows = rows.length > 0;
return (
<div data-slot="picker" className="flex min-w-0 flex-col">
<div data-slot="picker" className="flex min-w-0 flex-col" onKeyDown={handleNumberShortcut}>
<div className="flex h-control-compact items-center gap-2 px-2">
<SearchIcon aria-hidden className="size-[1.4rem] shrink-0 text-muted" />
<input
Expand All @@ -208,6 +226,7 @@ export function Picker<T>(props: PickerProps<T>) {
listId={listId}
listKey={listKey}
rows={rows}
shortcutRows={shortcutRows}
activeIndex={activeIndex}
selectedKey={selectedKey}
onHighlight={highlightRow}
Expand Down Expand Up @@ -379,11 +398,23 @@ function useLoadMoreOnce<T>({ onLoadMore, items, total, status }: PickerProps<T>
}, [onLoadMore, status, total, items.length, visit.count]);
}

function useShortcutRows<T>(rows: readonly ListRow<T>[], 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<T> {
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<T>[];
readonly shortcutRows: readonly number[];
readonly activeIndex: number;
readonly selectedKey?: string;
readonly onHighlight: (index: number) => void;
Expand All @@ -395,7 +426,7 @@ interface PickerListProps<T> {
}

function PickerList<T>(props: PickerListProps<T>) {
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<HTMLUListElement>(null);

const measure = useCallback(() => {
Expand Down Expand Up @@ -434,6 +465,7 @@ function PickerList<T>(props: PickerListProps<T>) {
<PickerOption
id={optionId(listId, index)}
row={entry.row}
digit={shortcutDigit(shortcutRows, index)}
active={index === activeIndex}
selected={entry.row.key === selectedKey}
selectedCheck={selectedCheck}
Expand Down Expand Up @@ -466,9 +498,15 @@ function PickerGroupStart({ row, previous, hairlines }: { readonly row: PickerRo
);
}

function shortcutDigit(shortcutRows: readonly number[], index: number): number | undefined {
const position = shortcutRows.indexOf(index);
return position === -1 ? undefined : position + 1;
}

interface PickerOptionProps {
readonly id: string;
readonly row: PickerRow;
readonly digit?: number;
readonly active: boolean;
readonly selected: boolean;
readonly selectedCheck: boolean;
Expand All @@ -490,12 +528,12 @@ function PickerOption(props: PickerOptionProps) {
}

/** What an option reads after its name: why it is disabled, and how to run its action from the keyboard. */
function optionDescriptions(id: string, row: PickerRow, reason: string | undefined) {
function optionDescriptions(id: string, row: PickerRow, reason: string | undefined, digit: number | undefined) {
const actionHint = row.action ? `${row.action.label} (${ROW_ACTION_SHORTCUT.label})` : undefined;
const describedBy = [reason && `${id}-reason`, actionHint && `${id}-action`].filter(Boolean).join(" ");
return {
describedBy: describedBy || undefined,
keyShortcuts: actionHint ? ROW_ACTION_SHORTCUT.aria : undefined,
keyShortcuts: optionKeyShortcuts(digit, actionHint !== undefined),
spans: (
<>
{reason ? <span id={`${id}-reason`} hidden>{reason}</span> : null}
Expand All @@ -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 (
<li
{...(row.disabled ? undefined : { onMouseMove: active ? undefined : onHighlight, onClick: onPick })}
Expand All @@ -529,13 +572,13 @@ function PickerOptionItem({ id, row, active, selected, selectedCheck, onHighligh
)}
>
{/* A disabled row keeps its action for the pointer, so a starred row whose model went away can be unstarred. */}
<PickerOptionContent row={row} selected={selected} selectedCheck={selectedCheck} onRunAction={onRunAction} />
<PickerOptionContent row={row} digit={digit} selected={selected} selectedCheck={selectedCheck} onRunAction={onRunAction} />
{descriptions.spans}
</li>
);
}

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 ? (
Expand All @@ -553,6 +596,11 @@ function PickerOptionContent({ row, selected, selectedCheck, onRunAction }: { re
) : null}
</span>
{row.tag ? <span className="max-w-[60%] min-w-0 text-caption text-fade text-muted">{row.tag}</span> : null}
{digit !== undefined ? (
<span aria-hidden className="shrink-0 text-muted">
<Kbd variant="keycap">{formatKeybinding(`mod+${digit}`, isMac)}</Kbd>
</span>
) : null}
{row.action ? <PickerRowActionButton action={row.action} onRun={onRunAction} /> : null}
{selectedCheck ? (
<span aria-hidden className="flex size-[1.4rem] shrink-0 items-center justify-center">
Expand Down Expand Up @@ -591,6 +639,11 @@ function isRowActionShortcut(event: KeyboardEvent<HTMLInputElement>): boolean {
return (event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "d";
}

function numberShortcutDigit(event: KeyboardEvent<HTMLElement>): 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 (
<div role="alert" className="flex flex-col items-start gap-1 px-2 py-2">
Expand Down
48 changes: 48 additions & 0 deletions apps/web/src/stores/__tests__/modelFavoritesStore.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
4 changes: 2 additions & 2 deletions apps/web/src/stores/modelFavoritesStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -40,7 +40,7 @@ export const useModelFavoritesStore = create<ModelFavoritesState>()(
modelId: entry.modelId,
label,
};
return { entries: [row, ...s.entries].slice(0, MAX_FAVORITES) };
return { entries: [...s.entries, row].slice(-MAX_FAVORITES) };
});
},
isFavorite: (providerId, modelId) =>
Expand Down
Loading