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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 5 additions & 36 deletions src/components/chat/message-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@ import {
type SessionSelectorSetting,
} from "@/components/chat/session-selectors-panel"
import {
deriveModelGroups,
isModelConfigOption,
modelListGroups,
MODEL_LIST_VIRTUALIZE_THRESHOLD,
Expand Down Expand Up @@ -1475,7 +1474,7 @@ export function MessageInput({
<InlineSessionConfigSelector
key={option.id}
option={option}
derivedGroups={deriveModelGroups(option)}
derivedGroups={modelListGroups(option)}
onSelect={(configId, valueId) =>
onConfigOptionChange?.(configId, valueId)
}
Expand Down Expand Up @@ -1526,40 +1525,10 @@ export function MessageInput({
}
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,
})),
},
]
// Same grouping + display polish as the wide picker (provider headers,
// versioned names, no duplicate sibling blurbs) so the collapsed rail
// cannot drift from the composer chips.
const groups: SessionSelectorGroup[] = modelListGroups(option)
// 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/`.
Expand Down
40 changes: 40 additions & 0 deletions src/components/chat/session-config-selector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,46 @@ describe("InlineSessionConfigSelector — model grouping", () => {
// No provider headers for an ungroupable flat list.
expect(screen.queryByText("anthropic")).toBeNull()
})

it("promotes Fable 5 onto the trigger and drops Default's sibling blurb", async () => {
const user = userEvent.setup()
const option = modelOption(
[
{
value: "default",
name: "Default (recommended)",
description: "Opus (1M context)",
},
{ value: "opus[1m]", name: "Opus (1M context)" },
{
value: "fable",
name: "Fable",
description: "Fable 5 · Most capable, complex agents",
},
{ value: "sonnet", name: "Sonnet" },
{ value: "haiku", name: "Haiku" },
],
"fable"
)
render(<InlineSessionConfigSelector option={option} onSelect={vi.fn()} />)

// Trigger uses the promoted name, not the short ACP label.
expect(screen.getByRole("button", { name: /Fable 5/ })).toBeInTheDocument()
expect(screen.queryByRole("button", { name: /^Model: Fable$/ })).toBeNull()

await user.click(screen.getByRole("button", { name: /Fable 5/ }))

const fable = await screen.findByRole("menuitemradio", { name: /Fable 5/ })
expect(fable).toHaveTextContent("Most capable, complex agents")
expect(fable).not.toHaveTextContent("Fable 5 ·")

const def = screen.getByRole("menuitemradio", {
name: /Default \(recommended\)/,
})
// The sibling title used to be Default's description, which made the
// list look like two copies of Opus (1M context).
expect(def).not.toHaveTextContent("Opus (1M context)")
})
})

// Cline 3.0.50's `auto_approve` — the first boolean config option any pinned
Expand Down
16 changes: 11 additions & 5 deletions src/components/chat/session-config-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ 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 {
polishSelectOptions,
type ModelOptionGroup,
} from "@/lib/model-config-groups"
import type { SessionConfigOptionInfo } from "@/lib/types"

interface SessionConfigSelectorProps {
Expand All @@ -41,12 +44,15 @@ export function InlineSessionConfigSelector({
// `name === null` is a headerless bucket (the leading prefix-less models).
const renderGroups: ModelOptionGroup[] | null =
derivedGroups && derivedGroups.length > 0
? derivedGroups
? derivedGroups.map((group) => ({
...group,
options: polishSelectOptions(group.options),
}))
: option.kind.groups.length > 0
? option.kind.groups.map((group) => ({
key: group.group,
name: group.name,
options: group.options,
options: polishSelectOptions(group.options),
}))
: null

Expand All @@ -55,7 +61,7 @@ export function InlineSessionConfigSelector({
// the group it sits in) rather than repeating `provider/`.
const renderedOptions = renderGroups
? renderGroups.flatMap((group) => group.options)
: option.kind.options
: polishSelectOptions(option.kind.options)
const selected = renderedOptions.find(
(item) => item.value === option.kind.current_value
)
Expand Down Expand Up @@ -112,7 +118,7 @@ export function InlineSessionConfigSelector({
))}
</Fragment>
))
: option.kind.options.map((item) => (
: renderedOptions.map((item) => (
<DropdownMenuRadioItem
key={item.value}
value={item.value}
Expand Down
119 changes: 119 additions & 0 deletions src/lib/model-config-groups.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
flattenModelGroups,
isModelConfigOption,
modelListGroups,
polishSelectOptions,
type ModelOptionGroup,
} from "./model-config-groups"
import type {
Expand Down Expand Up @@ -295,6 +296,84 @@ const SAMPLE_GROUPS: ModelOptionGroup[] = [
},
]

describe("polishSelectOptions", () => {
// Claude's short ACP catalog: short names, versioned heads in the
// description, and Default's blurb repeating a sibling title.
const claudeCatalog = [
opt("default", "Default (recommended)", "Opus (1M context)"),
opt("opus[1m]", "Opus (1M context)"),
opt("fable", "Fable", "Fable 5 · Most capable, complex agents"),
opt("sonnet", "Sonnet"),
opt("haiku", "Haiku"),
]

it("promotes a versioned description head so the trigger matches the row", () => {
const polished = polishSelectOptions(claudeCatalog)
const fable = polished.find((o) => o.value === "fable")
expect(fable).toMatchObject({
value: "fable",
name: "Fable 5",
description: "Most capable, complex agents",
})
})

it("drops a description that is exactly a sibling option's name", () => {
const polished = polishSelectOptions(claudeCatalog)
const def = polished.find((o) => o.value === "default")
expect(def).toMatchObject({
value: "default",
name: "Default (recommended)",
description: null,
})
})

it("leaves already-clean sibling names and values alone", () => {
const polished = polishSelectOptions(claudeCatalog)
expect(polished.map((o) => o.value)).toEqual([
"default",
"opus[1m]",
"fable",
"sonnet",
"haiku",
])
expect(polished.find((o) => o.value === "opus[1m]")?.name).toBe(
"Opus (1M context)"
)
expect(polished.find((o) => o.value === "sonnet")?.name).toBe("Sonnet")
expect(polished.find((o) => o.value === "haiku")?.name).toBe("Haiku")
})

it("promotes a version-only description with no blurb separator", () => {
const polished = polishSelectOptions([
opt("fable", "Fable", "Fable 5"),
opt("opus", "Opus", "Opus 4.6 (1M context)"),
])
expect(polished[0]).toMatchObject({
name: "Fable 5",
description: null,
})
expect(polished[1]).toMatchObject({
name: "Opus 4.6 (1M context)",
description: null,
})
})

it("does not steal a unique blurb that does not start with the name", () => {
const polished = polishSelectOptions([
opt("sonnet", "Sonnet", "Balanced speed and quality"),
])
expect(polished[0]).toMatchObject({
name: "Sonnet",
description: "Balanced speed and quality",
})
})

it("is idempotent", () => {
const once = polishSelectOptions(claudeCatalog)
expect(polishSelectOptions(once)).toEqual(once)
})
})

describe("modelListGroups", () => {
it("uses the derived provider groups when applicable", () => {
const option = modelOption([
Expand Down Expand Up @@ -335,6 +414,46 @@ describe("modelListGroups", () => {
expect(groups[0].name).toBeNull()
expect(groups[0].options.map((o) => o.value)).toEqual(["a", "b", "c"])
})

it("polishes a flat Claude list so the trigger can show Fable 5", () => {
const catalog = modelOption([
opt("default", "Default (recommended)", "Opus (1M context)"),
opt("opus[1m]", "Opus (1M context)"),
opt("fable", "Fable", "Fable 5 · Most capable, complex agents"),
opt("sonnet", "Sonnet"),
opt("haiku", "Haiku"),
])
catalog.kind.current_value = "fable"
const groups = modelListGroups(catalog)
expect(groups).toHaveLength(1)
expect(groups[0].name).toBeNull()
const fable = groups[0].options.find((o) => o.value === "fable")
expect(fable?.name).toBe("Fable 5")
expect(
groups[0].options.find((o) => o.value === "default")?.description
).toBeNull()
})

it("polishes before stripping a shared provider prefix", () => {
// If grouping ran first, the name would become "Fable" while the
// description still started with "OpenCode Zen/Fable 5", and promotion
// would miss.
const option = modelOption([
opt(
"opencode/fable",
"OpenCode Zen/Fable",
"OpenCode Zen/Fable 5 · Most capable"
),
opt("opencode/sonnet", "OpenCode Zen/Sonnet"),
])
const groups = modelListGroups(option)
expect(groups.map((g) => g.name)).toEqual(["OpenCode Zen"])
expect(groups[0].options.map((o) => o.name)).toEqual(["Fable 5", "Sonnet"])
expect(groups[0].options[0]).toMatchObject({
value: "opencode/fable",
description: "Most capable",
})
})
})

describe("filterModelGroups", () => {
Expand Down
Loading
Loading