diff --git a/package.json b/package.json
index 9639907f..3226552c 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "codeg",
"private": true,
- "version": "0.15.13",
+ "version": "0.15.14",
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index e36d4837..9d892d10 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -982,7 +982,7 @@ checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
[[package]]
name = "codeg"
-version = "0.15.13"
+version = "0.15.14"
dependencies = [
"aes-gcm",
"agent-client-protocol-schema",
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 4ab4da99..20ee8a66 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "codeg"
-version = "0.15.13"
+version = "0.15.14"
description = "Agent Code Generation App"
authors = ["feitao"]
edition = "2021"
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index 6e39238f..53a9c5ee 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "codeg",
- "version": "0.15.13",
+ "version": "0.15.14",
"identifier": "app.codeg",
"build": {
"beforeDevCommand": "pnpm tauri:before-dev",
diff --git a/src/components/chat/message-input.test.tsx b/src/components/chat/message-input.test.tsx
index bfe8d5e5..936a2299 100644
--- a/src/components/chat/message-input.test.tsx
+++ b/src/components/chat/message-input.test.tsx
@@ -1,10 +1,13 @@
import {
render,
+ screen,
waitFor,
+ within,
cleanup,
fireEvent,
act,
} from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
import { NextIntlClientProvider } from "next-intl"
import type { ComponentProps } from "react"
import type { Editor } from "@tiptap/core"
@@ -71,9 +74,30 @@ vi.mock("@/lib/platform", () => ({
vi.mock("@/lib/transport", () => ({
getActiveRemoteConnectionId: () => null,
}))
+// virtua renders 0 rows under jsdom — render children directly so the large
+// (searchable + virtualized) model list is exercisable here too.
+vi.mock("virtua", async () => {
+ const { forwardRef, useImperativeHandle } = await import("react")
+ return {
+ VList: forwardRef(function VListMock(
+ props: { children: React.ReactNode; role?: string; id?: string },
+ ref: React.Ref<{ scrollToIndex: () => void }>
+ ) {
+ useImperativeHandle(ref, () => ({ scrollToIndex: () => {} }))
+ return (
+
+ {props.children}
+
+ )
+ }),
+ }
+})
import enMessages from "@/i18n/messages/en.json"
-import type { PromptCapabilitiesInfo } from "@/lib/types"
+import type {
+ PromptCapabilitiesInfo,
+ SessionConfigOptionInfo,
+} from "@/lib/types"
import { MessageInput } from "./message-input"
@@ -199,3 +223,176 @@ describe("MessageInput attach-to-chat insertion position", () => {
assertBetweenHelloAndWorld(editor.getMarkdown(), link)
})
})
+
+// When the composer is narrow the model/config/mode selectors collapse behind a
+// cog button into a single Popover that renders a master–detail panel: the
+// settings on the left, the active setting's options (plain buttons) on the
+// right. This is the WebKit-safe replacement for the old nested dropdown/submenu
+// — a nested Radix dismissable layer drops the selection on WKWebView, so the
+// options are plain s in the one popover layer. jsdom has no layout, so
+// the container-query-hidden wide row stays hidden and this collapsed path is
+// what renders here.
+const MODEL_OPTION: SessionConfigOptionInfo = {
+ id: "model",
+ name: "Model",
+ description: "Pick the model",
+ category: null,
+ kind: {
+ type: "select",
+ current_value: "default",
+ options: [
+ { value: "default", name: "Default", description: "Use the default" },
+ { value: "opus", name: "Opus", description: "Most capable" },
+ ],
+ groups: [],
+ },
+}
+
+describe("MessageInput collapsed selectors popover", () => {
+ afterEach(() => cleanup())
+
+ it("selects a config option from the cog Popover and closes it", async () => {
+ const user = userEvent.setup()
+ const onConfigOptionChange = vi.fn()
+ const { container } = renderInput({
+ configOptions: [MODEL_OPTION],
+ onConfigOptionChange,
+ })
+ await waitFor(() =>
+ expect(container.querySelector('[role="textbox"]')).not.toBeNull()
+ )
+
+ const settingsLabel = enMessages.Folder.chat.messageInput.agentSettings
+ await user.click(screen.getByRole("button", { name: settingsLabel }))
+
+ const popover = await screen.findByRole("dialog", { name: settingsLabel })
+ // The left rail shows the setting as a title + current value row.
+ expect(
+ within(popover).getByRole("button", { name: /Model/ })
+ ).toBeInTheDocument()
+
+ // Options are plain buttons (native clicks) — selecting fires the change.
+ await user.click(within(popover).getByRole("button", { name: /Opus/ }))
+ expect(onConfigOptionChange).toHaveBeenCalledWith("model", "opus")
+
+ // Selecting a value closes the controlled popover.
+ await waitFor(() =>
+ expect(screen.queryByRole("dialog", { name: settingsLabel })).toBeNull()
+ )
+ })
+
+ it("groups model values by their provider prefix in the cog Popover", async () => {
+ const user = userEvent.setup()
+ const onConfigOptionChange = vi.fn()
+ const groupedModel: SessionConfigOptionInfo = {
+ id: "model",
+ name: "Model",
+ description: "Pick the model",
+ category: null,
+ kind: {
+ type: "select",
+ current_value: "anthropic/claude-opus",
+ options: [
+ {
+ value: "anthropic/claude-opus",
+ name: "anthropic/claude-opus",
+ description: null,
+ },
+ { value: "openai/gpt-4o", name: "openai/gpt-4o", description: null },
+ ],
+ groups: [],
+ },
+ }
+ const { container } = renderInput({
+ configOptions: [groupedModel],
+ onConfigOptionChange,
+ })
+ await waitFor(() =>
+ expect(container.querySelector('[role="textbox"]')).not.toBeNull()
+ )
+
+ const settingsLabel = enMessages.Folder.chat.messageInput.agentSettings
+ await user.click(screen.getByRole("button", { name: settingsLabel }))
+ const popover = await screen.findByRole("dialog", { name: settingsLabel })
+
+ // The detail pane carries one header per provider namespace…
+ expect(within(popover).getByText("anthropic")).toBeInTheDocument()
+ expect(within(popover).getByText("openai")).toBeInTheDocument()
+
+ // …and the option label drops the redundant `openai/` prefix, while the
+ // committed value stays the full id. (Pick the non-current model so its
+ // label is unique to the detail pane, not echoed in the left-rail summary.)
+ await user.click(within(popover).getByRole("button", { name: /gpt-4o/ }))
+ expect(onConfigOptionChange).toHaveBeenCalledWith("model", "openai/gpt-4o")
+ })
+
+ it("uses a searchable virtualized list for a long model list", async () => {
+ const user = userEvent.setup()
+ const onConfigOptionChange = vi.fn()
+ const options = Array.from({ length: 30 }, (_, i) => ({
+ value: `openrouter/model-${i}`,
+ name: `openrouter/model-${i}`,
+ description: null,
+ }))
+ const bigModel: SessionConfigOptionInfo = {
+ id: "model",
+ name: "Model",
+ description: null,
+ category: null,
+ kind: {
+ type: "select",
+ current_value: "openrouter/model-0",
+ options,
+ groups: [],
+ },
+ }
+ const { container } = renderInput({
+ configOptions: [bigModel],
+ onConfigOptionChange,
+ })
+ await waitFor(() =>
+ expect(container.querySelector('[role="textbox"]')).not.toBeNull()
+ )
+
+ const settingsLabel = enMessages.Folder.chat.messageInput.agentSettings
+ await user.click(screen.getByRole("button", { name: settingsLabel }))
+ const popover = await screen.findByRole("dialog", { name: settingsLabel })
+
+ // A long list (> threshold) renders the searchable combobox, not plain rows.
+ const search = within(popover).getByRole("combobox")
+ await user.type(search, "model-17")
+ // Filtering narrows to the one match; the full id is committed on click.
+ await user.click(within(popover).getByRole("option", { name: /model-17/ }))
+ expect(onConfigOptionChange).toHaveBeenCalledWith(
+ "model",
+ "openrouter/model-17"
+ )
+ })
+
+ it("selects a mode from the cog Popover and closes it", async () => {
+ const user = userEvent.setup()
+ const onModeChange = vi.fn()
+ const { container } = renderInput({
+ modes: [
+ { id: "plan", name: "Plan", description: "Plan first" },
+ { id: "act", name: "Act", description: "Act now" },
+ ],
+ selectedModeId: "plan",
+ onModeChange,
+ })
+ await waitFor(() =>
+ expect(container.querySelector('[role="textbox"]')).not.toBeNull()
+ )
+
+ const settingsLabel = enMessages.Folder.chat.messageInput.agentSettings
+ await user.click(screen.getByRole("button", { name: settingsLabel }))
+
+ const popover = await screen.findByRole("dialog", { name: settingsLabel })
+ await user.click(within(popover).getByRole("button", { name: /Act/ }))
+ expect(onModeChange).toHaveBeenCalledWith("act")
+
+ await waitFor(() =>
+ expect(screen.queryByRole("dialog", { name: settingsLabel })).toBeNull()
+ )
+ })
+})
diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx
index ddaff6b0..7d9d3d9b 100644
--- a/src/components/chat/message-input.tsx
+++ b/src/components/chat/message-input.tsx
@@ -39,6 +39,11 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
import {
ContextMenu,
ContextMenuContent,
@@ -103,14 +108,21 @@ import {
ConversationFolderBranchPicker,
useConversationFolderBranchPickerVisible,
} from "@/components/chat/conversation-context-bar"
+import { InlineModeSelector } from "@/components/chat/mode-selector"
+import { InlineSessionConfigSelector } from "@/components/chat/session-config-selector"
+import { ModelOptionPicker } from "@/components/chat/model-option-picker"
import {
- InlineModeSelector,
- ModeSelector,
-} from "@/components/chat/mode-selector"
+ SessionSelectorsPanel,
+ type SessionSelectorGroup,
+ type SessionSelectorSetting,
+} from "@/components/chat/session-selectors-panel"
import {
- InlineSessionConfigSelector,
- SessionConfigSelector,
-} from "@/components/chat/session-config-selector"
+ deriveModelGroups,
+ isModelConfigOption,
+ modelListGroups,
+ MODEL_LIST_VIRTUALIZE_THRESHOLD,
+ type ModelOptionGroup,
+} from "@/lib/model-config-groups"
import {
getExpertIcon,
pickExpertLocalized,
@@ -424,6 +436,21 @@ function SelectorLoadingChip({ label }: { label: string }) {
)
}
+// Groups for the searchable + virtualized model picker, or `null` when the
+// option should keep the lightweight selectors. Only the MODEL option, and only
+// when its list is long enough to jank, qualifies. Falls back to a single
+// headerless group for a long flat (un-prefixed) list.
+function modelPickerGroups(
+ option: SessionConfigOptionInfo
+): ModelOptionGroup[] | null {
+ if (!isModelConfigOption(option)) return null
+ if (option.kind.type !== "select") return null
+ if (option.kind.options.length <= MODEL_LIST_VIRTUALIZE_THRESHOLD) return null
+ // Preserve derived `provider/` groups, server-provided groups, or a flat list
+ // (never silently flatten server groups — keeps wide/collapsed consistent).
+ return modelListGroups(option)
+}
+
export function MessageInput({
onSend,
placeholder,
@@ -575,6 +602,11 @@ export function MessageInput({
const [attachments, setAttachments] = useState([])
const embeddedPayloadsRef = useRef>(new Map())
const [isDragActive, setIsDragActive] = useState(false)
+ // Collapsed (narrow) selectors live in a controlled Popover holding a
+ // master–detail panel (`SessionSelectorsPanel`). It's controlled so a value
+ // pick closes it explicitly — matching the prior cog menu, which also closed
+ // on every selection.
+ const [collapsedSelectorsOpen, setCollapsedSelectorsOpen] = useState(false)
const [quickMessages, setQuickMessages] = useState([])
const [quickMessagesLoading, setQuickMessagesLoading] = useState(false)
// Whether the async Clipboard read API is usable here. It's absent in
@@ -2409,45 +2441,37 @@ export function MessageInput({
const hasImageAttachments = imageAttachments.length > 0
const showDragActive = isDragActive && !disabled
- const selectorItems = (
- <>
- {showConfigLoading && (
-
- )}
- {hasConfigOptions &&
- availableConfigOptions.map((option) => (
-
- onConfigOptionChange?.(configId, valueId)
- }
- />
- ))}
- {showModeLoading && }
- {showModeSelector && (
-
- )}
- >
- )
-
const inlineSelectorItems = (
<>
{hasConfigOptions &&
- availableConfigOptions.map((option) => (
-
- onConfigOptionChange?.(configId, valueId)
- }
- />
- ))}
+ availableConfigOptions.map((option) => {
+ // Long model lists get the searchable + virtualized popover (a Radix
+ // menu of hundreds of items is the scroll jank); every other option —
+ // and short model lists — keep the lightweight inline dropdown.
+ const listGroups = modelPickerGroups(option)
+ if (listGroups) {
+ return (
+
+ onConfigOptionChange?.(configId, valueId)
+ }
+ />
+ )
+ }
+ return (
+
+ onConfigOptionChange?.(configId, valueId)
+ }
+ />
+ )
+ })}
{showModeSelector && (
)
+ // Normalized settings for the collapsed (narrow) master–detail panel. Config
+ // options and the mode picker are mutually exclusive in this UI (see
+ // `showModeSelector`), but both are mapped so the panel stays agnostic.
+ const collapsedSettings = useMemo(() => {
+ const result: SessionSelectorSetting[] = []
+ if (hasConfigOptions) {
+ for (const option of availableConfigOptions) {
+ if (option.kind.type !== "select") continue
+ const kind = option.kind
+ // Model values that carry a `provider/` prefix group by provider; every
+ // other option keeps its server groups or stays flat (`null` derived).
+ const derived = deriveModelGroups(option)
+ const groups: SessionSelectorGroup[] = derived
+ ? derived.map((group) => ({
+ key: group.key,
+ name: group.name,
+ options: group.options.map((item) => ({
+ value: item.value,
+ name: item.name,
+ description: item.description,
+ })),
+ }))
+ : kind.groups.length > 0
+ ? kind.groups.map((group) => ({
+ key: group.group,
+ name: group.name,
+ options: group.options.map((item) => ({
+ value: item.value,
+ name: item.name,
+ description: item.description,
+ })),
+ }))
+ : [
+ {
+ key: "__flat__",
+ name: null,
+ options: kind.options.map((item) => ({
+ value: item.value,
+ name: item.name,
+ description: item.description,
+ })),
+ },
+ ]
+ // Resolve the left-rail summary against the built groups so a grouped
+ // model shows its prefix-stripped name (the provider is implied) rather
+ // than repeating `provider/`.
+ const current = groups
+ .flatMap((group) => group.options)
+ .find((item) => item.value === kind.current_value)
+ // A long model list gets a searchable + virtualized detail pane (a plain
+ // list of hundreds of buttons janks); short lists keep plain buttons.
+ const searchable =
+ isModelConfigOption(option) &&
+ kind.options.length > MODEL_LIST_VIRTUALIZE_THRESHOLD
+ result.push({
+ key: `config:${option.id}`,
+ title: option.name,
+ currentValue: kind.current_value,
+ currentLabel: current?.name ?? kind.current_value,
+ groups,
+ onSelect: (value) => onConfigOptionChange?.(option.id, value),
+ ...(searchable && {
+ search: {
+ placeholder: t("searchModel"),
+ inputLabel: t("searchModelAria"),
+ listLabel: t("modelListLabel"),
+ empty: t("noModels"),
+ },
+ }),
+ })
+ }
+ }
+ if (showModeSelector) {
+ const selected = availableModes.find(
+ (mode) => mode.id === effectiveModeId
+ )
+ result.push({
+ key: "mode",
+ title: t("modeLabel"),
+ currentValue: effectiveModeId ?? "",
+ currentLabel: selected?.name ?? effectiveModeId ?? "",
+ groups: [
+ {
+ key: "__modes__",
+ name: null,
+ options: availableModes.map((mode) => ({
+ value: mode.id,
+ name: mode.name,
+ description: mode.description,
+ })),
+ },
+ ],
+ onSelect: (value) => handleModeSelect(value),
+ })
+ }
+ return result
+ }, [
+ hasConfigOptions,
+ availableConfigOptions,
+ showModeSelector,
+ availableModes,
+ effectiveModeId,
+ onConfigOptionChange,
+ handleModeSelect,
+ t,
+ ])
+
const actionButtons = isEditingQueueItem ? (
-
-
+
+
)}
-
-
+
- {selectorItems}
-
-
+ {showConfigLoading && (
+
+ )}
+ {showModeLoading && (
+
+ )}
+ {collapsedSettings.length > 0 && (
+
+ setCollapsedSelectorsOpen(false)
+ }
+ />
+ )}
+
+
)}
diff --git a/src/components/chat/mode-selector.tsx b/src/components/chat/mode-selector.tsx
index 239db4e7..b687a1b9 100644
--- a/src/components/chat/mode-selector.tsx
+++ b/src/components/chat/mode-selector.tsx
@@ -7,9 +7,6 @@ import {
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
- DropdownMenuSub,
- DropdownMenuSubContent,
- DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { DropdownRadioItemContent } from "@/components/chat/dropdown-radio-item-content"
@@ -22,43 +19,6 @@ interface ModeSelectorProps {
label: string
}
-export function ModeSelector({
- modes,
- selectedModeId,
- onSelect,
- label,
-}: ModeSelectorProps) {
- const selected = modes.find((mode) => mode.id === selectedModeId)
- const currentLabel = selected?.name ?? selectedModeId ?? ""
- return (
-
-
- {label}
-
- {currentLabel}
-
-
-
-
- {modes.map((mode) => (
-
-
-
- ))}
-
-
-
- )
-}
-
export function InlineModeSelector({
modes,
selectedModeId,
@@ -74,6 +34,7 @@ export function InlineModeSelector({
variant="ghost"
size="xs"
title={selected?.description ?? selected?.name ?? label}
+ aria-label={currentLabel ? `${label}: ${currentLabel}` : label}
className="min-w-0 gap-0.5 px-1 text-muted-foreground"
>
{currentLabel}
diff --git a/src/components/chat/model-option-list.test.tsx b/src/components/chat/model-option-list.test.tsx
new file mode 100644
index 00000000..a15e5e70
--- /dev/null
+++ b/src/components/chat/model-option-list.test.tsx
@@ -0,0 +1,159 @@
+import {
+ render,
+ screen,
+ cleanup,
+ within,
+ fireEvent,
+} from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+import { afterEach, describe, expect, it, vi } from "vitest"
+import {
+ forwardRef,
+ useImperativeHandle,
+ type CSSProperties,
+ type ReactNode,
+} from "react"
+
+// virtua renders ZERO rows under jsdom (no layout), so mock it to render every
+// child directly — the established pattern (see sidebar-conversation-list.test).
+// Forward a no-op scrollToIndex handle so keyboard nav doesn't throw.
+vi.mock("virtua", () => ({
+ VList: forwardRef(function VListMock(
+ props: {
+ children: ReactNode
+ role?: string
+ id?: string
+ "aria-label"?: string
+ style?: CSSProperties
+ className?: string
+ },
+ ref: React.Ref<{ scrollToIndex: (i: number) => void }>
+ ) {
+ useImperativeHandle(ref, () => ({ scrollToIndex: vi.fn() }))
+ return (
+
+ {props.children}
+
+ )
+ }),
+}))
+
+import { ModelOptionList } from "./model-option-list"
+import type { ModelOptionGroup } from "@/lib/model-config-groups"
+
+const GROUPS: ModelOptionGroup[] = [
+ {
+ key: "anthropic",
+ name: "anthropic",
+ options: [
+ { value: "anthropic/opus", name: "opus", description: null },
+ { value: "anthropic/sonnet", name: "sonnet", description: null },
+ ],
+ },
+ {
+ key: "openai",
+ name: "openai",
+ options: [{ value: "openai/gpt-4o", name: "gpt-4o", description: null }],
+ },
+]
+
+function renderList(
+ overrides: Partial[0]> = {}
+) {
+ const onSelect = vi.fn()
+ render(
+
+ )
+ return { onSelect }
+}
+
+describe("ModelOptionList", () => {
+ afterEach(() => cleanup())
+
+ it("renders grouped options and marks the current value selected", () => {
+ renderList()
+ expect(screen.getByText("anthropic")).toBeInTheDocument()
+ expect(screen.getByText("openai")).toBeInTheDocument()
+ expect(screen.getByRole("option", { name: /opus/ })).toHaveAttribute(
+ "aria-selected",
+ "true"
+ )
+ expect(screen.getByRole("option", { name: /sonnet/ })).toHaveAttribute(
+ "aria-selected",
+ "false"
+ )
+ })
+
+ it("filters options as you type (matching name or value)", async () => {
+ const user = userEvent.setup()
+ renderList()
+ await user.type(screen.getByRole("combobox"), "gpt")
+ expect(screen.getByRole("option", { name: /gpt-4o/ })).toBeInTheDocument()
+ expect(screen.queryByRole("option", { name: /opus/ })).toBeNull()
+ // The now-empty anthropic group drops its header too.
+ expect(screen.queryByText("anthropic")).toBeNull()
+ })
+
+ it("shows the empty label when nothing matches", async () => {
+ const user = userEvent.setup()
+ renderList()
+ await user.type(screen.getByRole("combobox"), "zzzz")
+ expect(screen.getByText("No models found")).toBeInTheDocument()
+ expect(screen.queryByRole("option")).toBeNull()
+ })
+
+ it("commits a value on click", async () => {
+ const user = userEvent.setup()
+ const { onSelect } = renderList()
+ await user.click(screen.getByRole("option", { name: /sonnet/ }))
+ expect(onSelect).toHaveBeenCalledWith("anthropic/sonnet")
+ })
+
+ it("navigates with the keyboard and commits on Enter", async () => {
+ const user = userEvent.setup()
+ const { onSelect } = renderList()
+ const input = screen.getByRole("combobox")
+ await user.click(input)
+ // Cursor starts at the first option (opus); ArrowDown → sonnet, Enter picks.
+ await user.keyboard("{ArrowDown}{Enter}")
+ expect(onSelect).toHaveBeenCalledWith("anthropic/sonnet")
+ })
+
+ it("ignores Enter while an IME composition is in flight", () => {
+ const { onSelect } = renderList()
+ const input = screen.getByRole("combobox")
+ // Enter during CJK composition confirms the candidate — it must NOT select.
+ fireEvent.keyDown(input, { key: "Enter", isComposing: true })
+ expect(onSelect).not.toHaveBeenCalled()
+ })
+
+ it("points aria-activedescendant at the active option", async () => {
+ const user = userEvent.setup()
+ renderList()
+ const input = screen.getByRole("combobox")
+ const initial = input.getAttribute("aria-activedescendant")
+ expect(initial).toBeTruthy()
+ // The active descendant must resolve to a real option element.
+ const listbox = screen.getByRole("listbox")
+ expect(within(listbox).getByRole("option", { name: /opus/ }).id).toBe(
+ initial
+ )
+ await user.click(input)
+ await user.keyboard("{ArrowDown}")
+ expect(input.getAttribute("aria-activedescendant")).not.toBe(initial)
+ })
+})
diff --git a/src/components/chat/model-option-list.tsx b/src/components/chat/model-option-list.tsx
new file mode 100644
index 00000000..39d7bee3
--- /dev/null
+++ b/src/components/chat/model-option-list.tsx
@@ -0,0 +1,235 @@
+"use client"
+
+import { useCallback, useId, useMemo, useRef, useState } from "react"
+import { Check, Search } from "lucide-react"
+import { VList, type VListHandle } from "virtua"
+import { cn } from "@/lib/utils"
+import { DropdownRadioItemContent } from "@/components/chat/dropdown-radio-item-content"
+import {
+ filterModelGroups,
+ flattenModelGroups,
+ type ModelOptionGroup,
+} from "@/lib/model-config-groups"
+
+interface ModelOptionListProps {
+ groups: ModelOptionGroup[]
+ currentValue: string
+ onSelect: (value: string) => void
+ searchPlaceholder: string
+ searchAriaLabel: string
+ listAriaLabel: string
+ emptyLabel: string
+ /** Focus the search box on mount (the wide popover opens straight into it). */
+ autoFocus?: boolean
+}
+
+// Coarse per-row viewport estimate (headers are shorter, two-line options
+// taller) — only sizes the scroll window; virtua measures real rows itself.
+const ROW_ESTIMATE_PX = 44
+const MAX_LIST_HEIGHT_PX = 320
+
+// Searchable, virtualized model list shared by both selector forms (the wide
+// popover and the collapsed cog panel). Deliberately NOT a Radix menu and NOT
+// cmdk: a Radix menu's roving focus over hundreds of items is the scroll jank we
+// are fixing, and cmdk + virtua was the combination that previously broke item
+// clicks. Instead: a plain search box drives a virtua `VList` of plain option
+// buttons, with arrow/Enter keyboard handled here and listbox a11y on the list.
+export function ModelOptionList({
+ groups,
+ currentValue,
+ onSelect,
+ searchPlaceholder,
+ searchAriaLabel,
+ listAriaLabel,
+ emptyLabel,
+ autoFocus = false,
+}: ModelOptionListProps) {
+ const [query, setQuery] = useState("")
+ const [activeIndex, setActiveIndex] = useState(0)
+ const vlistRef = useRef(null)
+ const baseId = useId()
+ const listId = `${baseId}-list`
+ const optionId = useCallback(
+ (optionIndex: number) => `${baseId}-opt-${optionIndex}`,
+ [baseId]
+ )
+
+ const rows = useMemo(
+ () => flattenModelGroups(filterModelGroups(groups, query)),
+ [groups, query]
+ )
+ // Flat row indices that are options (skipping headers) — the keyboard cursor
+ // walks these, and they map an option position back to its `VList` row index.
+ const optionRowIndices = useMemo(
+ () => rows.flatMap((row, index) => (row.kind === "option" ? [index] : [])),
+ [rows]
+ )
+ const optionCount = optionRowIndices.length
+ // Reverse lookup (flat row index → keyboard option index) so each option row
+ // can resolve its cursor position during render without a mutable counter.
+ const optionIndexByRow = useMemo(() => {
+ const map = new Map()
+ optionRowIndices.forEach((rowIndex, optionIndex) =>
+ map.set(rowIndex, optionIndex)
+ )
+ return map
+ }, [optionRowIndices])
+
+ // Clamp on read so a shrinking filtered set (or a live groups update) can never
+ // leave the cursor out of range — avoids a setState-in-effect just to re-clamp.
+ const activeIndexClamped =
+ optionCount === 0 ? 0 : Math.min(activeIndex, optionCount - 1)
+
+ const moveActiveTo = useCallback(
+ (next: number) => {
+ if (optionCount === 0) return
+ const clamped = Math.max(0, Math.min(optionCount - 1, next))
+ setActiveIndex(clamped)
+ vlistRef.current?.scrollToIndex(optionRowIndices[clamped], {
+ align: "nearest",
+ })
+ },
+ [optionCount, optionRowIndices]
+ )
+
+ const handleKeyDown = useCallback(
+ (event: React.KeyboardEvent) => {
+ // Don't steal Enter/arrows while an IME composition is in flight (CJK
+ // input): Enter there confirms the candidate, it must not pick a model.
+ if (event.nativeEvent.isComposing || event.key === "Process") return
+ switch (event.key) {
+ case "ArrowDown":
+ event.preventDefault()
+ moveActiveTo(activeIndexClamped + 1)
+ break
+ case "ArrowUp":
+ event.preventDefault()
+ moveActiveTo(activeIndexClamped - 1)
+ break
+ case "Home":
+ event.preventDefault()
+ moveActiveTo(0)
+ break
+ case "End":
+ event.preventDefault()
+ moveActiveTo(optionCount - 1)
+ break
+ case "Enter": {
+ const rowIndex = optionRowIndices[activeIndexClamped]
+ const row = rowIndex != null ? rows[rowIndex] : undefined
+ if (row && row.kind === "option") {
+ event.preventDefault()
+ onSelect(row.option.value)
+ }
+ break
+ }
+ default:
+ break
+ }
+ },
+ [
+ activeIndexClamped,
+ moveActiveTo,
+ onSelect,
+ optionCount,
+ optionRowIndices,
+ rows,
+ ]
+ )
+
+ const listHeight = Math.min(
+ MAX_LIST_HEIGHT_PX,
+ Math.max(rows.length, 1) * ROW_ESTIMATE_PX
+ )
+
+ // Always keep the active row mounted so `aria-activedescendant` resolves to a
+ // real element even after wheel-scrolling / filtering unmounts it off-screen.
+ const activeFlatIndex = optionRowIndices[activeIndexClamped]
+
+ return (
+
+
+
+ 0 ? optionId(activeIndexClamped) : undefined
+ }
+ aria-label={searchAriaLabel}
+ placeholder={searchPlaceholder}
+ onChange={(event) => {
+ setQuery(event.target.value)
+ setActiveIndex(0)
+ }}
+ onKeyDown={handleKeyDown}
+ className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
+ />
+
+
+ {optionCount === 0 ? (
+
+ {emptyLabel}
+
+ ) : (
+
+ {rows.map((row, flatIndex) => {
+ if (row.kind === "header") {
+ return (
+
+ {row.name}
+
+ )
+ }
+ const optionIndex = optionIndexByRow.get(flatIndex) ?? 0
+ const selected = row.option.value === currentValue
+ const active = optionIndex === activeIndexClamped
+ return (
+ setActiveIndex(optionIndex)}
+ onClick={() => onSelect(row.option.value)}
+ className={cn(
+ "flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors",
+ active && "bg-accent text-accent-foreground",
+ selected && !active && "bg-accent/60"
+ )}
+ >
+
+ {selected ? : null}
+
+
+
+ )
+ })}
+
+ )}
+
+ )
+}
diff --git a/src/components/chat/model-option-picker.tsx b/src/components/chat/model-option-picker.tsx
new file mode 100644
index 00000000..3fea54e8
--- /dev/null
+++ b/src/components/chat/model-option-picker.tsx
@@ -0,0 +1,88 @@
+"use client"
+
+import { useMemo, useState } from "react"
+import { ChevronDown } from "lucide-react"
+import { useTranslations } from "next-intl"
+import { Button } from "@/components/ui/button"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import { ModelOptionList } from "@/components/chat/model-option-list"
+import type { ModelOptionGroup } from "@/lib/model-config-groups"
+import type { SessionConfigOptionInfo } from "@/lib/types"
+
+interface ModelOptionPickerProps {
+ option: SessionConfigOptionInfo
+ /** The grouped list to show (derived `provider/` groups, or a single
+ * headerless group for a long flat list). */
+ groups: ModelOptionGroup[]
+ onSelect: (configId: string, valueId: string) => void
+}
+
+// Wide-form model picker for LONG model lists: a trigger button opening a
+// Popover that hosts the searchable + virtualized {@link ModelOptionList}.
+// Replaces the Radix `DropdownMenu` (whose roving focus over hundreds of items
+// is the scroll jank) only for the model option, only when it's large — short
+// lists keep `InlineSessionConfigSelector`. Mirrors the BranchPicker layout
+// (Popover `overflow-hidden p-0`, the list is the sole nested scroller) so a
+// scrollbar click never dismisses the popover.
+export function ModelOptionPicker({
+ option,
+ groups,
+ onSelect,
+}: ModelOptionPickerProps) {
+ const t = useTranslations("Folder.chat.messageInput")
+ const [open, setOpen] = useState(false)
+ const kind = option.kind.type === "select" ? option.kind : null
+ const currentValue = kind?.current_value ?? ""
+ const currentLabel = useMemo(() => {
+ for (const group of groups) {
+ for (const opt of group.options) {
+ if (opt.value === currentValue) return opt.name
+ }
+ }
+ return currentValue
+ }, [groups, currentValue])
+
+ if (!kind) return null
+
+ return (
+
+
+
+ {currentLabel}
+
+
+
+
+ {
+ onSelect(option.id, value)
+ setOpen(false)
+ }}
+ searchPlaceholder={t("searchModel")}
+ searchAriaLabel={t("searchModelAria")}
+ listAriaLabel={t("modelListLabel")}
+ emptyLabel={t("noModels")}
+ autoFocus
+ />
+
+
+ )
+}
diff --git a/src/components/chat/session-config-selector.test.tsx b/src/components/chat/session-config-selector.test.tsx
new file mode 100644
index 00000000..234ad99d
--- /dev/null
+++ b/src/components/chat/session-config-selector.test.tsx
@@ -0,0 +1,170 @@
+import { render, screen, cleanup, within } from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+import { afterEach, describe, expect, it, vi } from "vitest"
+
+import { InlineSessionConfigSelector } from "./session-config-selector"
+import { deriveModelGroups } from "@/lib/model-config-groups"
+import type { SessionConfigOptionInfo } from "@/lib/types"
+
+function modelOption(
+ options: { value: string; name: string; description?: string | null }[],
+ current = options[0]?.value ?? ""
+): SessionConfigOptionInfo {
+ return {
+ id: "model",
+ name: "Model",
+ description: null,
+ category: null,
+ kind: {
+ type: "select",
+ current_value: current,
+ options: options.map((o) => ({ description: null, ...o })),
+ groups: [],
+ },
+ }
+}
+
+describe("InlineSessionConfigSelector — model grouping", () => {
+ afterEach(() => cleanup())
+
+ it("renders provider headers and prefix-stripped labels for derived groups", async () => {
+ const user = userEvent.setup()
+ const option = modelOption(
+ [
+ { value: "anthropic/claude-opus", name: "anthropic/claude-opus" },
+ { value: "openai/gpt-4o", name: "openai/gpt-4o" },
+ ],
+ "anthropic/claude-opus"
+ )
+ const onSelect = vi.fn()
+ render(
+
+ )
+
+ // The trigger shows the selected model with its `provider/` prefix
+ // stripped (the provider is implied by its group) — not `anthropic/...`.
+ const trigger = screen.getByRole("button", { name: /claude-opus/ })
+ expect(trigger).not.toHaveTextContent("anthropic/")
+ await user.click(trigger)
+
+ // Provider namespaces become headers.
+ expect(await screen.findByText("anthropic")).toBeInTheDocument()
+ expect(screen.getByText("openai")).toBeInTheDocument()
+ // In-group labels drop the redundant `provider/` prefix.
+ const item = screen.getByRole("menuitemradio", { name: /claude-opus/ })
+ expect(item).toBeInTheDocument()
+ })
+
+ it("headers with the human provider name and strips it from rows (value≠name)", async () => {
+ const user = userEvent.setup()
+ // Real OpenCode shape: ids are `opencode/…` but names repeat `OpenCode Zen/`.
+ const option = modelOption(
+ [
+ { value: "opencode/big-pickle", name: "OpenCode Zen/Big Pickle" },
+ { value: "opencode/claude-haiku", name: "OpenCode Zen/Claude Haiku" },
+ { value: "anthropic/claude-opus", name: "anthropic/claude-opus" },
+ ],
+ "opencode/big-pickle"
+ )
+ const onSelect = vi.fn()
+ render(
+
+ )
+ // The trigger shows the stripped current label, not "OpenCode Zen/…".
+ const trigger = screen.getByRole("button", { name: /Big Pickle/ })
+ expect(trigger).not.toHaveTextContent("OpenCode Zen/")
+ await user.click(trigger)
+
+ // The header is the human provider name (not the `opencode` id).
+ expect(await screen.findByText("OpenCode Zen")).toBeInTheDocument()
+ // Rows drop the repeated prefix but commit the full id.
+ const haiku = screen.getByRole("menuitemradio", { name: /Claude Haiku/ })
+ expect(haiku).not.toHaveTextContent("OpenCode Zen/")
+ await user.click(haiku)
+ expect(onSelect).toHaveBeenCalledWith("model", "opencode/claude-haiku")
+ })
+
+ it("commits the full value (not the stripped label) on select", async () => {
+ const user = userEvent.setup()
+ const option = modelOption([
+ { value: "anthropic/claude-opus", name: "anthropic/claude-opus" },
+ { value: "openai/gpt-4o", name: "openai/gpt-4o" },
+ ])
+ const onSelect = vi.fn()
+ render(
+
+ )
+ await user.click(screen.getByRole("button", { name: /claude-opus/ }))
+ await user.click(
+ await screen.findByRole("menuitemradio", { name: /gpt-4o/ })
+ )
+ expect(onSelect).toHaveBeenCalledWith("model", "openai/gpt-4o")
+ })
+
+ it("renders the floating bucket with no header before provider groups", async () => {
+ const user = userEvent.setup()
+ const option = modelOption(
+ [
+ { value: "default", name: "Default" },
+ { value: "anthropic/opus", name: "anthropic/opus" },
+ ],
+ "default"
+ )
+ render(
+
+ )
+ await user.click(screen.getByRole("button", { name: /Default/ }))
+
+ // The prefix-less "Default" option is present…
+ expect(
+ await screen.findByRole("menuitemradio", { name: /Default/ })
+ ).toBeInTheDocument()
+ // …with a provider header for the grouped one, but no "Default" header.
+ expect(screen.getByText("anthropic")).toBeInTheDocument()
+ // Inside the menu "Default" appears once (the option), not also as a group
+ // label (the floating bucket is headerless). The trigger's copy of the
+ // current label lives outside the menu, so scope the count to the menu.
+ const menu = screen.getByRole("menu")
+ expect(within(menu).getAllByText(/^Default$/)).toHaveLength(1)
+ })
+
+ it("falls back to a flat list when no grouping applies", async () => {
+ const user = userEvent.setup()
+ const option = modelOption(
+ [
+ { value: "opus", name: "Opus" },
+ { value: "haiku", name: "Haiku" },
+ ],
+ "opus"
+ )
+ render(
+
+ )
+ await user.click(screen.getByRole("button", { name: /Opus/ }))
+ expect(
+ await screen.findByRole("menuitemradio", { name: /Haiku/ })
+ ).toBeInTheDocument()
+ // No provider headers for an ungroupable flat list.
+ expect(screen.queryByText("anthropic")).toBeNull()
+ })
+})
diff --git a/src/components/chat/session-config-selector.tsx b/src/components/chat/session-config-selector.tsx
index 38745a2a..7f8c42d1 100644
--- a/src/components/chat/session-config-selector.tsx
+++ b/src/components/chat/session-config-selector.tsx
@@ -10,106 +10,52 @@ import {
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
- DropdownMenuSub,
- DropdownMenuSubContent,
- DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { DropdownRadioItemContent } from "@/components/chat/dropdown-radio-item-content"
+import type { ModelOptionGroup } from "@/lib/model-config-groups"
import type { SessionConfigOptionInfo } from "@/lib/types"
interface SessionConfigSelectorProps {
option: SessionConfigOptionInfo
onSelect: (configId: string, valueId: string) => void
-}
-
-export function SessionConfigSelector({
- option,
- onSelect,
-}: SessionConfigSelectorProps) {
- if (option.kind.type !== "select") return null
-
- const allOptions =
- option.kind.groups.length > 0
- ? option.kind.groups.flatMap((group) => group.options)
- : option.kind.options
- const selected = allOptions.find(
- (item) => item.value === option.kind.current_value
- )
- const currentLabel = selected?.name ?? option.kind.current_value
-
- return (
-
-
-
- {option.name}
-
-
- {currentLabel}
-
-
-
- onSelect(option.id, value)}
- >
- {option.kind.groups.length > 0
- ? option.kind.groups.map((group, index) => (
-
- {index > 0 && }
- {group.name}
- {group.options.map((item) => (
-
-
-
- ))}
-
- ))
- : option.kind.options.map((item) => (
-
-
-
- ))}
-
-
-
- )
+ /**
+ * Frontend-derived grouping for the model picker (split on the `provider/`
+ * prefix). When provided, it overrides the option's own (flat) value list;
+ * a group with `name === null` renders its options with no header. `null`
+ * means "no grouping" — fall back to server groups, else the flat list.
+ */
+ derivedGroups?: ModelOptionGroup[] | null
}
export function InlineSessionConfigSelector({
option,
onSelect,
+ derivedGroups,
}: SessionConfigSelectorProps) {
if (option.kind.type !== "select") return null
- const allOptions =
- option.kind.groups.length > 0
- ? option.kind.groups.flatMap((group) => group.options)
- : option.kind.options
- const selected = allOptions.find(
+ // Unified group list rendered in the dropdown body. Derived (model) groups
+ // win; otherwise server-provided groups; otherwise `null` → flat options.
+ // `name === null` is a headerless bucket (the leading prefix-less models).
+ const renderGroups: ModelOptionGroup[] | null =
+ derivedGroups && derivedGroups.length > 0
+ ? derivedGroups
+ : option.kind.groups.length > 0
+ ? option.kind.groups.map((group) => ({
+ key: group.group,
+ name: group.name,
+ options: group.options,
+ }))
+ : null
+
+ // Resolve the trigger label against the *rendered* options so the selected
+ // model shows its prefix-stripped name (its provider is already implied by
+ // the group it sits in) rather than repeating `provider/`.
+ const renderedOptions = renderGroups
+ ? renderGroups.flatMap((group) => group.options)
+ : option.kind.options
+ const selected = renderedOptions.find(
(item) => item.value === option.kind.current_value
)
const currentLabel = selected?.name ?? option.kind.current_value
@@ -121,6 +67,9 @@ export function InlineSessionConfigSelector({
variant="ghost"
size="xs"
title={currentLabel}
+ aria-label={
+ currentLabel ? `${option.name}: ${currentLabel}` : option.name
+ }
className="min-w-0 gap-0.5 px-1 text-muted-foreground"
>
{currentLabel}
@@ -141,14 +90,16 @@ export function InlineSessionConfigSelector({
value={option.kind.current_value}
onValueChange={(value) => onSelect(option.id, value)}
>
- {option.kind.groups.length > 0
- ? option.kind.groups.map((group, index) => (
-
+ {renderGroups
+ ? renderGroups.map((group, index) => (
+
{index > 0 && }
- {group.name}
+ {group.name !== null && (
+ {group.name}
+ )}
{group.options.map((item) => (
diff --git a/src/components/chat/session-selectors-panel.tsx b/src/components/chat/session-selectors-panel.tsx
new file mode 100644
index 00000000..96a0011d
--- /dev/null
+++ b/src/components/chat/session-selectors-panel.tsx
@@ -0,0 +1,195 @@
+"use client"
+
+import { useState } from "react"
+import { Check } from "lucide-react"
+import { cn } from "@/lib/utils"
+import { DropdownRadioItemContent } from "@/components/chat/dropdown-radio-item-content"
+import { ModelOptionList } from "@/components/chat/model-option-list"
+
+// One selectable value within a setting (e.g. a single model or mode).
+export interface SessionSelectorOption {
+ value: string
+ name: string
+ description?: string | null
+}
+
+// A visual group of options. `name === null` renders the options ungrouped
+// (the flat / single-group case); a non-null name renders a small header.
+export interface SessionSelectorGroup {
+ key: string
+ name: string | null
+ options: SessionSelectorOption[]
+}
+
+// Localized labels for the searchable/virtualized list (long model lists).
+export interface SessionSelectorSearch {
+ placeholder: string
+ inputLabel: string
+ listLabel: string
+ empty: string
+}
+
+// One setting shown in the left rail (a config option, or the mode picker).
+export interface SessionSelectorSetting {
+ key: string
+ title: string
+ currentValue: string
+ currentLabel: string
+ groups: SessionSelectorGroup[]
+ onSelect: (value: string) => void
+ /** When set, the detail pane renders a searchable + virtualized list instead
+ * of the plain button list — used for long model lists that otherwise jank. */
+ search?: SessionSelectorSearch
+}
+
+interface SessionSelectorsPanelProps {
+ settings: SessionSelectorSetting[]
+ /** Accessible label for the left-hand settings rail. */
+ settingsLabel: string
+ /** Invoked after a value is chosen (used to close the surrounding popover). */
+ onAfterSelect?: () => void
+}
+
+// Master–detail picker for the collapsed agent settings.
+//
+// WHY this shape: on WKWebView, nesting a second Radix dismissable layer (a
+// `DropdownMenu`/submenu) inside the cog layer's portal silently drops the
+// selection — the value never changes. The branch dropdown and the wide inline
+// selectors work precisely because they are never nested. So instead of a second
+// menu, every option here is a plain ``: a native click always fires,
+// and the whole picker lives inside the single cog popover (one layer only).
+//
+// Left rail = the settings (title + current value, left-aligned). Right pane =
+// the active setting's options. Selecting commits immediately and closes.
+export function SessionSelectorsPanel({
+ settings,
+ settingsLabel,
+ onAfterSelect,
+}: SessionSelectorsPanelProps) {
+ // `activeKey` is only a hint — the active setting is always resolved against
+ // the current `settings`, so it stays valid if the list changes underneath.
+ const [activeKey, setActiveKey] = useState(null)
+
+ if (settings.length === 0) return null
+ const active = settings.find((s) => s.key === activeKey) ?? settings[0]
+
+ return (
+
+ {/* Left rail: one row per setting, title over current value. */}
+
+ {settings.map((setting) => {
+ const isActive = setting.key === active.key
+ return (
+ setActiveKey(setting.key)}
+ className={cn(
+ "flex w-full flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left transition-colors",
+ "hover:bg-accent hover:text-accent-foreground",
+ isActive && "bg-accent text-accent-foreground"
+ )}
+ >
+
+ {setting.title}
+
+
+ {setting.currentLabel}
+
+
+ )
+ })}
+
+
+ {/* Right pane: the active setting's options (the "sub-options").
+ Each option is a plain
, not a role="radio"/native radio: this
+ picker commits-and-closes on choose, which is *menu* semantics, whereas
+ a radio group selects-on-focus — arrow-keying a radio would commit and
+ close on every keypress. The correct widget (a Radix menu) is the very
+ portal/dismissable-layer that drops the selection on WKWebView, which is
+ the bug we're fixing. So: plain buttons (full native Tab/Enter/Space
+ operability) with `aria-current` marking the chosen value — the same,
+ honest pattern as the left rail. */}
+ {active.search ? (
+ // Long model lists: a searchable + virtualized list (its own scroller),
+ // so no surrounding `overflow-y-auto` wrapper here.
+
+ {
+ active.onSelect(value)
+ onAfterSelect?.()
+ }}
+ searchPlaceholder={active.search.placeholder}
+ searchAriaLabel={active.search.inputLabel}
+ listAriaLabel={active.search.listLabel}
+ emptyLabel={active.search.empty}
+ />
+
+ ) : (
+
+ {active.groups.map((group, groupIndex) => (
+
+ {group.name ? (
+
+ {group.name}
+
+ ) : null}
+ {group.options.map((opt) => {
+ const selected = opt.value === active.currentValue
+ return (
+
{
+ active.onSelect(opt.value)
+ onAfterSelect?.()
+ }}
+ className={cn(
+ "flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors",
+ "hover:bg-accent hover:text-accent-foreground",
+ selected && "bg-accent/60"
+ )}
+ >
+
+ {selected ? : null}
+
+
+
+ )
+ })}
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/src/components/chat/session-selectors.test.tsx b/src/components/chat/session-selectors.test.tsx
new file mode 100644
index 00000000..a3b98af7
--- /dev/null
+++ b/src/components/chat/session-selectors.test.tsx
@@ -0,0 +1,174 @@
+import {
+ render,
+ screen,
+ within,
+ cleanup,
+ fireEvent,
+} from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+import { afterEach, describe, expect, it, vi } from "vitest"
+
+import {
+ SessionSelectorsPanel,
+ type SessionSelectorSetting,
+} from "./session-selectors-panel"
+
+// Two distinct settings (no substring overlap in their titles) so role queries
+// stay unambiguous.
+function makeSettings(
+ modelOnSelect: () => void = vi.fn(),
+ effortOnSelect: () => void = vi.fn()
+): SessionSelectorSetting[] {
+ return [
+ {
+ key: "config:model",
+ title: "Model",
+ currentValue: "default",
+ currentLabel: "Default",
+ groups: [
+ {
+ key: "__flat__",
+ name: null,
+ options: [
+ {
+ value: "default",
+ name: "Default",
+ description: "Use the default",
+ },
+ { value: "opus", name: "Opus", description: "Most capable" },
+ ],
+ },
+ ],
+ onSelect: modelOnSelect,
+ },
+ {
+ key: "config:effort",
+ title: "Effort",
+ currentValue: "low",
+ currentLabel: "Low",
+ groups: [
+ {
+ key: "__flat__",
+ name: null,
+ options: [
+ { value: "low", name: "Low", description: null },
+ { value: "high", name: "High", description: null },
+ ],
+ },
+ ],
+ onSelect: effortOnSelect,
+ },
+ ]
+}
+
+describe("SessionSelectorsPanel", () => {
+ afterEach(() => cleanup())
+
+ it("shows the first setting's options with the current one marked", () => {
+ render(
+
+ )
+ // The right pane is a group labelled by the active setting; options are
+ // plain buttons with `aria-current` marking the chosen value.
+ const group = screen.getByRole("group", { name: "Model" })
+ expect(
+ within(group).getByRole("button", { name: /Default/ })
+ ).toHaveAttribute("aria-current", "true")
+ expect(
+ within(group).getByRole("button", { name: /Opus/ })
+ ).not.toHaveAttribute("aria-current")
+ })
+
+ it("commits a value via a plain click and notifies onAfterSelect", () => {
+ const modelOnSelect = vi.fn()
+ const onAfterSelect = vi.fn()
+ render(
+
+ )
+ fireEvent.click(screen.getByRole("button", { name: /Opus/ }))
+ expect(modelOnSelect).toHaveBeenCalledWith("opus")
+ expect(onAfterSelect).toHaveBeenCalledTimes(1)
+ })
+
+ it("activates an option with the keyboard (native button semantics)", async () => {
+ const user = userEvent.setup()
+ const modelOnSelect = vi.fn()
+ render(
+
+ )
+ const opus = screen.getByRole("button", { name: /Opus/ })
+ // Options are ordinary tab stops (no tabindex=-1 roving), so keyboard users
+ // reach them with Tab and activate with Enter/Space.
+ expect(opus.tabIndex).toBe(0)
+ opus.focus()
+ await user.keyboard("{Enter}")
+ expect(modelOnSelect).toHaveBeenCalledWith("opus")
+ })
+
+ it("switches the detail pane to another setting", () => {
+ const modelOnSelect = vi.fn()
+ const effortOnSelect = vi.fn()
+ render(
+
+ )
+ // Initially the Model pane is shown; Effort's options are not.
+ expect(screen.queryByRole("button", { name: /High/ })).toBeNull()
+
+ fireEvent.click(screen.getByRole("button", { name: /Effort/ }))
+
+ const group = screen.getByRole("group", { name: "Effort" })
+ fireEvent.click(within(group).getByRole("button", { name: /High/ }))
+ expect(effortOnSelect).toHaveBeenCalledWith("high")
+ // Switching panes must not fire the previous setting's handler.
+ expect(modelOnSelect).not.toHaveBeenCalled()
+ })
+
+ it("renders group headers for grouped options", () => {
+ const settings: SessionSelectorSetting[] = [
+ {
+ key: "config:model",
+ title: "Model",
+ currentValue: "opus",
+ currentLabel: "Opus",
+ groups: [
+ {
+ key: "anthropic",
+ name: "Anthropic",
+ options: [{ value: "opus", name: "Opus", description: null }],
+ },
+ {
+ key: "openai",
+ name: "OpenAI",
+ options: [{ value: "gpt", name: "GPT", description: null }],
+ },
+ ],
+ onSelect: vi.fn(),
+ },
+ ]
+ render(
+
+ )
+ expect(screen.getByText("Anthropic")).toBeInTheDocument()
+ expect(screen.getByText("OpenAI")).toBeInTheDocument()
+ })
+
+ it("renders nothing when there are no settings", () => {
+ const { container } = render(
+
+ )
+ expect(container).toBeEmptyDOMElement()
+ })
+})
diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx
index fc673d98..7468eaca 100644
--- a/src/components/ui/alert-dialog.tsx
+++ b/src/components/ui/alert-dialog.tsx
@@ -141,7 +141,7 @@ function AlertDialogDescription({
= {}
+): SessionConfigOptionInfo {
+ return {
+ id: "model",
+ name: "Model",
+ description: null,
+ category: null,
+ kind: {
+ type: "select",
+ current_value: options[0]?.value ?? "",
+ options,
+ groups: [],
+ },
+ ...overrides,
+ }
+}
+
+function opt(
+ value: string,
+ name = value,
+ description: string | null = null
+): SessionConfigSelectOptionInfo {
+ return { value, name, description }
+}
+
+describe("isModelConfigOption", () => {
+ it("matches the model option by id", () => {
+ expect(isModelConfigOption(modelOption([opt("a")]))).toBe(true)
+ })
+
+ it("matches by category when id differs", () => {
+ expect(
+ isModelConfigOption(
+ modelOption([opt("a")], { id: "primary-model", category: "model" })
+ )
+ ).toBe(true)
+ })
+
+ it("rejects the mode picker and other options", () => {
+ expect(
+ isModelConfigOption(
+ modelOption([opt("a")], { id: "mode", category: "mode" })
+ )
+ ).toBe(false)
+ expect(isModelConfigOption(modelOption([opt("a")], { id: "effort" }))).toBe(
+ false
+ )
+ })
+})
+
+describe("deriveModelGroups", () => {
+ it("returns null for non-model options", () => {
+ const mode = modelOption([opt("anthropic/opus")], {
+ id: "mode",
+ category: "mode",
+ })
+ expect(deriveModelGroups(mode)).toBeNull()
+ })
+
+ it("returns null when no value carries a slash (stays flat)", () => {
+ const option = modelOption([
+ opt("default", "Default"),
+ opt("opus", "Opus"),
+ opt("haiku", "Haiku"),
+ ])
+ expect(deriveModelGroups(option)).toBeNull()
+ })
+
+ it("respects server-provided groups verbatim (returns null)", () => {
+ const option = modelOption([opt("anthropic/opus")])
+ option.kind.groups = [
+ {
+ group: "anthropic",
+ name: "Anthropic",
+ options: [opt("anthropic/opus", "Opus")],
+ },
+ ]
+ expect(deriveModelGroups(option)).toBeNull()
+ })
+
+ it("groups by the first slash and preserves first-seen order", () => {
+ const option = modelOption([
+ opt("anthropic/claude-opus"),
+ opt("openai/gpt-4o"),
+ opt("anthropic/claude-sonnet"),
+ opt("google/gemini-2.0"),
+ ])
+ const groups = deriveModelGroups(option)
+ expect(groups?.map((g) => g.name)).toEqual([
+ "anthropic",
+ "openai",
+ "google",
+ ])
+ const anthropic = groups?.find((g) => g.name === "anthropic")
+ // Labels strip the redundant `anthropic/` prefix (name repeated the value).
+ expect(anthropic?.options.map((o) => o.name)).toEqual([
+ "claude-opus",
+ "claude-sonnet",
+ ])
+ // Values are never rewritten — only the display label is stripped.
+ expect(anthropic?.options.map((o) => o.value)).toEqual([
+ "anthropic/claude-opus",
+ "anthropic/claude-sonnet",
+ ])
+ })
+
+ it("uses the shared display-name prefix as header and strips it from rows", () => {
+ // Real OpenCode shape: values are `opencode/…` (its id prefix), but every
+ // display name repeats a human `OpenCode Zen/…`. The shared NAME prefix wins
+ // for the header and is stripped from each row so it isn't shown twice.
+ const option = modelOption([
+ opt("opencode/big-pickle", "OpenCode Zen/Big Pickle"),
+ opt("opencode/claude-haiku", "OpenCode Zen/Claude Haiku"),
+ opt("anthropic/claude-opus", "anthropic/claude-opus"),
+ ])
+ const groups = deriveModelGroups(option)
+ const zen = groups?.find((g) => g.key === "opencode")
+ expect(zen?.name).toBe("OpenCode Zen")
+ expect(zen?.options.map((o) => o.name)).toEqual([
+ "Big Pickle",
+ "Claude Haiku",
+ ])
+ // Values stay the full id — only the label changed.
+ expect(zen?.options.map((o) => o.value)).toEqual([
+ "opencode/big-pickle",
+ "opencode/claude-haiku",
+ ])
+ })
+
+ it("falls back to the value-id prefix as header when names don't share one", () => {
+ const option = modelOption([
+ opt("anthropic/claude-opus", "Claude Opus"),
+ opt("anthropic/claude-sonnet", "Claude Sonnet"),
+ opt("openai/gpt-4o", "GPT-4o"),
+ ])
+ const groups = deriveModelGroups(option)
+ const anthropic = groups?.find((g) => g.key === "anthropic")
+ // Clean names have no "/" → nothing redundant → header is the id prefix and
+ // labels are left untouched.
+ expect(anthropic?.name).toBe("anthropic")
+ expect(anthropic?.options.map((o) => o.name)).toEqual([
+ "Claude Opus",
+ "Claude Sonnet",
+ ])
+ })
+
+ it("floats prefix-less values into a leading headerless bucket", () => {
+ const option = modelOption([
+ opt("default", "Default"),
+ opt("anthropic/opus", "anthropic/opus"),
+ opt("openai/gpt", "openai/gpt"),
+ ])
+ const groups = deriveModelGroups(option) as ModelOptionGroup[]
+ expect(groups[0]).toMatchObject({ name: null })
+ expect(groups[0].options.map((o) => o.name)).toEqual(["Default"])
+ expect(groups.slice(1).map((g) => g.name)).toEqual(["anthropic", "openai"])
+ })
+
+ it("groups a lone provider, stripping the prefix repeated on every row", () => {
+ const option = modelOption([opt("anthropic/opus"), opt("anthropic/sonnet")])
+ const groups = deriveModelGroups(option)
+ expect(groups?.map((g) => g.name)).toEqual(["anthropic"])
+ expect(groups?.[0].options.map((o) => o.name)).toEqual(["opus", "sonnet"])
+ })
+
+ it("strips the shared prefix for a single-provider OpenCode list (R2)", () => {
+ // Only one value prefix, nothing floating — must still drop the repeated
+ // `OpenCode Zen/` shown on every row (regression for the real OpenCode shape
+ // when the list contains only that provider).
+ const option = modelOption([
+ opt("opencode/big-pickle", "OpenCode Zen/Big Pickle"),
+ opt("opencode/claude-haiku", "OpenCode Zen/Claude Haiku"),
+ ])
+ const groups = deriveModelGroups(option)
+ expect(groups?.map((g) => g.name)).toEqual(["OpenCode Zen"])
+ expect(groups?.[0].options.map((o) => o.name)).toEqual([
+ "Big Pickle",
+ "Claude Haiku",
+ ])
+ expect(groups?.[0].options.map((o) => o.value)).toEqual([
+ "opencode/big-pickle",
+ "opencode/claude-haiku",
+ ])
+ })
+
+ it("keeps a lone provider flat when its names carry no repeated prefix", () => {
+ // value has a `provider/` prefix but the display names are already clean →
+ // nothing redundant to strip, so no lone header is forced.
+ const option = modelOption([
+ opt("anthropic/opus", "Opus"),
+ opt("anthropic/sonnet", "Sonnet"),
+ ])
+ expect(deriveModelGroups(option)).toBeNull()
+ })
+
+ it("does not mistake a lone slashed display name for a provider prefix", () => {
+ // The `meta` group has a single row whose display-name slash is NOT the
+ // provider (head `Big` ≠ value prefix `meta`) → leave the label intact.
+ const option = modelOption([
+ opt("anthropic/claude-opus", "anthropic/claude-opus"),
+ opt("meta/llama", "Big/Thing"),
+ ])
+ const groups = deriveModelGroups(option)
+ const meta = groups?.find((g) => g.key === "meta")
+ expect(meta?.name).toBe("meta")
+ expect(meta?.options.map((o) => o.name)).toEqual(["Big/Thing"])
+ })
+
+ it("groups a lone provider once a prefix-less value floats beside it", () => {
+ const option = modelOption([
+ opt("default", "Default"),
+ opt("anthropic/opus"),
+ ])
+ const groups = deriveModelGroups(option)
+ expect(groups?.map((g) => g.name)).toEqual([null, "anthropic"])
+ })
+
+ it("groups multi-segment values under their first segment", () => {
+ const option = modelOption([
+ opt("openrouter/anthropic/claude"),
+ opt("openrouter/openai/gpt"),
+ opt("ollama/llama3"),
+ ])
+ const groups = deriveModelGroups(option)
+ expect(groups?.map((g) => g.name)).toEqual(["openrouter", "ollama"])
+ const router = groups?.find((g) => g.name === "openrouter")
+ // Only the first `openrouter/` token is stripped; the sub-path remains.
+ expect(router?.options.map((o) => o.name)).toEqual([
+ "anthropic/claude",
+ "openai/gpt",
+ ])
+ })
+
+ it("does not strip a human label that does not repeat the prefix", () => {
+ const option = modelOption([
+ opt("anthropic/claude-opus", "Claude Opus"),
+ opt("openai/gpt-4o", "GPT-4o"),
+ ])
+ const groups = deriveModelGroups(option)
+ expect(groups?.flatMap((g) => g.options.map((o) => o.name))).toEqual([
+ "Claude Opus",
+ "GPT-4o",
+ ])
+ })
+
+ it("treats leading/trailing slashes as ungroupable (floating)", () => {
+ const option = modelOption([
+ opt("/leading", "/leading"),
+ opt("trailing/", "trailing/"),
+ opt("anthropic/opus", "anthropic/opus"),
+ ])
+ const groups = deriveModelGroups(option) as ModelOptionGroup[]
+ // `/leading` and `trailing/` have no usable prefix → headerless bucket.
+ expect(groups[0]).toMatchObject({ name: null })
+ expect(groups[0].options.map((o) => o.value)).toEqual([
+ "/leading",
+ "trailing/",
+ ])
+ expect(groups.slice(1).map((g) => g.name)).toEqual(["anthropic"])
+ })
+})
+
+const SAMPLE_GROUPS: ModelOptionGroup[] = [
+ { key: "__ungrouped__", name: null, options: [opt("default", "Default")] },
+ {
+ key: "anthropic",
+ name: "anthropic",
+ options: [opt("anthropic/opus", "opus"), opt("anthropic/sonnet", "sonnet")],
+ },
+ {
+ key: "openai",
+ name: "openai",
+ options: [opt("openai/gpt-4o", "gpt-4o")],
+ },
+]
+
+describe("modelListGroups", () => {
+ it("uses the derived provider groups when applicable", () => {
+ const option = modelOption([
+ opt("anthropic/opus", "anthropic/opus"),
+ opt("openai/gpt-4o", "openai/gpt-4o"),
+ ])
+ expect(modelListGroups(option).map((g) => g.name)).toEqual([
+ "anthropic",
+ "openai",
+ ])
+ })
+
+ it("preserves server-provided groups (does NOT flatten them)", () => {
+ // The agent shipped its own grouping → derive returns null; the picker must
+ // keep those groups, not collapse to one headerless bucket.
+ const option = modelOption([])
+ option.kind.groups = [
+ {
+ group: "fast",
+ name: "Fast",
+ options: [opt("a", "A"), opt("b", "B")],
+ },
+ { group: "smart", name: "Smart", options: [opt("c", "C")] },
+ ]
+ const groups = modelListGroups(option)
+ expect(groups.map((g) => g.name)).toEqual(["Fast", "Smart"])
+ expect(groups.flatMap((g) => g.options.map((o) => o.value))).toEqual([
+ "a",
+ "b",
+ "c",
+ ])
+ })
+
+ it("falls back to a single headerless group for a flat list", () => {
+ const option = modelOption([opt("a", "A"), opt("b", "B"), opt("c", "C")])
+ const groups = modelListGroups(option)
+ expect(groups).toHaveLength(1)
+ expect(groups[0].name).toBeNull()
+ expect(groups[0].options.map((o) => o.value)).toEqual(["a", "b", "c"])
+ })
+})
+
+describe("filterModelGroups", () => {
+ it("returns the groups unchanged for an empty query", () => {
+ expect(filterModelGroups(SAMPLE_GROUPS, " ")).toBe(SAMPLE_GROUPS)
+ })
+
+ it("matches on the display name (case-insensitive) and drops empty groups", () => {
+ const result = filterModelGroups(SAMPLE_GROUPS, "OPUS")
+ // Only "opus" (anthropic) matches; floating + openai groups drop out, and
+ // the sibling "sonnet" is filtered out of the anthropic group.
+ expect(result.map((g) => g.key)).toEqual(["anthropic"])
+ expect(result[0].options.map((o) => o.value)).toEqual(["anthropic/opus"])
+ })
+
+ it("matches on the value id even when the label was stripped", () => {
+ // The label is "gpt-4o" but the value is "openai/gpt-4o" — querying the
+ // provider still finds it.
+ const result = filterModelGroups(SAMPLE_GROUPS, "openai")
+ expect(result.map((g) => g.key)).toEqual(["openai"])
+ })
+
+ it("returns an empty list when nothing matches", () => {
+ expect(filterModelGroups(SAMPLE_GROUPS, "zzz")).toEqual([])
+ })
+})
+
+describe("flattenModelGroups", () => {
+ it("emits a header row per named group and one row per option", () => {
+ const rows = flattenModelGroups(SAMPLE_GROUPS)
+ expect(
+ rows.map((r) => (r.kind === "header" ? `#${r.name}` : r.option.value))
+ ).toEqual([
+ // The floating bucket (name === null) contributes NO header row.
+ "default",
+ "#anthropic",
+ "anthropic/opus",
+ "anthropic/sonnet",
+ "#openai",
+ "openai/gpt-4o",
+ ])
+ })
+
+ it("namespaces option keys by group so duplicate values never collide", () => {
+ const rows = flattenModelGroups([
+ { key: "a", name: "a", options: [opt("x/m", "m")] },
+ { key: "b", name: "b", options: [opt("x/m", "m")] },
+ ])
+ const optionKeys = rows.filter((r) => r.kind === "option").map((r) => r.key)
+ expect(new Set(optionKeys).size).toBe(optionKeys.length)
+ })
+})
diff --git a/src/lib/model-config-groups.ts b/src/lib/model-config-groups.ts
new file mode 100644
index 00000000..147ade3b
--- /dev/null
+++ b/src/lib/model-config-groups.ts
@@ -0,0 +1,253 @@
+import type {
+ SessionConfigOptionInfo,
+ SessionConfigSelectOptionInfo,
+} from "@/lib/types"
+
+// A visual group of model options for the composer's model selector. `name`
+// being `null` renders the options with no header — used for the leading
+// "floating" bucket of values that carry no `provider/` prefix. A non-null
+// name is a provider header (e.g. "OpenCode Zen", "anthropic").
+export interface ModelOptionGroup {
+ key: string
+ name: string | null
+ options: SessionConfigSelectOptionInfo[]
+}
+
+// Only the model picker groups by `/` prefix — never the mode picker or any
+// other agent config option. The backend ships the model option with
+// `id === "model"` and no category; Codex's approval-preset option uses
+// `category === "mode"`. Match on either signal so a future relabel stays safe.
+export function isModelConfigOption(option: SessionConfigOptionInfo): boolean {
+ return option.id === "model" || option.category === "model"
+}
+
+// The namespace before the FIRST "/", or `null` when there is no usable prefix
+// (no slash, a leading slash, or a trailing slash with an empty suffix). Values
+// like `openrouter/anthropic/claude` group under their first segment.
+function prefixOf(value: string): string | null {
+ const idx = value.indexOf("/")
+ if (idx <= 0) return null
+ if (idx >= value.length - 1) return null
+ return value.slice(0, idx)
+}
+
+// Split a display name on its first "/", trimming both sides. Returns null when
+// there's no clean split (no slash, or an empty head/tail).
+function splitNamePrefix(name: string): { head: string; tail: string } | null {
+ const idx = name.indexOf("/")
+ if (idx < 0) return null
+ const head = name.slice(0, idx).trim()
+ const tail = name.slice(idx + 1).trim()
+ if (!head || !tail) return null
+ return { head, tail }
+}
+
+// The leading display-name segment shared by EVERY option in a group (e.g.
+// "OpenCode Zen" for names like "OpenCode Zen/Big Pickle"), or null when they
+// don't all repeat the same one. This is what's redundant to show on every row.
+function sharedNamePrefix(
+ items: SessionConfigSelectOptionInfo[]
+): string | null {
+ let shared: string | null = null
+ for (const item of items) {
+ const split = splitNamePrefix(item.name)
+ if (!split) return null
+ if (shared === null) shared = split.head
+ else if (shared !== split.head) return null
+ }
+ return shared
+}
+
+// The shared display-name prefix worth stripping from a group's rows, or null to
+// leave the rows untouched. A group of 2+ rows that all repeat the same leading
+// segment is clearly redundant. A single-row group is only stripped when its
+// name's leading segment IS the value-id prefix (a genuine `provider/model` like
+// `anthropic/claude-opus`) — a lone slashed display name like `GPT-4o/preview`
+// must not be mistaken for a provider prefix.
+function strippablePrefix(
+ valuePrefix: string,
+ items: SessionConfigSelectOptionInfo[]
+): string | null {
+ const shared = sharedNamePrefix(items)
+ if (shared === null) return null
+ if (items.length < 2 && shared !== valuePrefix) return null
+ return shared
+}
+
+// Build one group: when its rows share a strippable prefix, that prefix becomes
+// the header and is removed from every row; otherwise the header is the value-id
+// prefix and the labels are left as-is.
+function buildGroup(
+ valuePrefix: string,
+ items: SessionConfigSelectOptionInfo[]
+): ModelOptionGroup {
+ const shared = strippablePrefix(valuePrefix, items)
+ if (shared === null) {
+ return { key: valuePrefix, name: valuePrefix, options: items }
+ }
+ return {
+ key: valuePrefix,
+ name: shared,
+ options: items.map((opt) => ({
+ ...opt,
+ name: splitNamePrefix(opt.name)!.tail,
+ })),
+ }
+}
+
+// Derive `provider/` prefix groups for the model selector's flat value list.
+//
+// Returns `null` (meaning "render the list as-is, ungrouped") when:
+// - the option is not the model picker,
+// - it is not a select,
+// - the agent already shipped server-side groups (respected verbatim), or
+// - grouping/stripping would add nothing: no value carries a "/", or there is
+// a single provider with nothing floating AND no repeated display prefix to
+// strip (a single provider whose rows DO repeat a prefix is still grouped so
+// that prefix can be stripped — see the lone-provider branch below).
+//
+// Group MEMBERSHIP is by the VALUE's first-"/" segment (the stable model id, so
+// odd display names never fracture a group). The HEADER and the per-row labels
+// come from the DISPLAY NAME: when every row in a group repeats the same leading
+// `Provider/` (e.g. values `opencode/…` but names `OpenCode Zen/…`), that shared
+// segment becomes the header and is stripped from each row so it isn't shown
+// twice. When the names don't share a clean segment there's nothing redundant —
+// the header falls back to the value-id prefix and labels are left untouched.
+// Values are never rewritten; only display labels change.
+export function deriveModelGroups(
+ option: SessionConfigOptionInfo
+): ModelOptionGroup[] | null {
+ if (!isModelConfigOption(option)) return null
+ if (option.kind.type !== "select") return null
+ const kind = option.kind
+ if (kind.groups.length > 0) return null
+
+ const prefixes = kind.options.map((opt) => prefixOf(opt.value))
+ if (!prefixes.some((prefix) => prefix !== null)) return null
+
+ const floating: SessionConfigSelectOptionInfo[] = []
+ const order: string[] = []
+ const byPrefix = new Map()
+
+ kind.options.forEach((opt, index) => {
+ const prefix = prefixes[index]
+ if (prefix === null) {
+ floating.push(opt)
+ return
+ }
+ let bucket = byPrefix.get(prefix)
+ if (!bucket) {
+ bucket = []
+ byPrefix.set(prefix, bucket)
+ order.push(prefix)
+ }
+ bucket.push(opt)
+ })
+
+ // A single provider with nothing floating beside it is only worth surfacing
+ // when its rows share a redundant display prefix to strip (e.g. every row is
+ // "OpenCode Zen/…"); a lone header over an already-clean list is just noise,
+ // so fall back to the flat list.
+ if (order.length === 1 && floating.length === 0) {
+ const prefix = order[0]
+ const items = byPrefix.get(prefix)!
+ if (strippablePrefix(prefix, items) === null) return null
+ return [buildGroup(prefix, items)]
+ }
+
+ const groups: ModelOptionGroup[] = []
+ if (floating.length > 0) {
+ groups.push({ key: "__ungrouped__", name: null, options: floating })
+ }
+ for (const prefix of order) {
+ groups.push(buildGroup(prefix, byPrefix.get(prefix)!))
+ }
+ return groups
+}
+
+// Above this many model options the picker switches to the searchable +
+// virtualized list (a Radix menu / plain list of hundreds of rows is what janks
+// scrolling). Short lists (Claude's handful) keep the lightweight rendering.
+export const MODEL_LIST_VIRTUALIZE_THRESHOLD = 24
+
+// The grouped list to render in the searchable model picker: derived `provider/`
+// groups when applicable, else the agent's server-provided groups (preserved
+// verbatim), else a single headerless group for a flat list. Keeps the wide and
+// collapsed pickers consistent for every shape (incl. server-grouped lists).
+export function modelListGroups(
+ option: SessionConfigOptionInfo
+): ModelOptionGroup[] {
+ if (option.kind.type !== "select") return []
+ const kind = option.kind
+ const derived = deriveModelGroups(option)
+ if (derived) return derived
+ if (kind.groups.length > 0) {
+ return kind.groups.map((group) => ({
+ key: group.group,
+ name: group.name,
+ options: group.options,
+ }))
+ }
+ return [{ key: "__all__", name: null, options: kind.options }]
+}
+
+// Filter a group list by a search query, matching each option's display name OR
+// its value id (case-insensitive substring). Groups left with no matching option
+// are dropped. An empty/whitespace query returns the groups unchanged.
+export function filterModelGroups(
+ groups: ModelOptionGroup[],
+ query: string
+): ModelOptionGroup[] {
+ const q = query.trim().toLowerCase()
+ if (!q) return groups
+ const result: ModelOptionGroup[] = []
+ for (const group of groups) {
+ const options = group.options.filter(
+ (opt) =>
+ opt.name.toLowerCase().includes(q) ||
+ opt.value.toLowerCase().includes(q)
+ )
+ if (options.length > 0) result.push({ ...group, options })
+ }
+ return result
+}
+
+// A single rendered row in the flattened model list: either a group header
+// (non-null group name) or a selectable option. Headers carry no value; options
+// carry their full option plus the owning group key (for stable React keys).
+export type ModelOptionRow =
+ | { kind: "header"; key: string; name: string }
+ | {
+ kind: "option"
+ key: string
+ groupKey: string
+ option: SessionConfigSelectOptionInfo
+ }
+
+// Flatten groups into a single row list for a (virtualized) list view: a header
+// row per named group (headerless/floating buckets contribute no header row),
+// followed by one option row per option. Stable keys are namespaced by group so
+// the same value under two groups never collides.
+export function flattenModelGroups(
+ groups: ModelOptionGroup[]
+): ModelOptionRow[] {
+ const rows: ModelOptionRow[] = []
+ for (const group of groups) {
+ if (group.name !== null) {
+ rows.push({
+ kind: "header",
+ key: `header:${group.key}`,
+ name: group.name,
+ })
+ }
+ for (const option of group.options) {
+ rows.push({
+ kind: "option",
+ key: `option:${group.key}:${option.value}`,
+ groupKey: group.key,
+ option,
+ })
+ }
+ }
+ return rows
+}
diff --git a/src/test-setup.ts b/src/test-setup.ts
index 46014e98..c789fdc7 100644
--- a/src/test-setup.ts
+++ b/src/test-setup.ts
@@ -11,6 +11,11 @@ if (typeof Element !== "undefined") {
// jsdom doesn't implement scrollIntoView; the composer's suggestion popup
// calls it to keep the active row visible.
Element.prototype.scrollIntoView ??= () => {}
+ // jsdom doesn't implement Pointer Capture; Radix menus/popovers touch these
+ // during the pointer interactions @testing-library/user-event drives.
+ Element.prototype.hasPointerCapture ??= () => false
+ Element.prototype.setPointerCapture ??= () => {}
+ Element.prototype.releasePointerCapture ??= () => {}
}
if (typeof Range !== "undefined") {
Range.prototype.getClientRects ??= () =>