feat(app): add new model picker experiment (#58413)

This commit is contained in:
Aarav Sareen
2026-10-23 15:22:54 +05:30
committed by GitHub
Unverified
parent 6214451724
commit 46eca76752
9 changed files with 1310 additions and 3 deletions
+5 -1
View File
@@ -1,6 +1,8 @@
import { useCommand, type CommandOption } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { DialogSelectModel } from "@/providers/models/select-dialog"
import { DialogSelectModelNext } from "@/providers/models/select-dialog-next"
import { useSettings } from "@/settings/model"
import { useLocal, type ModelSelection } from "@/providers/models/selection"
import { useDialog } from "@opencode/ui/context/dialog"
import { useSessionLayout } from "@/session/session-layout"
@@ -19,6 +21,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
const dialog = useDialog()
const language = useLanguage()
const local = useLocal()
const settings = useSettings()
const workspace = useWorkspaceLocation()
const { sessionKey } = useSessionLayout()
const sessionOwnership = createSessionOwnership(sessionKey)
@@ -35,8 +38,9 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
}
const chooseModel = () => {
const Picker = settings.general.newModelPicker() ? DialogSelectModelNext : DialogSelectModel
sessionOwnership.capture().run(() => {
void dialog.show(() => <DialogSelectModel model={model} />)
void dialog.show(() => <Picker model={model} />)
})
}
+8 -2
View File
@@ -1,4 +1,5 @@
import { Show, createEffect, createMemo, createSignal } from "solid-js"
import { Dynamic } from "solid-js/web"
import { createMediaQuery } from "@solid-primitives/media"
import { Button } from "@opencode/ui/button"
import { useDialog } from "@opencode/ui/context/dialog"
@@ -9,6 +10,8 @@ import { consoleProviderName } from "@/providers/catalog/console"
import { Tooltip } from "@opencode/ui/tooltip"
import { ComposerEditor } from "./editor/editor"
import { DialogSelectModel, ModelSelectorDrawer } from "@/providers/models/select-dialog"
import { DialogSelectModelNext, ModelSelectorDrawerNext } from "@/providers/models/select-dialog-next"
import { useSettings } from "@/settings/model"
import { ModelDetails } from "@/providers/models/details"
import { DialogSelectModelUnpaid } from "@/providers/models/unpaid"
import { formatKeybind, useCommand } from "@/shell/commands/command"
@@ -89,13 +92,15 @@ function ComposerModelControl(props: {
const shouldAnimate = createMemo<boolean>((previous) => previous ?? (props.loading && !props.preview))
const mobile = createMediaQuery("(max-width: 767px)")
const dialog = useDialog()
const settings = useSettings()
const managed = createMemo(() => consoleModelGroup(props.model.list()))
const open = (detail: number) => {
if (!props.paid) return props.onUnpaidClick()
const Picker = settings.general.newModelPicker() ? DialogSelectModelNext : DialogSelectModel
// A pointer open keeps focus in the composer, so the dialog returns it to the caret. A keyboard open
// (a click with no pointer presses) returns focus to this trigger instead of moving it to the editor.
void dialog.show(() => <DialogSelectModel model={props.model} />, detail === 0 ? undefined : props.onClose)
void dialog.show(() => <Picker model={props.model} />, detail === 0 ? undefined : props.onClose)
}
// A previewed chip is visible before the catalog loads, so a click on it opens the dialog once loading finishes.
@@ -190,7 +195,8 @@ function ComposerModelControl(props: {
</Button>
}
>
<ModelSelectorDrawer
<Dynamic
component={settings.general.newModelPicker() ? ModelSelectorDrawerNext : ModelSelectorDrawer}
model={props.model}
trigger={(triggerProps) => (
<Button
@@ -0,0 +1,214 @@
/* The "New model picker" experiment. Layout first; spacing and type values are still being tuned. */
[data-component="dialog-v2"]:has(.model-dialog-next) [data-slot="dialog-container"] {
width: min(calc(100vw - 24px), 480px);
min-height: 0;
border-radius: 10px;
background: var(--v2-background-bg-base);
}
.model-dialog-next {
/* The inset lives on the field and the rows, so the scroll area and its scrollbar reach the dialog's edges. */
[data-component="search-list"] {
padding-top: 6px;
}
/* The 4px bar is centered in a 12px track; pulling the track out puts the bar 2px from the edge. */
[data-component="search-list"] .scroll-view__thumb[data-orientation="vertical"] {
inset-inline-end: -2px;
}
[data-component="search-list"] [data-slot="search-list-search"] {
display: flex;
flex-direction: column;
gap: 6px;
padding: 0 6px;
/* Full width across the list's padding, and a hairline on any display. */
&::after {
height: 1px;
margin-inline: -6px;
background: var(--v2-border-border-muted);
content: "";
transform: scaleY(0.5);
}
}
[data-component="search-list"] [data-slot="search-list-search"] [data-component="text-input-v2"],
[data-component="search-list"]
[data-slot="search-list-search"]
[data-component="text-input-v2"]:where(:hover, :focus-within):not([data-disabled], [data-invalid]) {
height: 28px;
padding-inline-end: 2px;
border-radius: 0;
background: transparent;
}
/* The 24px manage button sits 2px from every edge of the 28px row. */
[data-component="search-list"] [data-slot="text-input-v2-trailing"] {
margin-inline-end: 0;
[data-component="icon-button-v2"] [data-slot="icon-svg"] {
color: var(--v2-icon-icon-muted);
}
}
[data-component="search-list"] [data-slot="text-input-v2-leading-icon"] {
display: none;
}
/* Row inset, check slot and gap: the query starts where the model names do. */
[data-component="search-list"] [data-slot="text-input-v2-input"] {
padding-inline-start: 22px;
}
/* The stuck header's gradient replaces it; the list measures sections for it, and here they have no box. */
[data-component="search-list"] [data-slot="search-list-fade"] {
display: none;
}
/* Arrow keys keep the active row clear of the stuck header. */
[data-component="search-list"] [data-slot="search-list-scroll"] > .scroll-view__viewport {
scroll-padding-top: 32px;
}
[data-component="search-list"] [data-slot="search-list-results"] {
gap: 4px;
padding: 0 6px 6px;
}
/*
* Without a box, a section no longer bounds its header, so every header sticks for the whole list and the next one
* slides over it, as on the home page, instead of pushing it away.
*/
[data-component="search-list"] [data-slot="search-list-section"] {
display: contents;
}
/*
* The header bleeds across the list's 6px inset so rows scroll under an opaque band; its text lines up with the
* model names. It starts right under the divider, so it rests where it sticks.
*/
[data-component="search-list"] [data-slot="search-list-header"] {
height: auto;
padding-block: 12px 2px;
padding-inline: 28px 6px;
background: var(--v2-background-bg-base);
color: var(--v2-text-text-muted);
font-size: 12px;
line-height: 18px;
/* The home page's sticky header fade, faded in by how far rows have scrolled beneath the stuck header. */
&::after {
position: absolute;
top: 100%;
inset-inline: 0;
height: 16px;
background: linear-gradient(
180deg,
var(--v2-background-bg-base) 0%,
color-mix(in srgb, var(--v2-background-bg-base) 92.0456%, transparent) 7.93%,
color-mix(in srgb, var(--v2-background-bg-base) 84.9947%, transparent) 14.14%,
color-mix(in srgb, var(--v2-background-bg-base) 78.6813%, transparent) 19%,
color-mix(in srgb, var(--v2-background-bg-base) 72.9394%, transparent) 22.85%,
color-mix(in srgb, var(--v2-background-bg-base) 67.6028%, transparent) 26.05%,
color-mix(in srgb, var(--v2-background-bg-base) 62.5055%, transparent) 28.95%,
color-mix(in srgb, var(--v2-background-bg-base) 57.4815%, transparent) 31.91%,
color-mix(in srgb, var(--v2-background-bg-base) 52.3647%, transparent) 35.27%,
color-mix(in srgb, var(--v2-background-bg-base) 46.989%, transparent) 39.4%,
color-mix(in srgb, var(--v2-background-bg-base) 41.1884%, transparent) 44.65%,
color-mix(in srgb, var(--v2-background-bg-base) 34.7969%, transparent) 51.36%,
color-mix(in srgb, var(--v2-background-bg-base) 27.6484%, transparent) 59.9%,
color-mix(in srgb, var(--v2-background-bg-base) 19.5767%, transparent) 70.62%,
color-mix(in srgb, var(--v2-background-bg-base) 10.416%, transparent) 83.87%,
transparent 100%
);
content: "";
opacity: 0;
pointer-events: none;
}
&[data-stuck]::after {
opacity: var(--model-dialog-header-fade, 0);
}
/*
* A header sliding up over the stuck one paints only behind its own text, so its padding never covers the stuck
* label as it fades. Once stuck, it fills its whole band again to hide the rows scrolling beneath.
*/
&:not([data-stuck]) {
background-clip: content-box;
}
}
[data-component="search-list"]
[data-slot="search-list-section"]:first-child
> [data-slot="search-list-header"] {
padding-top: 16px;
}
[data-component="search-list"] [data-slot="search-list-row"] {
justify-content: flex-start;
gap: 3px;
height: 28px;
padding-inline: 3px;
border-radius: 6px;
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-base);
letter-spacing: -0.04px;
}
[data-slot="model-dialog-check"] {
display: flex;
width: 16px;
flex-shrink: 0;
align-items: center;
justify-content: center;
color: var(--v2-icon-icon-base);
}
[data-slot="model-dialog-name"] {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: 6px;
}
[data-slot="model-dialog-variant"] {
display: flex;
min-width: 0;
max-width: 120px;
flex-shrink: 0;
align-items: center;
color: var(--v2-text-text-muted);
[data-slot="icon-svg"] {
color: var(--v2-icon-icon-muted);
translate: 0 0.5px;
}
}
}
/* Outranks the list's dark-scheme highlight, which uses a lighter alpha. */
[data-component="dialog-v2"] .model-dialog-next [data-component="search-list"] [data-slot="search-list-row"][data-active] {
background: var(--v2-overlay-simple-overlay-hover);
}
[data-component="menu-v2-content"][data-slot="model-dialog-next-flyout"] {
position: fixed;
/* One width for every model, matching the 160px its placement assumes. */
width: 160px;
max-height: 400px;
overflow-y: auto;
animation: none;
/* The model stats that close the flyout. */
> :last-child {
font-size: 11px;
line-height: 16px;
cursor: default;
user-select: none;
}
}
File diff suppressed because it is too large Load Diff
+4
View File
@@ -156,6 +156,8 @@ export const dict = {
"dialog.model.hint.variant": "Variant",
"dialog.model.hint.select": "Select",
"dialog.model.hint.favorite": "Favorite",
"dialog.model.favorite": "Favorite",
"dialog.model.unfavorite": "Unfavorite",
"dialog.model.unpaid.freeModels.title": "Free models provided by OpenCode",
"dialog.model.unpaid.addMore.title": "Add more models from popular providers",
@@ -787,6 +789,8 @@ export const dict = {
"settings.experimental.row.generatedUI.description": "Let agents show dashboards, charts and forms in the timeline",
"settings.experimental.row.newIcons.title": "New icons",
"settings.experimental.row.newIcons.description": "Preview the updated icon set",
"settings.experimental.row.newModelPicker.title": "New model picker",
"settings.experimental.row.newModelPicker.description": "Preview the redesigned model picker",
"settings.guiExtensions.title": "Extensions",
"settings.guiExtensions.description": "Built-in GUI extensions in this window. Development builds only.",
"settings.guiExtensions.reload": "Reload",
@@ -50,6 +50,17 @@ export const SettingsExperimental: Component = () => {
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.experimental.row.newModelPicker.title")}
description={language.t("settings.experimental.row.newModelPicker.description")}
>
<div data-action="settings-new-model-picker">
<Switch
checked={settings.general.newModelPicker()}
onChange={(checked) => settings.general.setNewModelPicker(checked)}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
</div>
+1
View File
@@ -97,6 +97,7 @@ describe("settings schema", () => {
timelineWidth: "comfortable",
generatedUI: true,
newIcons: false,
newModelPicker: false,
mobileTitlebarPosition: "top",
followUpBehavior: "steer",
},
+7
View File
@@ -131,6 +131,8 @@ const generalSchema = Persistence.struct({
generatedUI: Schema.Boolean,
// The "New icons" design experiment, off until it ships.
newIcons: Schema.Boolean,
// The "New model picker" design experiment, off until it ships.
newModelPicker: Schema.Boolean,
mobileTitlebarPosition: Schema.Literals(["top", "bottom"]),
// Owned by the review extension, which copies it out once; kept so settings rewrites cannot drop it first.
mobileDiffWrap: Persistence.optional(Schema.Boolean),
@@ -300,6 +302,7 @@ export const defaultSettings: Settings = {
timelineWidth: "comfortable",
generatedUI: true,
newIcons: false,
newModelPicker: false,
mobileTitlebarPosition: "top",
followUpBehavior: "steer",
},
@@ -412,6 +415,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setNewIcons(value: boolean) {
setStore("general", "newIcons", value)
},
newModelPicker: withFallback(() => store.general?.newModelPicker, defaultSettings.general.newModelPicker),
setNewModelPicker(value: boolean) {
setStore("general", "newModelPicker", value)
},
mobileTitlebarPosition: withFallback(
() => store.general?.mobileTitlebarPosition,
defaultSettings.general.mobileTitlebarPosition,
@@ -111,6 +111,13 @@ export const clientSettings: Entry<SettingsRootTab>[] = [
description: "settings.experimental.row.newIcons.description",
keywords: "icons iconography glyphs redesign",
},
{
tab: "experimental",
label: "settings.experimental.row.newModelPicker.title",
target: "settings-new-model-picker",
description: "settings.experimental.row.newModelPicker.description",
keywords: "model picker selector switcher redesign",
},
{
tab: "appearance",
label: "settings.general.row.colorScheme.title",