mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 03:56:55 +03:00
feat(app): add new model picker experiment (#58413)
This commit is contained in:
committed by
GitHub
Unverified
parent
6214451724
commit
46eca76752
@@ -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} />)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -97,6 +97,7 @@ describe("settings schema", () => {
|
||||
timelineWidth: "comfortable",
|
||||
generatedUI: true,
|
||||
newIcons: false,
|
||||
newModelPicker: false,
|
||||
mobileTitlebarPosition: "top",
|
||||
followUpBehavior: "steer",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user