From f3e879206700c1f89d22d77758d64cdd49f4134e Mon Sep 17 00:00:00 2001 From: "(CJ) Chukwudi Nwobodo" <142016413+chuks-qua@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:41:06 +0100 Subject: [PATCH 1/4] feat(models): mark legacy model generations in the catalog Add an optional lifecycle flag (current or legacy, absent means current) to ProviderModelInfo. Claude flags models superseded by a newer release in the same family, in its static fallback and in discovery, including dated snapshots. --- packages/contracts/src/index.ts | 5 +-- .../providers/__tests__/static-models.test.ts | 34 ++++++++++++++++++- .../src/providers/claude-static-fallback.ts | 30 ++++++++++++++-- packages/contracts/src/providers/models.ts | 8 +++++ .../claude/__tests__/list-models.test.ts | 3 +- .../src/private/claude/list-models.ts | 3 +- 6 files changed, 76 insertions(+), 7 deletions(-) diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 9222077d9..0328f9053 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1602,13 +1602,14 @@ export { } from "./providers/devin-models.js"; export { CURSOR_CLI_MODEL_SNAPSHOT } from "./providers/cursor-cli-models-snapshot.js"; export { CODEX_STATIC_MODELS, supportsCodexUltraOrchestration } from "./providers/codex-static-fallback.js"; -export { CLAUDE_STATIC_MODELS } from "./providers/claude-static-fallback.js"; +export { CLAUDE_STATIC_MODELS, isLegacyClaudeModelId } from "./providers/claude-static-fallback.js"; export { ProviderModelInfoSchema, ModelPolicyStateSchema, + ModelLifecycleSchema, } from "./providers/models.js"; -export type { ProviderModelInfo } from "./providers/models.js"; +export type { ProviderModelInfo, ModelLifecycle } from "./providers/models.js"; export { isChildTurnCancellable, isTurnDiffSource, diff --git a/packages/contracts/src/providers/__tests__/static-models.test.ts b/packages/contracts/src/providers/__tests__/static-models.test.ts index 1fef281e8..7bc729c4c 100644 --- a/packages/contracts/src/providers/__tests__/static-models.test.ts +++ b/packages/contracts/src/providers/__tests__/static-models.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { CLAUDE_STATIC_MODELS } from "../claude-static-fallback.js"; +import { CLAUDE_STATIC_MODELS, isLegacyClaudeModelId } from "../claude-static-fallback.js"; import { CODEX_STATIC_MODELS } from "../codex-static-fallback.js"; import { ProviderModelInfoSchema } from "../models.js"; @@ -11,3 +11,35 @@ describe("static provider catalogs", () => { }, ); }); + +describe("Claude model lifecycle", () => { + it("folds superseded generations away and keeps the newest of each family current", () => { + const legacyIds = CLAUDE_STATIC_MODELS.filter((row) => row.lifecycle === "legacy").map((row) => row.id); + expect(legacyIds).toEqual([ + "claude-opus-5", + "claude-fable-5", + "claude-sonnet-5", + "claude-opus-4-8", + "claude-opus-4-7", + "claude-opus-4-6", + "claude-sonnet-4-6", + ]); + }); + + it("leaves current models unflagged, so absent means current", () => { + const current = CLAUDE_STATIC_MODELS.filter((row) => row.lifecycle !== "legacy"); + expect(current.map((row) => [row.id, row.lifecycle])).toEqual([ + ["claude-opus-5-5", undefined], + ["claude-sonnet-5-5", undefined], + ["claude-fable-5-1", undefined], + ["claude-haiku-4-5", undefined], + ]); + }); + + it("recognises dated discovery ids of a legacy model without matching a newer sibling", () => { + expect(isLegacyClaudeModelId("claude-sonnet-4-6-20250514")).toBe(true); + expect(isLegacyClaudeModelId("claude-opus-5")).toBe(true); + expect(isLegacyClaudeModelId("claude-opus-5-5")).toBe(false); + expect(isLegacyClaudeModelId("claude-opus-5-5-20261001")).toBe(false); + }); +}); diff --git a/packages/contracts/src/providers/claude-static-fallback.ts b/packages/contracts/src/providers/claude-static-fallback.ts index 50893e632..fa94e7ed1 100644 --- a/packages/contracts/src/providers/claude-static-fallback.ts +++ b/packages/contracts/src/providers/claude-static-fallback.ts @@ -1,7 +1,33 @@ import type { ProviderModelInfo } from "./models.js"; +/** Claude models superseded by a newer release in the same family. The picker folds these away. */ +const CLAUDE_LEGACY_MODEL_IDS: ReadonlySet = new Set([ + "claude-opus-5", + "claude-fable-5", + "claude-sonnet-5", + "claude-opus-4-8", + "claude-opus-4-7", + "claude-opus-4-6", + "claude-sonnet-4-6", +]); + +/** Snapshot suffix the Anthropic Models API appends to an alias, e.g. `-20250514`. */ +const DATED_SNAPSHOT_SUFFIX = /-\d{8}$/; + +/** + * True when a Claude model id, or a dated snapshot of one, is a legacy generation. The suffix + * must be a date: a plain prefix match would fold `claude-opus-5-5` under `claude-opus-5`. + */ +export function isLegacyClaudeModelId(id: string): boolean { + return CLAUDE_LEGACY_MODEL_IDS.has(id.replace(DATED_SNAPSHOT_SUFFIX, "")); +} + +function withClaudeLifecycle(model: ProviderModelInfo): ProviderModelInfo { + return isLegacyClaudeModelId(model.id) ? { ...model, lifecycle: "legacy" } : model; +} + /** Complete Claude fallback catalog shared by server discovery and web pickers. */ -export const CLAUDE_STATIC_MODELS: readonly ProviderModelInfo[] = [ +export const CLAUDE_STATIC_MODELS: readonly ProviderModelInfo[] = ([ ...[ ["claude-opus-5-5", "Claude Opus 5.5"], ["claude-opus-5", "Claude Opus 5"], @@ -38,4 +64,4 @@ export const CLAUDE_STATIC_MODELS: readonly ProviderModelInfo[] = [ contextWindow: 200_000, supportsReasoning: false, }, -]; +] satisfies ProviderModelInfo[]).map(withClaudeLifecycle); diff --git a/packages/contracts/src/providers/models.ts b/packages/contracts/src/providers/models.ts index f177c7c23..d6c544e3b 100644 --- a/packages/contracts/src/providers/models.ts +++ b/packages/contracts/src/providers/models.ts @@ -5,6 +5,12 @@ import { lazySchema } from "../utils/lazySchema.js"; /** Policy state for a provider model (read-only, set by org admins). */ export const ModelPolicyStateSchema = z.enum(["enabled", "disabled", "unconfigured"]); +/** Whether a model is a current generation or an older one the picker folds away. */ +export const ModelLifecycleSchema = z.enum(["current", "legacy"]); + +/** TypeScript type inferred from ModelLifecycleSchema. */ +export type ModelLifecycle = z.infer; + /** Model metadata returned by a provider's listModels() call. */ export const ProviderModelInfoSchema = lazySchema(() => z.object({ @@ -30,6 +36,8 @@ export const ProviderModelInfoSchema = lazySchema(() => supportsFastMode: z.boolean().optional(), /** Context windows the user can choose between (e.g. ["200k", "1m"]). Absent means no choice. */ contextWindowOptions: z.array(ContextWindowModeSchema).optional(), + /** Model generation. Absent means current; legacy models sit behind the picker's fold. */ + lifecycle: ModelLifecycleSchema.optional(), /** Org/enterprise policy state. Absent when no policy is configured. */ policy: z.object({ state: ModelPolicyStateSchema }).optional(), /** Billing rate multiplier relative to base plan (e.g. 1, 0.33, 3, 30). */ diff --git a/packages/providers/src/private/claude/__tests__/list-models.test.ts b/packages/providers/src/private/claude/__tests__/list-models.test.ts index a07edc3e8..2c97c5396 100644 --- a/packages/providers/src/private/claude/__tests__/list-models.test.ts +++ b/packages/providers/src/private/claude/__tests__/list-models.test.ts @@ -67,7 +67,7 @@ describe("listClaudeModels", () => { }); }); - it("derives capabilities for dated REST-only ids from the runtime effort rules", async () => { + it("derives capabilities and lifecycle for dated REST-only ids", async () => { const result = await listClaudeModels(); expect(result.find((model) => model.id === "claude-sonnet-4-6-20250514")).toEqual({ id: "claude-sonnet-4-6-20250514", @@ -76,6 +76,7 @@ describe("listClaudeModels", () => { contextWindowOptions: ["200k", "1m"], supportsReasoning: true, supportedReasoningEfforts: ["low", "medium", "high", "max"], + lifecycle: "legacy", }); expect(result.find((model) => model.id === "claude-haiku-4-5-20251001")).toEqual({ id: "claude-haiku-4-5-20251001", diff --git a/packages/providers/src/private/claude/list-models.ts b/packages/providers/src/private/claude/list-models.ts index c13589ad6..7b323334b 100644 --- a/packages/providers/src/private/claude/list-models.ts +++ b/packages/providers/src/private/claude/list-models.ts @@ -13,7 +13,7 @@ import { supports1MContextWindow, supportsEffortParameter, } from "@mcode/shared/model-effort"; -import { CLAUDE_STATIC_MODELS } from "@mcode/contracts"; +import { CLAUDE_STATIC_MODELS, isLegacyClaudeModelId } from "@mcode/contracts"; import type { ProviderModelInfo } from "@mcode/contracts"; /** Cache TTL: 5 minutes, matching the design spec. */ @@ -62,6 +62,7 @@ function fromAnthropicModel(model: AnthropicModelInfo): ProviderModelInfo { supportsReasoning: supportedReasoningEfforts.length > 0, ...(supportedReasoningEfforts.length > 0 ? { supportedReasoningEfforts } : {}), ...(supports1MContextWindow(model.id) ? { contextWindowOptions: ["200k", "1m"] as const } : {}), + ...(isLegacyClaudeModelId(model.id) ? { lifecycle: "legacy" as const } : {}), }; } From e67f939d0710dd82f7772a60d68878506c6db17e Mon Sep 17 00:00:00 2001 From: "(CJ) Chukwudi Nwobodo" <142016413+chuks-qua@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:41:06 +0100 Subject: [PATCH 2/4] feat(web): fold legacy models behind a row in the model picker A provider tab lists current models, then a Legacy models row with its count and a chevron that shows or hides the legacy models in place. The fold opens when the selected model is legacy, and a search lists legacy matches unfolded. --- apps/web/src/__tests__/model-registry.test.ts | 1 + .../web/src/components/chat/ModelSelector.tsx | 118 ++++++++++++++---- .../chat/__tests__/ModelSelector.test.tsx | 82 ++++++++++++ apps/web/src/components/ui/picker.tsx | 16 ++- apps/web/src/lib/model-registry.ts | 5 +- 5 files changed, 196 insertions(+), 26 deletions(-) diff --git a/apps/web/src/__tests__/model-registry.test.ts b/apps/web/src/__tests__/model-registry.test.ts index ae8be38f0..e26587bbd 100644 --- a/apps/web/src/__tests__/model-registry.test.ts +++ b/apps/web/src/__tests__/model-registry.test.ts @@ -112,6 +112,7 @@ describe("ModelRegistry", () => { contextWindowOptions: ["200k", "1m"], supportedReasoningLevels: ["low", "medium", "high", "xhigh", "max"], defaultReasoningLevel: "high", + lifecycle: "legacy", }); }); diff --git a/apps/web/src/components/chat/ModelSelector.tsx b/apps/web/src/components/chat/ModelSelector.tsx index b6cce16dc..2ea44ba3d 100644 --- a/apps/web/src/components/chat/ModelSelector.tsx +++ b/apps/web/src/components/chat/ModelSelector.tsx @@ -8,6 +8,7 @@ import { findModelById, isModelAvailable, registerProviderModels, + toModelDefinition, type ModelProvider, } from "@/lib/model-registry"; import { getTransport } from "@/transport"; @@ -94,6 +95,7 @@ interface ProviderModelCatalog { /** One model the picker can list, from a provider catalog or the favourites. */ interface ModelOption { + readonly kind: "model"; readonly modelId: string; readonly providerId: string; readonly label: string; @@ -105,6 +107,25 @@ interface ModelOption { readonly showProvider: boolean; } +/** The row at the end of a provider tab that shows or hides its legacy models. */ +interface LegacyFoldOption { + readonly kind: "legacy-fold"; + readonly providerId: string; + readonly count: number; + readonly expanded: boolean; +} + +type ModelPickerOption = ModelOption | LegacyFoldOption; + +/** Divides the legacy fold and the models behind it from the current models above. */ +const LEGACY_GROUP = "legacy"; + +/** + * The fold follows the selected model until the user toggles it: `auto` opens it only when the + * selected model is legacy, so a thread's own model is never hidden behind it. + */ +type LegacyFoldState = "auto" | "open" | "closed"; + function findDisplayProvider( selectedProviderId: string | undefined, normalizedModelId: string, @@ -272,16 +293,7 @@ function useProviderModelCatalog(): ProviderModelCatalog { setLoadingProviders((currentProviders) => new Set(currentProviders).add(providerId)); try { const providerModels = await getTransport().listProviderModels(providerId); - const models: ModelProvider["models"] = providerModels.map((model) => ({ - id: model.id, - label: model.name, - providerId, - group: model.group, - contextWindow: model.contextWindow, - supportedReasoningLevels: model.supportedReasoningEfforts, - defaultReasoningLevel: model.defaultReasoningEffort, - multiplier: model.multiplier, - })); + const models = providerModels.map((model) => toModelDefinition(providerId, model)); const updatedModels = new Map(dynamicModelsRef.current).set(providerId, models); dynamicModelsRef.current = updatedModels; setDynamicModels(updatedModels); @@ -368,25 +380,57 @@ function formatEndDate(availableUntil: string): string { }); } -function providerModelOptions( +function isLegacyModel(model: ModelProvider["models"][number]): boolean { + return model.lifecycle === "legacy"; +} + +/** Whether the legacy fold shows its models, given the user's toggle and the selected model. */ +function isLegacyFoldExpanded( + foldState: LegacyFoldState, + models: ModelProvider["models"], + selectedModelId: string | undefined, +): boolean { + if (foldState !== "auto") return foldState === "open"; + return models.some((model) => isLegacyModel(model) && model.id === selectedModelId); +} + +function catalogModelOptions( providerId: string, models: ModelProvider["models"], - searchQuery: string, + groupOverride?: string, ): ModelOption[] { - const filteredModels = filterModelsBySearchQuery(models, searchQuery); - const groups = groupModels(filteredModels) ?? [{ label: undefined, models: filteredModels }]; + const groups = groupModels(models) ?? [{ label: undefined, models }]; return groups.flatMap((group) => group.models.map((model) => ({ + kind: "model" as const, modelId: model.id, providerId, label: model.label, - group: group.label, + group: groupOverride ?? group.label, endedOn: accessEndedOn(model), showProvider: false, })), ); } +function providerModelOptions( + providerId: string, + models: ModelProvider["models"], + searchQuery: string, + legacyExpanded: boolean, +): ModelPickerOption[] { + // A search reaches every model, so a legacy match is listed with the rest instead of behind the fold. + if (tokenizeSearch(searchQuery).length > 0) { + return catalogModelOptions(providerId, filterModelsBySearchQuery(models, searchQuery)); + } + const currentOptions = catalogModelOptions(providerId, models.filter((model) => !isLegacyModel(model))); + const legacyModels = models.filter(isLegacyModel); + if (legacyModels.length === 0) return currentOptions; + const fold: LegacyFoldOption = { kind: "legacy-fold", providerId, count: legacyModels.length, expanded: legacyExpanded }; + const legacyOptions = legacyExpanded ? catalogModelOptions(providerId, legacyModels, LEGACY_GROUP) : []; + return [...currentOptions, fold, ...legacyOptions]; +} + function accessEndedOn(model: ModelProvider["models"][number]): string | undefined { return model.availableUntil && !isModelAvailable(model) ? formatEndDate(model.availableUntil) : undefined; } @@ -400,6 +444,7 @@ function favoriteModelOptions( const provider = MODEL_PROVIDERS.find((entry) => entry.id === favorite.providerId); const model = provider && getModels(provider).find((entry) => entry.id === favorite.modelId); return { + kind: "model" as const, modelId: favorite.modelId, providerId: favorite.providerId, label: favorite.label, @@ -445,6 +490,16 @@ function favoriteAction( }; } +function legacyFoldRow(option: LegacyFoldOption): PickerRow { + return { + key: `${option.providerId}:legacy-fold`, + name: "Legacy models", + tag: String(option.count), + expanded: option.expanded, + group: LEGACY_GROUP, + }; +} + function LockedModelLabel({ iconProviderId, shortLabel, @@ -507,6 +562,7 @@ export function ModelSelector({ const [open, setOpen] = useState(false); const [activeTab, setActiveTab] = useState(FAVORITES_TAB); const [query, setQuery] = useState(""); + const [legacyFold, setLegacyFold] = useState("auto"); const favorites = useModelFavoritesStore((store) => store.entries); const toggleFavorite = useModelFavoritesStore((store) => store.toggleFavorite); @@ -538,16 +594,21 @@ export function ModelSelector({ [favorites], ); - const items = useMemo(() => { + const selectedModelInTab = presentation.selectedProviderId === activeTab ? presentation.normalizedModelId : undefined; + const items = useMemo((): ModelPickerOption[] => { if (activeTab === FAVORITES_TAB) { return favoriteModelOptions(filterFavoritesBySearchQuery(favoritesVisible, query), getModels); } const provider = MODEL_PROVIDERS.find((entry) => entry.id === activeTab); - return provider ? providerModelOptions(provider.id, getModels(provider), query) : []; - }, [activeTab, favoritesVisible, getModels, query]); + if (!provider) return []; + const models = getModels(provider); + const legacyExpanded = isLegacyFoldExpanded(legacyFold, models, selectedModelInTab); + return providerModelOptions(provider.id, models, query, legacyExpanded); + }, [activeTab, favoritesVisible, getModels, query, legacyFold, selectedModelInTab]); const renderItem = useCallback( - (option: ModelOption): PickerRow => { + (option: ModelPickerOption): PickerRow => { + if (option.kind === "legacy-fold") return legacyFoldRow(option); const key = optionKey(option.providerId, option.modelId); const starred = starredKeys.has(key); return { @@ -569,11 +630,20 @@ export function ModelSelector({ [starredKeys, toggleFavorite], ); - useResetPickerOnOpen(open, providerLocked, displayProvider, defaultProviderId, setActiveTab, setQuery); + const showTab = useCallback((tab: ModelPickerTab) => { + setActiveTab(tab); + setLegacyFold("auto"); + }, []); + + useResetPickerOnOpen(open, providerLocked, displayProvider, defaultProviderId, showTab, setQuery); useFetchProviderModelsWhenOpen(open, locked, activeTab, favoritesVisible, fetchProviderModels); - const handleSelectModel = (modelId: string, providerId: string) => { - onSelect(modelId, providerId); + const handlePick = (option: ModelPickerOption) => { + if (option.kind === "legacy-fold") { + setLegacyFold(option.expanded ? "closed" : "open"); + return; + } + onSelect(option.modelId, option.providerId); setOpen(false); }; @@ -616,7 +686,7 @@ export function ModelSelector({ numberedShortcuts tabs={tabs} activeTab={activeTab} - onTabChange={setActiveTab} + onTabChange={showTab} query={query} onQueryChange={setQuery} searchPlaceholder="Search models" @@ -629,7 +699,7 @@ export function ModelSelector({ : undefined } renderItem={renderItem} - onSelect={(option) => handleSelectModel(option.modelId, option.providerId)} + onSelect={handlePick} footer={settings} hairlines={false} selectedCheck={false} diff --git a/apps/web/src/components/chat/__tests__/ModelSelector.test.tsx b/apps/web/src/components/chat/__tests__/ModelSelector.test.tsx index 989a7c4a2..257006b7f 100644 --- a/apps/web/src/components/chat/__tests__/ModelSelector.test.tsx +++ b/apps/web/src/components/chat/__tests__/ModelSelector.test.tsx @@ -351,4 +351,86 @@ describe("ModelSelector", () => { expect(goTo1).not.toHaveBeenCalled(); expect(onSelect).toHaveBeenCalledWith("gpt-9-zeta", "codex"); }); + + describe("legacy models", () => { + const LIVE_CLAUDE = [ + { id: "claude-opus-5-5", name: "Claude Opus 5.5" }, + { id: "claude-opus-5", name: "Claude Opus 5", lifecycle: "legacy" }, + { id: "claude-sonnet-5-5", name: "Claude Sonnet 5.5" }, + { id: "claude-sonnet-5", name: "Claude Sonnet 5", lifecycle: "legacy" }, + ]; + + async function openClaudeTab(selectedModelId: string, onSelect = vi.fn()) { + listProviderModels.mockResolvedValue(LIVE_CLAUDE); + render(); + await openPicker(); + await waitFor(() => expect(optionNames()[0]).toBe("Claude Opus 5.5")); + } + + it("folds legacy models behind a counted row that reveals and hides them in place", async () => { + const onSelect = vi.fn(); + await openClaudeTab("claude-opus-5-5", onSelect); + + expect(optionNames()).toEqual(["Claude Opus 5.5", "Claude Sonnet 5.5", "Legacy models2"]); + const fold = screen.getByRole("option", { name: /Legacy models/ }); + expect(fold).toHaveAttribute("aria-expanded", "false"); + + await userEvent.click(fold); + expect(optionNames()).toEqual([ + "Claude Opus 5.5", "Claude Sonnet 5.5", "Legacy models2", "Claude Opus 5", "Claude Sonnet 5", + ]); + expect(screen.getByRole("option", { name: /Legacy models/ })).toHaveAttribute("aria-expanded", "true"); + expect(onSelect).not.toHaveBeenCalled(); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("option", { name: /Legacy models/ })); + expect(optionNames()).toEqual(["Claude Opus 5.5", "Claude Sonnet 5.5", "Legacy models2"]); + }); + + it("opens the fold with Enter from the search field", async () => { + await openClaudeTab("claude-opus-5-5"); + await waitFor(() => expect(screen.getByRole("combobox")).toHaveFocus()); + + await userEvent.keyboard("{End}{Enter}"); + + expect(screen.getByRole("option", { name: /Claude Opus 5$/ })).toBeInTheDocument(); + }); + + it("opens the fold on the selected legacy model so it is never hidden", async () => { + await openClaudeTab("claude-sonnet-5"); + + const selected = screen.getByRole("option", { selected: true }); + expect(selected).toHaveTextContent("Claude Sonnet 5"); + expect(screen.getByRole("option", { name: /Legacy models/ })).toHaveAttribute("aria-expanded", "true"); + }); + + it("lists matching legacy models unfolded while searching", async () => { + await openClaudeTab("claude-opus-5-5"); + + await userEvent.type(screen.getByRole("combobox"), "sonnet"); + + expect(optionNames()).toEqual(["Claude Sonnet 5.5", "Claude Sonnet 5"]); + }); + + it("shows no fold for a catalog without legacy models", async () => { + listProviderModels.mockResolvedValue(LIVE_CLAUDE.filter((model) => model.lifecycle === undefined)); + render(); + await openPicker(); + + await waitFor(() => expect(optionNames()).toEqual(["Claude Opus 5.5", "Claude Sonnet 5.5"])); + }); + + it("keeps a starred legacy model in Favourites", async () => { + useModelFavoritesStore.setState({ + entries: [{ providerId: "claude", modelId: "claude-opus-5", label: "Claude Opus 5" }], + }); + await openClaudeTab("claude-opus-5-5"); + + await userEvent.click(screen.getByRole("radio", { name: "Favourites" })); + + expect(optionNames()).toEqual(["Claude Opus 5Claude"]); + }); + }); }); diff --git a/apps/web/src/components/ui/picker.tsx b/apps/web/src/components/ui/picker.tsx index fb1dcc387..35a3f5933 100644 --- a/apps/web/src/components/ui/picker.tsx +++ b/apps/web/src/components/ui/picker.tsx @@ -11,7 +11,7 @@ import { type KeyboardEvent, type ReactNode, } from "react"; -import { CheckIcon, SearchIcon } from "lucide-react"; +import { CheckIcon, ChevronDownIcon, ChevronRightIcon, SearchIcon } from "lucide-react"; import { formatKeybinding } from "@/lib/keybinding-manager"; import { isMac } from "@/lib/platform"; @@ -46,6 +46,8 @@ export interface PickerRow { readonly description?: ReactNode; /** A secondary action on the row, such as starring it. Running it never picks the row. */ readonly action?: PickerRowAction; + /** Makes the row a disclosure whose pick shows or hides the rows after it, drawn with a chevron. */ + readonly expanded?: boolean; /** Rows sharing a group sit together; a divider marks where the group changes. */ readonly group?: string; /** Text heading drawn before the first row of this row's group, in place of the divider. */ @@ -559,6 +561,7 @@ function PickerOptionItem({ id, row, digit, active, selected, selectedCheck, onH role="option" aria-selected={selected} aria-disabled={row.disabled || undefined} + aria-expanded={row.expanded} aria-describedby={descriptions.describedBy} aria-keyshortcuts={descriptions.keyShortcuts} data-active={active || undefined} @@ -596,6 +599,7 @@ function PickerOptionContent({ row, digit, selected, selectedCheck, onRunAction ) : null} {row.tag ? {row.tag} : null} + {digit !== undefined ? ( {formatKeybinding(`mod+${digit}`, isMac)} @@ -611,6 +615,16 @@ function PickerOptionContent({ row, digit, selected, selectedCheck, onRunAction ); } +function PickerDisclosureChevron({ expanded }: { readonly expanded: boolean | undefined }) { + if (expanded === undefined) return null; + const Chevron = expanded ? ChevronDownIcon : ChevronRightIcon; + return ( + + + + ); +} + function PickerRowActionButton({ action, onRun }: { readonly action: PickerRowAction; readonly onRun: () => void }) { return (