From 24b6b375cdd1df5e0b14601d8e5e6e3a3e206324 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Thu, 18 Jun 2026 16:27:06 +0800 Subject: [PATCH 1/4] fix(composer): apply collapsed agent selector changes on narrow layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the composer is narrow, the model/config/mode selectors collapse behind the cog button. Selecting an option there did nothing — the value never changed and the popup stayed open on the desktop webview (WKWebView), where a popup layer nested inside another drops the selection. - Replace the collapsed selectors with a single popover holding a master-detail panel: the settings on the left, the active setting's options on the right, all plain buttons. Choosing an option applies the value and closes the popover. - The wide-layout inline selectors are unchanged. --- src/components/chat/message-input.test.tsx | 93 +++++++++- src/components/chat/message-input.tsx | 163 +++++++++++----- src/components/chat/mode-selector.tsx | 41 +---- .../chat/session-config-selector.tsx | 82 +-------- .../chat/session-selectors-panel.tsx | 164 +++++++++++++++++ .../chat/session-selectors.test.tsx | 174 ++++++++++++++++++ src/test-setup.ts | 5 + 7 files changed, 560 insertions(+), 162 deletions(-) create mode 100644 src/components/chat/session-selectors-panel.tsx create mode 100644 src/components/chat/session-selectors.test.tsx diff --git a/src/components/chat/message-input.test.tsx b/src/components/chat/message-input.test.tsx index bfe8d5e5..6a10a44c 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" @@ -73,7 +76,10 @@ vi.mock("@/lib/transport", () => ({ })) 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 +205,88 @@ 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 - - + - {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/session-config-selector.tsx b/src/components/chat/session-config-selector.tsx index 38745a2a..0be2a553 100644 --- a/src/components/chat/session-config-selector.tsx +++ b/src/components/chat/session-config-selector.tsx @@ -10,9 +10,6 @@ import { DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { DropdownRadioItemContent } from "@/components/chat/dropdown-radio-item-content" @@ -23,82 +20,6 @@ interface SessionConfigSelectorProps { 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) => ( - - - - ))} - - - - ) -} - export function InlineSessionConfigSelector({ option, onSelect, @@ -121,6 +42,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} diff --git a/src/components/chat/session-selectors-panel.tsx b/src/components/chat/session-selectors-panel.tsx new file mode 100644 index 00000000..98461109 --- /dev/null +++ b/src/components/chat/session-selectors-panel.tsx @@ -0,0 +1,164 @@ +"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" + +// 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[] +} + +// 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 +} + +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 ` + ) + })} + + + {/* Right pane: the active setting's options (the "sub-options"). + Each option is a plain + ) + })} + + ))} + + + ) +} 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/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 ??= () => From e1c54549bf669558a42b4cce0b9734c3955d195d Mon Sep 17 00:00:00 2001 From: xintaofei Date: Thu, 18 Jun 2026 21:49:04 +0800 Subject: [PATCH 2/4] feat(composer): group and virtualize the model selector Group the model selector's options by their "/" provider prefix and strip the shared prefix from each label, in both the wide inline dropdown and the narrow collapsed panel. Render long model lists in a searchable, virtualized picker so scrolling stays smooth, while short lists keep the lightweight dropdown. --- src/components/chat/message-input.test.tsx | 106 +++++ src/components/chat/message-input.tsx | 92 ++++- .../chat/model-option-list.test.tsx | 159 ++++++++ src/components/chat/model-option-list.tsx | 235 +++++++++++ src/components/chat/model-option-picker.tsx | 88 ++++ .../chat/session-config-selector.test.tsx | 170 ++++++++ .../chat/session-config-selector.tsx | 47 ++- .../chat/session-selectors-panel.tsx | 121 ++++-- src/i18n/messages/ar.json | 4 + src/i18n/messages/de.json | 4 + src/i18n/messages/en.json | 4 + src/i18n/messages/es.json | 4 + src/i18n/messages/fr.json | 4 + src/i18n/messages/ja.json | 4 + src/i18n/messages/ko.json | 4 + src/i18n/messages/pt.json | 4 + src/i18n/messages/zh-CN.json | 4 + src/i18n/messages/zh-TW.json | 4 + src/lib/model-config-groups.test.ts | 385 ++++++++++++++++++ src/lib/model-config-groups.ts | 253 ++++++++++++ 20 files changed, 1630 insertions(+), 66 deletions(-) create mode 100644 src/components/chat/model-option-list.test.tsx create mode 100644 src/components/chat/model-option-list.tsx create mode 100644 src/components/chat/model-option-picker.tsx create mode 100644 src/components/chat/session-config-selector.test.tsx create mode 100644 src/lib/model-config-groups.test.ts create mode 100644 src/lib/model-config-groups.ts diff --git a/src/components/chat/message-input.test.tsx b/src/components/chat/message-input.test.tsx index 6a10a44c..936a2299 100644 --- a/src/components/chat/message-input.test.tsx +++ b/src/components/chat/message-input.test.tsx @@ -74,6 +74,24 @@ 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 { @@ -263,6 +281,94 @@ describe("MessageInput collapsed selectors popover", () => { ) }) + 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() diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 352a7fd6..7d9d3d9b 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -110,11 +110,19 @@ import { } 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 { SessionSelectorsPanel, type SessionSelectorGroup, type SessionSelectorSetting, } from "@/components/chat/session-selectors-panel" +import { + deriveModelGroups, + isModelConfigOption, + modelListGroups, + MODEL_LIST_VIRTUALIZE_THRESHOLD, + type ModelOptionGroup, +} from "@/lib/model-config-groups" import { getExpertIcon, pickExpertLocalized, @@ -428,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, @@ -2421,15 +2444,34 @@ export function MessageInput({ 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 && ( 0 + // 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, @@ -2472,9 +2526,17 @@ export function MessageInput({ })), }, ] + // 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, @@ -2482,6 +2544,14 @@ export function MessageInput({ 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"), + }, + }), }) } } 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 ( + + ) + })} +
+ )} +
+ ) +} 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 ( + + + + + + { + 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 0be2a553..7f8c42d1 100644 --- a/src/components/chat/session-config-selector.tsx +++ b/src/components/chat/session-config-selector.tsx @@ -13,24 +13,49 @@ import { 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 + /** + * 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 @@ -65,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 index 98461109..96a0011d 100644 --- a/src/components/chat/session-selectors-panel.tsx +++ b/src/components/chat/session-selectors-panel.tsx @@ -4,6 +4,7 @@ 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 { @@ -20,6 +21,14 @@ export interface SessionSelectorGroup { 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 @@ -28,6 +37,9 @@ export interface SessionSelectorSetting { 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 { @@ -111,54 +123,73 @@ export function SessionSelectorsPanel({ 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.groups.map((group, groupIndex) => ( -
- {group.name ? ( -
- {group.name} -
- ) : null} - {group.options.map((opt) => { - const selected = opt.value === active.currentValue - return ( - - ) - })} -
- ))} -
+ {group.name} + + ) : null} + {group.options.map((opt) => { + const selected = opt.value === active.currentValue + return ( + + ) + })} + + ))} + + )} ) } diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 1667f3d5..4bc83f8b 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -1844,6 +1844,10 @@ "loadingMode": "جارٍ تحميل الوضع...", "modeLabel": "الوضع", "agentSettings": "إعدادات الوكيل", + "searchModel": "البحث عن النماذج...", + "searchModelAria": "البحث عن النماذج", + "modelListLabel": "النماذج", + "noModels": "لم يتم العثور على نماذج", "cancel": "إلغاء", "send": "إرسال", "forkAndSend": "تفريع وإرسال", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 8a9b3ca4..4eb8710b 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1844,6 +1844,10 @@ "loadingMode": "Modus wird geladen...", "modeLabel": "Modus", "agentSettings": "Agent-Einstellungen", + "searchModel": "Modelle suchen...", + "searchModelAria": "Modelle suchen", + "modelListLabel": "Modelle", + "noModels": "Keine Modelle gefunden", "cancel": "Abbrechen", "send": "Senden", "forkAndSend": "Fork & Senden", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 55297592..c37f3a07 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1846,6 +1846,10 @@ "loadingMode": "Loading mode...", "modeLabel": "Mode", "agentSettings": "Agent settings", + "searchModel": "Search models...", + "searchModelAria": "Search models", + "modelListLabel": "Models", + "noModels": "No models found", "cancel": "Cancel", "send": "Send", "forkAndSend": "Fork & Send", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 33fba289..4b63f765 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -1844,6 +1844,10 @@ "loadingMode": "Cargando modo...", "modeLabel": "Modo", "agentSettings": "Ajustes del agente", + "searchModel": "Buscar modelos...", + "searchModelAria": "Buscar modelos", + "modelListLabel": "Modelos", + "noModels": "No se encontraron modelos", "cancel": "Cancelar", "send": "Enviar", "forkAndSend": "Fork y Enviar", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 4586df1a..e5d99057 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -1844,6 +1844,10 @@ "loadingMode": "Chargement du mode...", "modeLabel": "Mode", "agentSettings": "Paramètres de l'agent", + "searchModel": "Rechercher des modèles...", + "searchModelAria": "Rechercher des modèles", + "modelListLabel": "Modèles", + "noModels": "Aucun modèle trouvé", "cancel": "Annuler", "send": "Envoyer", "forkAndSend": "Fork & Envoyer", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 8a1769a9..06a87810 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -1844,6 +1844,10 @@ "loadingMode": "モードを読み込み中...", "modeLabel": "モード", "agentSettings": "エージェント設定", + "searchModel": "モデルを検索...", + "searchModelAria": "モデルを検索", + "modelListLabel": "モデル", + "noModels": "モデルが見つかりません", "cancel": "キャンセル", "send": "送信", "forkAndSend": "フォークして送信", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index bac98d6c..3f8eb627 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -1844,6 +1844,10 @@ "loadingMode": "모드 불러오는 중...", "modeLabel": "모드", "agentSettings": "에이전트 설정", + "searchModel": "모델 검색...", + "searchModelAria": "모델 검색", + "modelListLabel": "모델", + "noModels": "모델을 찾을 수 없습니다", "cancel": "취소", "send": "보내기", "forkAndSend": "포크 & 전송", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 711ad7b4..fa1de1d3 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1844,6 +1844,10 @@ "loadingMode": "Carregando modo...", "modeLabel": "Modo", "agentSettings": "Configurações do agente", + "searchModel": "Buscar modelos...", + "searchModelAria": "Buscar modelos", + "modelListLabel": "Modelos", + "noModels": "Nenhum modelo encontrado", "cancel": "Cancelar", "send": "Enviar", "forkAndSend": "Fork & Enviar", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 4c099d44..bce876d3 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -1846,6 +1846,10 @@ "loadingMode": "正在加载模式...", "modeLabel": "模式", "agentSettings": "智能体设置", + "searchModel": "搜索模型...", + "searchModelAria": "搜索模型", + "modelListLabel": "模型", + "noModels": "未找到模型", "cancel": "取消", "send": "发送", "forkAndSend": "分叉发送", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 70669f9e..8e1b3675 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -1844,6 +1844,10 @@ "loadingMode": "正在載入模式...", "modeLabel": "模式", "agentSettings": "智能體設定", + "searchModel": "搜尋模型...", + "searchModelAria": "搜尋模型", + "modelListLabel": "模型", + "noModels": "找不到模型", "cancel": "取消", "send": "傳送", "forkAndSend": "分叉發送", diff --git a/src/lib/model-config-groups.test.ts b/src/lib/model-config-groups.test.ts new file mode 100644 index 00000000..10982e01 --- /dev/null +++ b/src/lib/model-config-groups.test.ts @@ -0,0 +1,385 @@ +import { describe, expect, it } from "vitest" + +import { + deriveModelGroups, + filterModelGroups, + flattenModelGroups, + isModelConfigOption, + modelListGroups, + type ModelOptionGroup, +} from "./model-config-groups" +import type { + SessionConfigOptionInfo, + SessionConfigSelectOptionInfo, +} from "@/lib/types" + +function modelOption( + options: SessionConfigSelectOptionInfo[], + overrides: Partial = {} +): 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 +} From 63b1ef968fa5d06747fc98a004047f31ec14bace Mon Sep 17 00:00:00 2001 From: xintaofei Date: Thu, 18 Jun 2026 22:09:54 +0800 Subject: [PATCH 3/4] fix(ui): wrap long text in alert dialog descriptions --- src/components/ui/alert-dialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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({ Date: Thu, 18 Jun 2026 22:54:57 +0800 Subject: [PATCH 4/4] # Release version 0.15.14 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feat(composer): The model selector now groups models by provider and opens long lists in a searchable, virtualized picker. - fix(composer): On narrow layouts, picking an option from the collapsed selectors now actually applies it instead of doing nothing. - fix(ui): Long text in confirmation dialogs now wraps instead of overflowing. ----------------------------- # 发布版本 0.15.14 - 功能(输入框):模型选择器现在按供应商分组,较长的列表会在可搜索、虚拟化的选择器中打开。 - 修复(输入框):在窄布局下,从折叠的选择器中选择选项现在能真正生效,不再点击无反应。 - 修复(界面):确认弹窗中的长文本现在会自动换行,不再溢出。 --- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) 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",