diff --git a/packages/app/src/composer/commands.tsx b/packages/app/src/composer/commands.tsx index 0bf97a46a31..249414524d7 100644 --- a/packages/app/src/composer/commands.tsx +++ b/packages/app/src/composer/commands.tsx @@ -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(() => ) + void dialog.show(() => ) }) } diff --git a/packages/app/src/composer/composer.tsx b/packages/app/src/composer/composer.tsx index 6ba269b2235..6282fe7d818 100644 --- a/packages/app/src/composer/composer.tsx +++ b/packages/app/src/composer/composer.tsx @@ -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((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(() => , detail === 0 ? undefined : props.onClose) + void dialog.show(() => , 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: { } > - ( + )} + + + ) + } + + return ( +
+
+ setSearch(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.altKey || event.metaKey) return + + if (event.key === "ArrowDown") { + event.preventDefault() + moveActive(1) + + return + } + + if (event.key === "ArrowUp") { + event.preventDefault() + moveActive(-1) + + return + } + + if (event.key === "Enter" && !event.isComposing) { + event.preventDefault() + selectActive() + } + }} + aria-label={language.t("dialog.model.search.placeholder")} + showClearButton={!!store.search} + clearIcon="circle-xmark" + clearLabel={language.t("common.clear")} + onClearClick={() => setSearch("")} + /> +
+
+
(scrollRef = element)} + class="settings-panel settings-models pt-1 pb-4" + classList={{ "h-full px-4": !props.mobile, "max-h-[min(360px,40dvh)]": props.mobile }} + > + 0} + fallback={
{language.t("dialog.model.empty")}
} + > + 0} + onExpandedChange={(key, value) => setStore("collapsed", key, !value)} + rows={(items) => } + /> +
+
+
+
+ ) +} + +type ModelSelectorTriggerProps = Omit, "as" | "ref"> + +type ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => JSX.Element + +// Phones pick models from a drawer; wider screens use DialogSelectModelNext. +export function ModelSelectorDrawerNext(props: { + provider?: string + model?: ModelState + trigger: ModelSelectorTrigger + onClose?: () => void +}) { + const dialog = useDialog() + const location = useWorkspaceLocation() + + const controller = createModelSelectorController({ + model: props.model, + provider: () => props.provider, + onSelect: () => props.onClose?.(), + }) + + const chatgptPlan = createChatgptPlan(controller.current) + + const manage = async () => { + const { DialogManageModels } = await import("./manage") + void dialog.show(() => ) + } + + const connect = async () => { + const { DialogConnectProvider } = await import("@/providers/connect/dialog") + void dialog.show(() => ) + } + + return ( + props.onClose?.()} + /> + ) +} + +function ModelDrawer(props: { + trigger: ModelSelectorTrigger + model?: ModelState + provider?: string + chatgptPlan?: boolean + onClose: () => void + onManage: () => void + onConnect?: () => void +}) { + const language = useLanguage() + + const [store, setStore] = createStore<{ + open: boolean + loaded: boolean + restoreTrigger: boolean + action?: "select" | "manage" | "connect" + }>({ + open: false, + loaded: false, + restoreTrigger: true, + }) + + let trigger: HTMLDivElement | undefined + let content: HTMLDivElement | undefined + + return ( + <> +
+ {props.trigger({ + "aria-haspopup": "dialog", + get "aria-expanded"() { + return store.open + }, + onClick: () => setStore({ open: true, loaded: true, restoreTrigger: true }), + })} +
+ + + setStore("open", open)} + initialFocus={() => content} + returnFocus={() => trigger?.querySelector("button") ?? undefined} + onFinalFocus={(event) => { + if (!store.restoreTrigger) event.preventDefault() + }} + onContentPresentChange={(present) => { + if (present) return + const action = store.action + setStore("action", undefined) + + if (action === "manage") { + props.onManage() + + return + } + + if (action === "connect") { + props.onConnect?.() + + return + } + + if (action === "select") queueMicrotask(props.onClose) + }} + > +
+
+ setStore({ open: false, action: "select", restoreTrigger: false })} + /> +
+
+ + + + + +
+ + {language.t("dialog.model.chatgptPlan")} + + {language.t("dialog.model.chatgptManageUsage")} + + +
+
+
+
+
+
+
+ + ) +} + +function createModelSelectorController(input: { + provider: () => string | undefined + model?: ModelState + onSelect: () => void +}) { + const model = input.model ?? useLocal().model + + const allModels = createMemo(() => + model + .list() + .filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id })) + .filter((item) => (input.provider() ? item.provider.id === input.provider() : true)), + ) + + return { + all: () => model.list().filter((item) => (input.provider() ? item.provider.id === input.provider() : true)), + models: (search: string) => { + const query = search.trim() + + const filtered = query + ? allModels().filter((item) => matchesModelSearch(query, [item.name, item.id, item.provider.name])) + : allModels() + + return [...filtered].sort((a, b) => a.name.localeCompare(b.name)) + }, + groups: (models: ModelItem[]) => { + const byProvider = new Map() + + for (const item of models) { + byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item]) + } + + return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups) + }, + current: () => { + const value = model.current() + + return value ? modelKey(value) : undefined + }, + select: (item: ModelItem, variant?: string | null) => { + model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true }) + + if (variant !== undefined) model.variant.set(variant ?? undefined) + input.onSelect() + }, + variant: (item: ModelItem) => model.variant.current({ providerID: item.provider.id, modelID: item.id }), + favorites: () => model.favorite.list().filter((item) => !input.provider() || item.provider.id === input.provider()), + recent: () => + model + .recent() + .flatMap((item) => (item && (!input.provider() || item.provider.id === input.provider()) ? [item] : [])), + toggleFavorite: (item: ModelItem) => model.favorite.toggle({ providerID: item.provider.id, modelID: item.id }), + } +} + +function createProviderLabels(all: () => ModelItem[]) { + const managed = createMemo(() => consoleModelGroup(all())) + const managedIDs = createMemo(() => new Set(managed()?.providers.map((provider) => provider.id))) + const canonical = (provider: ModelItem["provider"]) => provider.canonical ?? provider.id.replace(/^console-/, "") + + const direct = createMemo( + () => + new Set( + all() + .filter((item) => !managedIDs().has(item.provider.id)) + .map((item) => canonical(item.provider)), + ), + ) + + return { + name: (provider: ModelItem["provider"]) => { + const group = managed() + + return group && managedIDs().has(provider.id) ? consoleProviderName(group, provider.name) : provider.name + }, + // The workspace badge only disambiguates a managed provider that is also connected directly. + workspace: (provider: ModelItem["provider"]) => { + const group = managed() + + if (!group || !managedIDs().has(provider.id) || !direct().has(canonical(provider))) return + + return group.workspace + }, + } +} + +type ModelGroup = { category: string; items: ModelItem[]; title?: string } + +// Favorites and recent models lead the list until a search narrows it. As in the TUI, they then leave their provider +// group, so no model is listed twice. +function withSections(input: { + groups: ModelGroup[] + favorites: ModelItem[] + recent: ModelItem[] + titles: { favorite: string; recent: string } +}): ModelGroup[] { + const favorites = new Set(input.favorites.map(modelKey)) + + const recent = input.recent.filter((item) => !favorites.has(modelKey(item))) + const listed = new Set([...favorites, ...recent.map(modelKey)]) + + return [ + ...(input.favorites.length > 0 + ? [{ category: "favorite", title: input.titles.favorite, items: input.favorites }] + : []), + ...(recent.length > 0 ? [{ category: "recent", title: input.titles.recent, items: recent }] : []), + ...input.groups.flatMap((group) => { + const items = group.items.filter((item) => !listed.has(modelKey(item))) + + return items.length > 0 ? [{ ...group, items }] : [] + }), + ] +} + +const clamp = (value: number) => Math.max(0, Math.min(1, value)) + +const round = (value: number) => Math.round(value * 1000) / 1000 + +type ModelRow = { id: string; item: ModelItem; group: ModelGroup } + +function createChatgptPlan(current: () => string | undefined) { + const data = useData() + const location = useWorkspaceLocation() + + return () => { + if (!current()?.startsWith("openai:")) return false + + const connection = data.location.integration + .list(location().ref) + ?.find((integration) => integration.id === "openai")?.connections[0] + + return connection?.type === "credential" && connection.method === "oauth" + } +} + +function ChatgptPlanFooter() { + const language = useLanguage() + + return ( + <> +
+
+ + {language.t("dialog.model.chatgptPlan")} + + {language.t("dialog.model.chatgptManageUsage")} + + +
+ + ) +} + +export function DialogSelectModelNext(props: { provider?: string; model?: ModelState }) { + const dialog = useDialog() + const language = useLanguage() + + const controller = createModelSelectorController({ + model: props.model, + provider: () => props.provider, + onSelect: () => dialog.close(), + }) + + const labels = createProviderLabels(controller.all) + const chatgptPlan = createChatgptPlan(controller.current) + + const [store, setStore] = createStore({ + search: "", + flyout: "", + keyboard: true, + navigated: false, + warm: false, + position: { left: 0, top: 0 }, + }) + + const [staged, setStaged] = createStore>({}) + + let list: SearchListHandle | undefined + let flyoutRef: HTMLDivElement | undefined + let timer: ReturnType | undefined + onCleanup(() => clearTimeout(timer)) + + const groups = createMemo((): ModelGroup[] => { + const providers = controller.groups(controller.models(store.search)) + + if (store.search.trim()) return providers + + return withSections({ + groups: providers, + favorites: controller.favorites(), + recent: controller.recent(), + titles: { favorite: language.t("dialog.model.favorites"), recent: language.t("dialog.model.recent") }, + }) + }) + + const rows = createMemo(() => + groups().flatMap((group) => group.items.map((item): ModelRow => ({ id: modelKey(item), item, group }))), + ) + + const rowItem = (id: string) => rows().find((row) => row.id === id)?.item + const flyoutItem = () => (store.flyout ? rowItem(store.flyout) : undefined) + const variantsOf = (item: ModelItem) => Object.keys(item.variants ?? {}) + const favorite = (item: ModelItem) => controller.favorites().some((entry) => modelKey(entry) === modelKey(item)) + + const variant = (item: ModelItem) => + modelKey(item) in staged ? (staged[modelKey(item)] ?? undefined) : controller.variant(item) + + const choose = (item: ModelItem, value?: string | null) => + controller.select(item, value === undefined ? staged[modelKey(item)] : value) + + const setSearch = (value: string) => setStore({ search: value, flyout: "", keyboard: true, navigated: false }) + + const manage = async () => { + const { DialogManageModels } = await import("./manage") + void dialog.show(() => ) + } + + const triangle = createSafeTriangle({ + flyout: () => (flyoutItem() && flyoutRef?.isConnected ? flyoutRef : undefined), + }) + + const openFlyout = (id: string, row: HTMLElement) => { + if (!row.closest('[data-slot="dialog-container"]')) return + const box = row.getBoundingClientRect() + // 6px beside the row's end edge, else its start edge, else over the dialog: always inside the layer that holds it. + const bounds = row.closest("[data-dialog-layer]")?.getBoundingClientRect() + const fits = (x: number) => !bounds || (x >= bounds.left && x + 160 <= bounds.right) + const end = language.direction() === "rtl" ? box.left - 6 - 160 : box.right + 6 + const start = language.direction() === "rtl" ? box.right + 6 : box.left - 6 - 160 + const over = bounds ? Math.max(bounds.left, Math.min(end, bounds.right - 160)) : end + const left = fits(end) ? end : fits(start) ? start : over + const top = box.top - 2 + setStore({ flyout: id, warm: true, position: { left, top } }) + + // A fixed element is placed against the viewport unless an ancestor contains it, as a component preview that holds + // its dialogs does. Correct by where it landed, before it paints. + const placed = flyoutRef?.isConnected ? flyoutRef.getBoundingClientRect() : undefined + + if (placed && (placed.left !== left || placed.top !== top)) + setStore("position", { left: left * 2 - placed.left, top: top * 2 - placed.top }) + } + + // The row whose flyout is waiting on the open delay. + let pending = "" + + const hover = (id: string, row: HTMLElement) => { + // A row highlighted by a scroll has no flyout yet, so moving on it still opens one. + if (list?.active()?.id === id && !store.keyboard && (store.flyout === id || pending === id)) return + list?.setActive(id) + setStore("keyboard", false) + clearTimeout(timer) + pending = "" + const item = rowItem(id) + + if (!item) return setStore("flyout", "") + + if (store.warm) return openFlyout(id, row) + + setStore("flyout", "") + pending = id + timer = setTimeout(() => { + pending = "" + openFlyout(id, row) + }, 100) + } + + // Rows the pointer crosses on its way into the open flyout leave it alone. + const move = (id: string, event: MouseEvent & { currentTarget: HTMLElement }) => { + // A still cursor must not take the highlight while the arrow keys scroll rows beneath it. + if (event.movementX === 0 && event.movementY === 0) return + const row = event.currentTarget + const point = { x: event.clientX, y: event.clientY } + + if (flyoutItem() && store.flyout !== id && triangle.hold(point, () => hover(id, row))) return + triangle.anchor(point) + hover(id, row) + } + + const close = () => setStore("flyout", "") + + let pointer: { x: number; y: number } | undefined + createEventListener(document, "pointermove", (event: PointerEvent) => { + pointer = { x: event.clientX, y: event.clientY } + }) + + // Scrolling moves rows under a still cursor without a pointer event. A wheel scroll highlights the row that lands + // under the cursor and moves the flyout beside it; a keyboard scroll keeps its highlight and closes the flyout, + // which would otherwise be left beside a row that moved away. + const follow = () => { + clearTimeout(timer) + pending = "" + + if (store.keyboard || !pointer) return close() + const row = document.elementFromPoint(pointer.x, pointer.y)?.closest('[data-slot="search-list-row"]') + const id = row?.dataset.optionKey + + if (!row || !id || !scroller?.contains(row)) return close() + list?.setActive(id) + triangle.anchor(pointer) + openFlyout(id, row) + } + + let scroller: HTMLDivElement | undefined + + // As on the home page, every header sticks for the whole list, so the next one slides over the stuck one while the + // stuck label fades out over 16px. The topmost stuck header also fades a gradient in as rows scroll beneath it. + const fadeHeaders = () => { + const viewport = scroller?.querySelector(".scroll-view__viewport") + + if (!viewport) return + const origin = viewport.getBoundingClientRect().top + const headers = [...viewport.querySelectorAll('[data-slot="search-list-header"]')] + + // Every read before any write, so the scroll lays out once. + const tops = headers.map((header) => header.getBoundingClientRect().top - origin) + const stuck = tops.findLastIndex((top) => top < 0.5) + + const labelOf = (header: HTMLElement | undefined) => + header?.querySelector('[data-slot="model-dialog-header-label"]') ?? undefined + + const labels = headers.map((header, index) => { + const label = labelOf(header) + const next = tops[index + 1] + const nextLabel = labelOf(headers[index + 1]) + + if (!label || next === undefined || !nextLabel) return { label, opacity: 1 } + + // Fades out as the next label comes up, gone just as it would touch this one. + return { label, opacity: clamp((next + nextLabel.offsetTop - label.offsetTop - label.offsetHeight) / 16) } + }) + + const header = headers[stuck] + const row = header?.nextElementSibling + // How far the rows under the stuck header have scrolled beneath it, from where its first row rests. + const hidden = header && row ? header.offsetHeight + 4 - (row.getBoundingClientRect().top - origin) : 0 + + labels.forEach((item) => item.label?.style.setProperty("opacity", String(round(item.opacity)))) + headers.forEach((item, index) => item.toggleAttribute("data-stuck", index === stuck)) + header?.style.setProperty("--model-dialog-header-fade", String(round(clamp(hidden / 16)))) + } + + // Results that change without a scroll, such as a narrower search, move the headers too. + createEffect( + on(rows, () => { + const frame = requestAnimationFrame(fadeHeaders) + onCleanup(() => cancelAnimationFrame(frame)) + }), + ) + + createEffect(() => { + if (!flyoutItem()) { + triangle.reset() + + return + } + // Outside the rows, such as the gap before the flyout, leaving the triangle closes it. + + createEventListener(document, "pointermove", (event: PointerEvent) => { + const target = event.target + + if (target instanceof Element && target.closest('[data-slot="search-list-row"]')) return + + if (target instanceof Node && flyoutRef?.contains(target)) return + + if (!triangle.contains({ x: event.clientX, y: event.clientY })) close() + }) + }) + + // The search field keeps focus for the dialog's life: a click anywhere else in the dialog, the rows, the scroll area + // or the flyout, leaves it there, so the arrow keys always move the highlight instead of scrolling a container. + const field = () => scroller?.querySelector('[data-slot="text-input-v2-input"]') ?? undefined + + const inDialog = (target: EventTarget | null) => + target instanceof Node && + (!!scroller?.closest('[data-slot="dialog-container"]')?.contains(target) || !!flyoutRef?.contains(target)) + + createEventListener( + document, + "mousedown", + (event: MouseEvent) => { + if (inDialog(event.target) && event.target !== field()) event.preventDefault() + }, + { capture: true }, + ) + + createEventListener(document, "focusin", (event: FocusEvent) => { + if (inDialog(event.target) && event.target !== field()) field()?.focus({ preventScroll: true }) + }) + + // The flyout is placed once when it opens, so moving the rows under it closes it instead. + createEventListener(window, "resize", close) + + const onKeyDown = (event: KeyboardEvent, active: ModelRow | undefined) => { + if (matchKeybind(favoriteKeybind, event)) { + if (!active) return + event.preventDefault() + controller.toggleFavorite(active.item) + // The model moves between Favorites and its group under the same key, so the highlight follows it. + list?.setActive(active.id) + setStore({ flyout: "", keyboard: true }) + + return + } + + if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Home" || event.key === "End") { + setStore({ keyboard: true, navigated: true, flyout: "" }) + + return + } + + if (event.metaKey || event.altKey || event.ctrlKey) return + + // While searching, left and right edit the query until the arrows have moved through the results. + if ((event.key === "ArrowLeft" || event.key === "ArrowRight") && (!store.search || store.navigated)) { + const item = active?.item + + if (!item || variantsOf(item).length === 0) return + event.preventDefault() + const options = [undefined, ...variantsOf(item)] + const direction = (event.key === "ArrowRight") !== (language.direction() === "rtl") ? 1 : -1 + const index = options.indexOf(variant(item)) + setStaged(modelKey(item), options[(index + direction + options.length) % options.length] ?? null) + setStore({ keyboard: true, flyout: "" }) + } + } + + const Row = (row: { model: ModelRow; attrs: SearchListRow }) => { + const item = () => row.model.item + const selected = () => controller.current() === modelKey(item()) + const active = () => row.attrs["data-active"] !== undefined + + return ( + + ) + } + + return ( + +
+ {language.t("dialog.model.select.title")} +
+ + {/* Scrolling moves the flyout's row away without a pointer event, which would leave it detached. */} +
{ + scroller = element + createEventListener( + element, + "scroll", + () => { + follow() + fadeHeaders() + }, + { capture: true }, + ) + // A wheel scroll is the pointer's, so the highlight and flyout follow the row under it. + createEventListener(element, "wheel", () => setStore("keyboard", false), { passive: true }) + }} + > + (list = handle)} + query={store.search} + onQuery={setSearch} + placeholder={language.t("dialog.model.search.placeholder")} + trailing={ + + } + aria-label={language.t("dialog.model.manage")} + onClick={manage} + /> + + } + items={rows()} + key={(row) => row.id} + section={(row) => row.group.category} + header={(row) => ( + + {row.group.title ?? labels.name(row.item.provider)} + + {(workspace) => {workspace()}} + + + )} + empty={language.t("dialog.model.empty")} + onSelect={(row) => choose(row.item)} + onKeyDown={onKeyDown} + > + {(row, attrs) => } + +
+ + + +
+ + {(item) => ( + // Inside the dialog content, so clicks here are not outside clicks that dismiss the dialog. +
(flyoutRef = element)} + data-component="menu-v2-content" + data-slot="model-dialog-next-flyout" + style={{ left: `${store.position.left}px`, top: `${store.position.top}px` }} + onMouseEnter={() => triangle.cancel()} + > + {/* A model without variants still lists Default, its only option. */} + + {(value) => ( +
event.currentTarget.setAttribute("data-highlighted", "")} + onMouseLeave={(event) => event.currentTarget.removeAttribute("data-highlighted")} + onClick={() => choose(item(), value ?? null)} + > + + {value ?? language.t("common.default")} + + + + +
+ )} +
+
+
event.currentTarget.setAttribute("data-highlighted", "")} + onMouseLeave={(event) => event.currentTarget.removeAttribute("data-highlighted")} + onClick={() => { + // The row moves between Favorites and its group, so the flyout would be left beside nothing. + controller.toggleFavorite(item()) + close() + }} + > + + {favorite(item()) ? language.t("dialog.model.unfavorite") : language.t("dialog.model.favorite")} + + +
+
+ +
+ )} + +
+ ) +} diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 99deceb34ee..93d87277128 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -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", diff --git a/packages/app/src/settings/experimental/experimental.tsx b/packages/app/src/settings/experimental/experimental.tsx index fd47795e991..af9420c5206 100644 --- a/packages/app/src/settings/experimental/experimental.tsx +++ b/packages/app/src/settings/experimental/experimental.tsx @@ -50,6 +50,17 @@ export const SettingsExperimental: Component = () => { />
+ +
+ settings.general.setNewModelPicker(checked)} + /> +
+
diff --git a/packages/app/src/settings/model.test.ts b/packages/app/src/settings/model.test.ts index 9d89f4273e1..710b9451447 100644 --- a/packages/app/src/settings/model.test.ts +++ b/packages/app/src/settings/model.test.ts @@ -97,6 +97,7 @@ describe("settings schema", () => { timelineWidth: "comfortable", generatedUI: true, newIcons: false, + newModelPicker: false, mobileTitlebarPosition: "top", followUpBehavior: "steer", }, diff --git a/packages/app/src/settings/model.tsx b/packages/app/src/settings/model.tsx index b40ce1cc7c0..84c6ae22e45 100644 --- a/packages/app/src/settings/model.tsx +++ b/packages/app/src/settings/model.tsx @@ -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, diff --git a/packages/app/src/settings/search-catalog.ts b/packages/app/src/settings/search-catalog.ts index 5e6a0132289..c05e03ddf2a 100644 --- a/packages/app/src/settings/search-catalog.ts +++ b/packages/app/src/settings/search-catalog.ts @@ -111,6 +111,13 @@ export const clientSettings: Entry[] = [ 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",