feat(app): composer improvements (#34720)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
This commit is contained in:
co-authored by
Brendan Allan
parent
f014686b07
commit
9410eda4e3
@@ -66,7 +66,7 @@ test("shows the V2 thinking level control while relevant", async ({ page }) => {
|
|||||||
await expect(control).toBeVisible()
|
await expect(control).toBeVisible()
|
||||||
|
|
||||||
await control.locator('[data-action="prompt-model-variant"]').click()
|
await control.locator('[data-action="prompt-model-variant"]').click()
|
||||||
const high = page.getByRole("option", { name: "high" })
|
const high = page.getByRole("menuitemradio", { name: "high" })
|
||||||
await expect(high).toBeVisible()
|
await expect(high).toBeVisible()
|
||||||
await page.mouse.move(0, 0)
|
await page.mouse.move(0, 0)
|
||||||
await expect(control).toBeVisible()
|
await expect(control).toBeVisible()
|
||||||
|
|||||||
@@ -3,13 +3,26 @@ import { List } from "@opencode-ai/ui/list"
|
|||||||
import { Switch } from "@opencode-ai/ui/switch"
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import type { Component } from "solid-js"
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
|
import { Dialog as DialogV2, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/v2/dialog-v2"
|
||||||
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
|
import { Switch as SwitchV2 } from "@opencode-ai/ui/v2/switch-v2"
|
||||||
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
|
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||||
|
import { For, Show, type Component } from "solid-js"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { popularProviders } from "@/hooks/use-providers"
|
import { popularProviders } from "@/hooks/use-providers"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
|
import { SettingsListV2 } from "./settings-v2/parts/list"
|
||||||
|
import { SettingsRowV2 } from "./settings-v2/parts/row"
|
||||||
|
import "./settings-v2/settings-v2.css"
|
||||||
|
|
||||||
|
type ModelItem = ReturnType<ReturnType<typeof useLocal>["model"]["list"]>[number]
|
||||||
|
|
||||||
export const DialogManageModels: Component = () => {
|
export const DialogManageModels: Component = () => {
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
@@ -102,3 +115,148 @@ export const DialogManageModels: Component = () => {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const DialogManageModelsV2: Component = () => {
|
||||||
|
const local = useLocal()
|
||||||
|
const language = useLanguage()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const directory = () => decode64(local.slug())
|
||||||
|
|
||||||
|
const handleConnectProvider = () => {
|
||||||
|
dialog.show(() => <DialogSelectProvider directory={directory} />)
|
||||||
|
}
|
||||||
|
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
|
||||||
|
const providerVisible = (providerID: string) =>
|
||||||
|
providerList(providerID).every((x) => local.model.visible({ modelID: x.id, providerID: x.provider.id }))
|
||||||
|
const setProviderVisibility = (providerID: string, checked: boolean) => {
|
||||||
|
providerList(providerID).forEach((x) => {
|
||||||
|
local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, checked)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const setModelVisibility = (item: ModelItem, checked: boolean) => {
|
||||||
|
local.model.setVisibility({ modelID: item.id, providerID: item.provider.id }, checked)
|
||||||
|
}
|
||||||
|
const list = useFilteredList<ModelItem>({
|
||||||
|
items: () => local.model.list(),
|
||||||
|
key: (x) => `${x.provider.id}:${x.id}`,
|
||||||
|
filterKeys: ["provider.name", "name", "id"],
|
||||||
|
sortBy: (a, b) => a.name.localeCompare(b.name),
|
||||||
|
groupBy: (x) => x.provider.id,
|
||||||
|
sortGroupsBy: (a, b) => {
|
||||||
|
const aRank = popularProviders.indexOf(a.category)
|
||||||
|
const bRank = popularProviders.indexOf(b.category)
|
||||||
|
const aPopular = aRank >= 0
|
||||||
|
const bPopular = bRank >= 0
|
||||||
|
if (aPopular && !bPopular) return -1
|
||||||
|
if (!aPopular && bPopular) return 1
|
||||||
|
return aRank - bRank
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogV2 size="large" variant="settings" class="settings-v2-manage-models-dialog">
|
||||||
|
<DialogHeader hideClose={true} closeLabel={language.t("common.close")}>
|
||||||
|
<DialogTitleGroup title={language.t("dialog.model.manage")} description={language.t("dialog.model.manage.description")} />
|
||||||
|
<ButtonV2 variant="neutral" icon="plus" onClick={handleConnectProvider}>
|
||||||
|
{language.t("command.provider.connect")}
|
||||||
|
</ButtonV2>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogBody class="flex min-h-0 flex-1 flex-col">
|
||||||
|
<div class="px-4 pt-px pb-3">
|
||||||
|
<div class="relative">
|
||||||
|
<TextInputV2
|
||||||
|
type="search"
|
||||||
|
appearance="base"
|
||||||
|
class="!w-full self-stretch"
|
||||||
|
value={list.filter()}
|
||||||
|
onInput={(event) => list.onInput(event.currentTarget.value)}
|
||||||
|
placeholder={language.t("dialog.model.search.placeholder")}
|
||||||
|
spellcheck={false}
|
||||||
|
autocorrect="off"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
autofocus
|
||||||
|
aria-label={language.t("dialog.model.search.placeholder")}
|
||||||
|
/>
|
||||||
|
<Show when={list.filter()}>
|
||||||
|
<IconButtonV2
|
||||||
|
type="button"
|
||||||
|
variant="ghost-muted"
|
||||||
|
size="small"
|
||||||
|
class="settings-v2-tab-search-clear"
|
||||||
|
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
|
||||||
|
onClick={() => list.clear()}
|
||||||
|
aria-label={language.t("common.clear")}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div data-slot="manage-models-scroll" class="relative min-h-0 flex-1">
|
||||||
|
<div class="settings-v2-panel settings-v2-models h-full px-4 pt-4 pb-4">
|
||||||
|
<Show
|
||||||
|
when={!list.grouped.loading}
|
||||||
|
fallback={
|
||||||
|
<div class="settings-v2-models-status">
|
||||||
|
{language.t("common.loading")}
|
||||||
|
{language.t("common.loading.ellipsis")}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={list.flat().length > 0}
|
||||||
|
fallback={
|
||||||
|
<div class="settings-v2-models-status">
|
||||||
|
<span>{language.t("dialog.model.empty")}</span>
|
||||||
|
<Show when={list.filter()}>
|
||||||
|
<span class="settings-v2-models-status-filter">"{list.filter()}"</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<For each={list.grouped.latest}>
|
||||||
|
{(group) => (
|
||||||
|
<div class="settings-v2-section" data-component="settings-models-provider">
|
||||||
|
<div class="settings-v2-models-group-header justify-between">
|
||||||
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
|
<ProviderIcon id={group.category} width={16} height={16} class="ml-4 shrink-0" />
|
||||||
|
<h3 class="settings-v2-section-title">{group.items[0].provider.name}</h3>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<SwitchV2
|
||||||
|
class="mr-6"
|
||||||
|
checked={providerVisible(group.category)}
|
||||||
|
onChange={(checked) => setProviderVisibility(group.category, checked)}
|
||||||
|
hideLabel
|
||||||
|
>
|
||||||
|
{group.items[0].provider.name}
|
||||||
|
</SwitchV2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SettingsListV2>
|
||||||
|
<For each={group.items}>
|
||||||
|
{(item) => (
|
||||||
|
<SettingsRowV2 title={item.name} description="">
|
||||||
|
<div>
|
||||||
|
<SwitchV2
|
||||||
|
checked={local.model.visible({ modelID: item.id, providerID: item.provider.id })}
|
||||||
|
onChange={(checked) => setModelVisibility(item, checked)}
|
||||||
|
hideLabel
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</SwitchV2>
|
||||||
|
</div>
|
||||||
|
</SettingsRowV2>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</SettingsListV2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogBody>
|
||||||
|
</DialogV2>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import { Popover as Kobalte } from "@kobalte/core/popover"
|
import { Popover as Kobalte } from "@kobalte/core/popover"
|
||||||
import { Component, ComponentProps, createMemo, JSX, Show, ValidComponent } from "solid-js"
|
import { Component, ComponentProps, createMemo, For, JSX, Show, ValidComponent } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { popularProviders } from "@/hooks/use-providers"
|
import { popularProviders } from "@/hooks/use-providers"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||||
import { Tag } from "@opencode-ai/ui/tag"
|
import { Tag } from "@opencode-ai/ui/tag"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||||
|
import { Tag as TagV2 } from "@opencode-ai/ui/v2/badge-v2"
|
||||||
|
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||||
import { ModelTooltip } from "./model-tooltip"
|
import { ModelTooltip } from "./model-tooltip"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
@@ -18,6 +22,22 @@ const isFree = (provider: string, cost: { input: number } | undefined) =>
|
|||||||
provider === "opencode" && (!cost || cost.input === 0)
|
provider === "opencode" && (!cost || cost.input === 0)
|
||||||
|
|
||||||
type ModelState = ReturnType<typeof useLocal>["model"]
|
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||||
|
type ModelItem = ReturnType<ModelState["list"]>[number]
|
||||||
|
|
||||||
|
const modelKey = (model: ModelItem) => `${model.provider.id}:${model.id}`
|
||||||
|
const manageKey = "action:manage"
|
||||||
|
|
||||||
|
const sortModelGroups = (a: { category: string; items: ModelItem[] }, b: { category: string; items: ModelItem[] }) => {
|
||||||
|
const aIndex = popularProviders.indexOf(a.category)
|
||||||
|
const bIndex = popularProviders.indexOf(b.category)
|
||||||
|
const aPopular = aIndex >= 0
|
||||||
|
const bPopular = bIndex >= 0
|
||||||
|
|
||||||
|
if (aPopular && !bPopular) return -1
|
||||||
|
if (!aPopular && bPopular) return 1
|
||||||
|
if (aPopular && bPopular) return aIndex - bIndex
|
||||||
|
return a.items[0].provider.name.localeCompare(b.items[0].provider.name)
|
||||||
|
}
|
||||||
|
|
||||||
const ModelList: Component<{
|
const ModelList: Component<{
|
||||||
provider?: string
|
provider?: string
|
||||||
@@ -199,6 +219,277 @@ export function ModelSelectorPopover(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ModelSelectorPopoverV2(props: {
|
||||||
|
provider?: string
|
||||||
|
model?: ModelState
|
||||||
|
children?: JSX.Element
|
||||||
|
triggerAs?: ValidComponent
|
||||||
|
triggerProps?: ModelSelectorTriggerProps
|
||||||
|
onClose?: () => void
|
||||||
|
}) {
|
||||||
|
const model = props.model ?? useLocal().model
|
||||||
|
const language = useLanguage()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const [store, setStore] = createStore({ open: false, search: "", active: "" })
|
||||||
|
let searchRef: HTMLInputElement | undefined
|
||||||
|
let contentRef: HTMLDivElement | undefined
|
||||||
|
let restoreTrigger = true
|
||||||
|
|
||||||
|
const allModels = createMemo(() =>
|
||||||
|
model
|
||||||
|
.list()
|
||||||
|
.filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id }))
|
||||||
|
.filter((item) => (props.provider ? item.provider.id === props.provider : true)),
|
||||||
|
)
|
||||||
|
const models = createMemo(() => {
|
||||||
|
const search = store.search.trim().toLowerCase()
|
||||||
|
const filtered = search
|
||||||
|
? allModels().filter(
|
||||||
|
(item) =>
|
||||||
|
item.name.toLowerCase().includes(search) ||
|
||||||
|
item.id.toLowerCase().includes(search) ||
|
||||||
|
item.provider.name.toLowerCase().includes(search),
|
||||||
|
)
|
||||||
|
: allModels()
|
||||||
|
|
||||||
|
return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
})
|
||||||
|
const groups = createMemo(() => {
|
||||||
|
const byProvider = new Map<string, ModelItem[]>()
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
const keys = () => [...models().map(modelKey), manageKey]
|
||||||
|
const current = () => {
|
||||||
|
const value = model.current()
|
||||||
|
return value ? `${value.provider.id}:${value.id}` : undefined
|
||||||
|
}
|
||||||
|
const initialActive = () => {
|
||||||
|
const selected = current()
|
||||||
|
const options = keys()
|
||||||
|
if (selected && options.includes(selected)) return selected
|
||||||
|
return options[0] ?? ""
|
||||||
|
}
|
||||||
|
const activeItem = () =>
|
||||||
|
store.active ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(store.active)}"]`) : undefined
|
||||||
|
const afterClose = (callback: () => void) => {
|
||||||
|
const complete = () => {
|
||||||
|
if (contentRef?.isConnected) {
|
||||||
|
requestAnimationFrame(complete)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(callback))
|
||||||
|
}
|
||||||
|
requestAnimationFrame(complete)
|
||||||
|
}
|
||||||
|
const setOpen = (open: boolean) => {
|
||||||
|
if (open) {
|
||||||
|
restoreTrigger = true
|
||||||
|
setStore({ open: true, active: initialActive() })
|
||||||
|
setTimeout(() =>
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
searchRef?.focus()
|
||||||
|
activeItem()?.scrollIntoView({ block: "nearest" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStore({ open: false, search: "", active: "" })
|
||||||
|
}
|
||||||
|
const select = (item: ModelItem) => {
|
||||||
|
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
|
||||||
|
props.onClose?.()
|
||||||
|
}
|
||||||
|
const selectModel = (item: ModelItem) => {
|
||||||
|
restoreTrigger = false
|
||||||
|
setOpen(false)
|
||||||
|
afterClose(() => select(item))
|
||||||
|
}
|
||||||
|
const manage = () => {
|
||||||
|
restoreTrigger = false
|
||||||
|
setOpen(false)
|
||||||
|
afterClose(() => {
|
||||||
|
void import("./dialog-manage-models").then((x) => {
|
||||||
|
dialog.show(() => <x.DialogManageModelsV2 />)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const selectActive = () => {
|
||||||
|
const item = models().find((item) => modelKey(item) === store.active)
|
||||||
|
if (item) {
|
||||||
|
selectModel(item)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (store.active === manageKey) manage()
|
||||||
|
}
|
||||||
|
const moveActive = (delta: number) => {
|
||||||
|
const options = keys()
|
||||||
|
if (options.length === 0) return
|
||||||
|
const index = options.indexOf(store.active)
|
||||||
|
const start = index === -1 ? 0 : index
|
||||||
|
setStore("active", options[(start + delta + options.length) % options.length])
|
||||||
|
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
|
||||||
|
}
|
||||||
|
const setSearch = (value: string) => {
|
||||||
|
const search = value.trim().toLowerCase()
|
||||||
|
const first = [...allModels()]
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
.find(
|
||||||
|
(item) =>
|
||||||
|
!search ||
|
||||||
|
item.name.toLowerCase().includes(search) ||
|
||||||
|
item.id.toLowerCase().includes(search) ||
|
||||||
|
item.provider.name.toLowerCase().includes(search),
|
||||||
|
)
|
||||||
|
setStore({ search: value, active: first ? modelKey(first) : manageKey })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuV2
|
||||||
|
open={store.open}
|
||||||
|
modal={false}
|
||||||
|
placement="top-start"
|
||||||
|
gutter={6}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
>
|
||||||
|
<MenuV2.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
||||||
|
{props.children}
|
||||||
|
</MenuV2.Trigger>
|
||||||
|
<MenuV2.Portal>
|
||||||
|
<MenuV2.Content
|
||||||
|
ref={(el: HTMLDivElement) => (contentRef = el)}
|
||||||
|
class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
||||||
|
onPointerDownOutside={() => (restoreTrigger = false)}
|
||||||
|
onFocusOutside={() => (restoreTrigger = false)}
|
||||||
|
onCloseAutoFocus={(event) => {
|
||||||
|
if (!restoreTrigger) event.preventDefault()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex flex-col p-0.5">
|
||||||
|
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
||||||
|
<Icon name="magnifying-glass" size="small" class="shrink-0" />
|
||||||
|
<input
|
||||||
|
ref={(el) => (searchRef = el)}
|
||||||
|
value={store.search}
|
||||||
|
placeholder={language.t("dialog.model.search.placeholder")}
|
||||||
|
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
|
||||||
|
spellcheck={false}
|
||||||
|
autocorrect="off"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
onInput={(event) => setSearch(event.currentTarget.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Tab") return
|
||||||
|
event.stopPropagation()
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault()
|
||||||
|
restoreTrigger = false
|
||||||
|
setOpen(false)
|
||||||
|
afterClose(() => props.onClose?.())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Show when={store.search.trim()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
|
||||||
|
onPointerDown={(event) => event.preventDefault()}
|
||||||
|
onClick={() => setSearch("")}
|
||||||
|
aria-label={language.t("common.clear")}
|
||||||
|
>
|
||||||
|
<Icon name="close" size="small" />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="h-px bg-v2-border-border-muted" />
|
||||||
|
<ScrollView data-slot="model-selector-scroll" class="max-h-[220px] min-h-0">
|
||||||
|
<div class="flex flex-col p-0.5 pt-0">
|
||||||
|
<Show
|
||||||
|
when={models().length > 0}
|
||||||
|
fallback={
|
||||||
|
<div class="flex h-12 items-center px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint">
|
||||||
|
{language.t("dialog.model.empty")}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<For each={groups()}>
|
||||||
|
{(group) => (
|
||||||
|
<MenuV2.Group>
|
||||||
|
<MenuV2.GroupLabel class="gap-2 px-3">
|
||||||
|
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
|
||||||
|
</MenuV2.GroupLabel>
|
||||||
|
<MenuV2.RadioGroup value={current()}>
|
||||||
|
<For each={group.items}>
|
||||||
|
{(item) => (
|
||||||
|
<MenuV2.RadioItem
|
||||||
|
value={modelKey(item)}
|
||||||
|
data-option-key={modelKey(item)}
|
||||||
|
data-selected-model={current() === modelKey(item) ? true : undefined}
|
||||||
|
class="scroll-my-6"
|
||||||
|
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setStore("active", modelKey(item))
|
||||||
|
setTimeout(() => searchRef?.focus())
|
||||||
|
}}
|
||||||
|
onSelect={() => selectModel(item)}
|
||||||
|
>
|
||||||
|
<span class="min-w-0 truncate">{item.name}</span>
|
||||||
|
<Show when={isFree(item.provider.id, item.cost)}>
|
||||||
|
<TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
|
||||||
|
</Show>
|
||||||
|
<Show when={item.latest}>
|
||||||
|
<TagV2 class="shrink-0">{language.t("model.tag.latest")}</TagV2>
|
||||||
|
</Show>
|
||||||
|
</MenuV2.RadioItem>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</MenuV2.RadioGroup>
|
||||||
|
</MenuV2.Group>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</ScrollView>
|
||||||
|
<div class="h-px bg-v2-border-border-muted" />
|
||||||
|
<div class="flex flex-col p-0.5">
|
||||||
|
<MenuV2.Item
|
||||||
|
data-option-key={manageKey}
|
||||||
|
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === manageKey }}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setStore("active", manageKey)
|
||||||
|
setTimeout(() => searchRef?.focus())
|
||||||
|
}}
|
||||||
|
onSelect={manage}
|
||||||
|
>
|
||||||
|
<Icon name="outline-sliders" size="small" />
|
||||||
|
<span class="min-w-0 flex-1 truncate leading-5">{language.t("dialog.model.manage")}</span>
|
||||||
|
</MenuV2.Item>
|
||||||
|
</div>
|
||||||
|
</MenuV2.Content>
|
||||||
|
</MenuV2.Portal>
|
||||||
|
</MenuV2>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => {
|
export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|||||||
@@ -35,12 +35,14 @@ import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
|
|||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||||
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||||
|
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { Select } from "@opencode-ai/ui/select"
|
import { Select } from "@opencode-ai/ui/select"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
@@ -1480,6 +1482,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
model: props.controls.model.selection,
|
model: props.controls.model.selection,
|
||||||
providerID: props.controls.model.selection.current()?.provider?.id,
|
providerID: props.controls.model.selection.current()?.provider?.id,
|
||||||
modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"),
|
modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"),
|
||||||
|
newLayoutDesigns: props.controls.newLayoutDesigns,
|
||||||
style: control(),
|
style: control(),
|
||||||
onClose: restoreFocus,
|
onClose: restoreFocus,
|
||||||
onUnpaidClick: () => {
|
onUnpaidClick: () => {
|
||||||
@@ -1646,6 +1649,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
<Show when={!providersLoading() && store.mode !== "shell" && showVariantControl()}>
|
<Show when={!providersLoading() && store.mode !== "shell" && showVariantControl()}>
|
||||||
<div
|
<div
|
||||||
data-component="prompt-variant-control"
|
data-component="prompt-variant-control"
|
||||||
|
class="[&_[data-action=prompt-model-variant]]:![font-weight:440]"
|
||||||
classList={{
|
classList={{
|
||||||
"animate-in fade-in": providersShouldFadeIn(),
|
"animate-in fade-in": providersShouldFadeIn(),
|
||||||
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
|
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
|
||||||
@@ -1662,22 +1666,45 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Select
|
<MenuV2
|
||||||
size="normal"
|
gutter={6}
|
||||||
options={variants()}
|
modal={false}
|
||||||
current={props.controls.model.selection.variant.current() ?? "default"}
|
placement="top-start"
|
||||||
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
|
||||||
onOpenChange={(open) => setStore("variantOpen", open)}
|
onOpenChange={(open) => setStore("variantOpen", open)}
|
||||||
onSelect={(value) => {
|
>
|
||||||
props.controls.model.selection.variant.set(value === "default" ? undefined : value)
|
<MenuV2.Trigger
|
||||||
restoreFocus()
|
as={ButtonV2}
|
||||||
}}
|
data-action="prompt-model-variant"
|
||||||
class="capitalize max-w-[160px] justify-start text-v2-text-text-faint"
|
variant="ghost-muted"
|
||||||
valueClass="truncate text-[13px] font-[440] leading-5 text-v2-text-text-faint"
|
size="normal"
|
||||||
triggerStyle={control()}
|
class="max-w-[160px] justify-start capitalize"
|
||||||
triggerProps={{ "data-action": "prompt-model-variant" }}
|
style={control()}
|
||||||
variant="ghost"
|
>
|
||||||
/>
|
<span class="truncate">
|
||||||
|
{props.controls.model.selection.variant.current() ?? language.t("common.default")}
|
||||||
|
</span>
|
||||||
|
<span class="-ml-0.5 -mr-1 flex shrink-0">
|
||||||
|
<Icon name="chevron-down" size="small" />
|
||||||
|
</span>
|
||||||
|
</MenuV2.Trigger>
|
||||||
|
<MenuV2.Portal>
|
||||||
|
<MenuV2.Content>
|
||||||
|
<MenuV2.RadioGroup
|
||||||
|
value={props.controls.model.selection.variant.current() ?? "default"}
|
||||||
|
onChange={(value) => {
|
||||||
|
props.controls.model.selection.variant.set(value === "default" ? undefined : value)
|
||||||
|
restoreFocus()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{variants().map((value) => (
|
||||||
|
<MenuV2.RadioItem value={value} class="capitalize">
|
||||||
|
{value === "default" ? language.t("common.default") : value}
|
||||||
|
</MenuV2.RadioItem>
|
||||||
|
))}
|
||||||
|
</MenuV2.RadioGroup>
|
||||||
|
</MenuV2.Content>
|
||||||
|
</MenuV2.Portal>
|
||||||
|
</MenuV2>
|
||||||
</TooltipV2>
|
</TooltipV2>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -2057,6 +2084,7 @@ type ComposerModelControlState = {
|
|||||||
model: ReturnType<typeof useLocal>["model"]
|
model: ReturnType<typeof useLocal>["model"]
|
||||||
providerID?: string
|
providerID?: string
|
||||||
modelName: string
|
modelName: string
|
||||||
|
newLayoutDesigns: boolean
|
||||||
style: JSX.CSSProperties | undefined
|
style: JSX.CSSProperties | undefined
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onUnpaidClick: () => void
|
onUnpaidClick: () => void
|
||||||
@@ -2147,36 +2175,65 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ModelSelectorPopover
|
<Show
|
||||||
model={props.state.model}
|
when={props.state.newLayoutDesigns}
|
||||||
triggerAs={Button}
|
fallback={
|
||||||
triggerProps={{
|
<ModelSelectorPopover
|
||||||
variant: "ghost",
|
model={props.state.model}
|
||||||
size: "normal",
|
triggerAs={Button}
|
||||||
style: props.state.style,
|
triggerProps={{
|
||||||
class:
|
variant: "ghost",
|
||||||
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group",
|
size: "normal",
|
||||||
classList: { "animate-in fade-in": props.state.shouldAnimate },
|
style: props.state.style,
|
||||||
"data-action": "prompt-model",
|
class:
|
||||||
}}
|
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group",
|
||||||
onClose={props.state.onClose}
|
classList: { "animate-in fade-in": props.state.shouldAnimate },
|
||||||
|
"data-action": "prompt-model",
|
||||||
|
}}
|
||||||
|
onClose={props.state.onClose}
|
||||||
|
>
|
||||||
|
<ModelControlContent state={props.state} />
|
||||||
|
</ModelSelectorPopover>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Show when={props.state.providerID}>
|
<ModelSelectorPopoverV2
|
||||||
{(providerID) => (
|
model={props.state.model}
|
||||||
<ProviderIcon
|
triggerAs={ButtonV2}
|
||||||
id={providerID()}
|
triggerProps={{
|
||||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
variant: "ghost-muted",
|
||||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
size: "normal",
|
||||||
/>
|
style: props.state.style,
|
||||||
)}
|
class: "min-w-0 max-w-[220px] justify-start ![font-weight:440] group",
|
||||||
</Show>
|
classList: { "animate-in fade-in": props.state.shouldAnimate },
|
||||||
<span class="truncate">{props.state.modelName}</span>
|
"data-action": "prompt-model",
|
||||||
<span class="-ml-1 shrink-0 flex size-fit">
|
}}
|
||||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
onClose={props.state.onClose}
|
||||||
</span>
|
>
|
||||||
</ModelSelectorPopover>
|
<ModelControlContent state={props.state} v2 />
|
||||||
|
</ModelSelectorPopoverV2>
|
||||||
|
</Show>
|
||||||
</TooltipV2>
|
</TooltipV2>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ModelControlContent(props: { state: ComposerModelControlState; v2?: boolean }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Show when={props.state.providerID}>
|
||||||
|
{(providerID) => (
|
||||||
|
<ProviderIcon
|
||||||
|
id={providerID()}
|
||||||
|
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||||
|
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
<span class="truncate">{props.state.modelName}</span>
|
||||||
|
<span class={props.v2 ? "-ml-0.5 -mr-1 flex shrink-0" : "-ml-1 shrink-0 flex size-fit"}>
|
||||||
|
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -194,8 +194,19 @@
|
|||||||
timeline-scope: --home-projects-scroll;
|
timeline-scope: --home-projects-scroll;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-slot="model-selector-scroll"] {
|
||||||
|
timeline-scope: --model-selector-scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="manage-models-scroll"] {
|
||||||
|
timeline-scope: --manage-models-scroll;
|
||||||
|
}
|
||||||
|
|
||||||
[data-slot="home-projects-scroll"]::before,
|
[data-slot="home-projects-scroll"]::before,
|
||||||
[data-slot="home-projects-scroll"]::after {
|
[data-slot="home-projects-scroll"]::after,
|
||||||
|
[data-slot="model-selector-scroll"]::before,
|
||||||
|
[data-slot="model-selector-scroll"]::after,
|
||||||
|
[data-slot="manage-models-scroll"]::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
@@ -211,11 +222,26 @@
|
|||||||
background: linear-gradient(to bottom, var(--v2-background-bg-base), transparent);
|
background: linear-gradient(to bottom, var(--v2-background-bg-base), transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-slot="model-selector-scroll"]::before {
|
||||||
|
top: 0;
|
||||||
|
background: linear-gradient(to bottom, var(--v2-background-bg-layer-01), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="manage-models-scroll"]::before {
|
||||||
|
top: 0;
|
||||||
|
background: linear-gradient(to bottom, var(--v2-background-bg-base), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
[data-slot="home-projects-scroll"]::after {
|
[data-slot="home-projects-scroll"]::after {
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: linear-gradient(to top, var(--v2-background-bg-base), transparent);
|
background: linear-gradient(to top, var(--v2-background-bg-base), transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-slot="model-selector-scroll"]::after {
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(to top, var(--v2-background-bg-layer-01), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
@supports (animation-timeline: --home-projects-scroll) and (timeline-scope: --home-projects-scroll) {
|
@supports (animation-timeline: --home-projects-scroll) and (timeline-scope: --home-projects-scroll) {
|
||||||
[data-slot="home-projects-scroll"] .scroll-view__viewport {
|
[data-slot="home-projects-scroll"] .scroll-view__viewport {
|
||||||
scroll-timeline: --home-projects-scroll y;
|
scroll-timeline: --home-projects-scroll y;
|
||||||
@@ -233,4 +259,34 @@
|
|||||||
animation-range: calc(100% - 1.1px) calc(100% - 1px);
|
animation-range: calc(100% - 1.1px) calc(100% - 1px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@supports (animation-timeline: --model-selector-scroll) and (timeline-scope: --model-selector-scroll) {
|
||||||
|
[data-slot="model-selector-scroll"] .scroll-view__viewport {
|
||||||
|
scroll-timeline: --model-selector-scroll y;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="model-selector-scroll"]::before {
|
||||||
|
animation: home-projects-fade-top linear both;
|
||||||
|
animation-timeline: --model-selector-scroll;
|
||||||
|
animation-range: 0 0.1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="model-selector-scroll"]::after {
|
||||||
|
animation: home-projects-fade-bottom linear both;
|
||||||
|
animation-timeline: --model-selector-scroll;
|
||||||
|
animation-range: calc(100% - 1.1px) calc(100% - 1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports (animation-timeline: --manage-models-scroll) and (timeline-scope: --manage-models-scroll) {
|
||||||
|
[data-slot="manage-models-scroll"] .settings-v2-panel {
|
||||||
|
scroll-timeline: --manage-models-scroll y;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="manage-models-scroll"]::before {
|
||||||
|
animation: home-projects-fade-top linear both;
|
||||||
|
animation-timeline: --manage-models-scroll;
|
||||||
|
animation-range: 0 0.1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,3 +5,5 @@ export function ModelSelectorPopover(props: { triggerAs: any; triggerProps?: Rec
|
|||||||
const Trigger = local.triggerAs
|
const Trigger = local.triggerAs
|
||||||
return <Trigger {...(local.triggerProps ?? {})}>{local.children}</Trigger>
|
return <Trigger {...(local.triggerProps ?? {})}>{local.children}</Trigger>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const ModelSelectorPopoverV2 = ModelSelectorPopover
|
||||||
|
|||||||
@@ -93,6 +93,10 @@ const icons = {
|
|||||||
viewBox: "0 0 16 16",
|
viewBox: "0 0 16 16",
|
||||||
body: `<path d="M2.5 7.5H3.5V8.5H2.5V7.5Z" stroke="currentColor"/><path d="M7.5 7.5H8.5V8.5H7.5V7.5Z" stroke="currentColor"/><path d="M12.5 7.5H13.5V8.5H12.5V7.5Z" stroke="currentColor"/>`,
|
body: `<path d="M2.5 7.5H3.5V8.5H2.5V7.5Z" stroke="currentColor"/><path d="M7.5 7.5H8.5V8.5H7.5V7.5Z" stroke="currentColor"/><path d="M12.5 7.5H13.5V8.5H12.5V7.5Z" stroke="currentColor"/>`,
|
||||||
},
|
},
|
||||||
|
"outline-sliders": {
|
||||||
|
viewBox: "0 0 16 16",
|
||||||
|
body: `<path d="M11.7779 4.66675H14.4446M11.7779 4.66675C11.7779 5.77132 10.8825 6.66675 9.77789 6.66675C8.67332 6.66675 7.77789 5.77132 7.77789 4.66675M11.7779 4.66675C11.7779 3.56218 10.8825 2.66675 9.77789 2.66675C8.67332 2.66675 7.77789 3.56218 7.77789 4.66675M1.55566 4.66675H7.77789M4.22233 11.3334H1.55566M4.22233 11.3334C4.22233 12.438 5.11776 13.3334 6.22233 13.3334C7.3269 13.3334 8.22233 12.438 8.22233 11.3334M4.22233 11.3334C4.22233 10.2288 5.11776 9.33341 6.22233 9.33341C7.3269 9.33341 8.22233 10.2288 8.22233 11.3334M14.4446 11.3334H8.22233" stroke="currentColor"/>`,
|
||||||
|
},
|
||||||
"outline-copy": {
|
"outline-copy": {
|
||||||
viewBox: "0 0 16 16",
|
viewBox: "0 0 16 16",
|
||||||
body: `<path d="M4.14908 11.0081H1.76282V1.51758H9.1038V2.55588M14.2225 4.99681H6.75397V14.4873H14.2225V4.99681Z" stroke="currentColor"/>`,
|
body: `<path d="M4.14908 11.0081H1.76282V1.51758H9.1038V2.55588M14.2225 4.99681H6.75397V14.4873H14.2225V4.99681Z" stroke="currentColor"/>`,
|
||||||
|
|||||||
Reference in New Issue
Block a user