Sfoglia il codice sorgente

feat(app): composer improvements (#34720)

Co-authored-by: Brendan Allan <git@brendonovich.dev>
Aarav Sareen 2 mesi fa
parent
commit
9410eda4e3

+ 1 - 1
packages/app/e2e/regression/prompt-thinking-level.spec.ts

@@ -66,7 +66,7 @@ test("shows the V2 thinking level control while relevant", async ({ page }) => {
   await expect(control).toBeVisible()
 
   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 page.mouse.move(0, 0)
   await expect(control).toBeVisible()

+ 159 - 1
packages/app/src/components/dialog-manage-models.tsx

@@ -3,13 +3,26 @@ import { List } from "@opencode-ai/ui/list"
 import { Switch } from "@opencode-ai/ui/switch"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
 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 { popularProviders } from "@/hooks/use-providers"
 import { useLanguage } from "@/context/language"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { DialogSelectProvider } from "./dialog-select-provider"
 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 = () => {
   const local = useLocal()
@@ -102,3 +115,148 @@ export const DialogManageModels: Component = () => {
     </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">&quot;{list.filter()}&quot;</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>
+  )
+}

+ 292 - 1
packages/app/src/components/dialog-select-model.tsx

@@ -1,15 +1,19 @@
 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 { useLocal } from "@/context/local"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { popularProviders } from "@/hooks/use-providers"
 import { Button } from "@opencode-ai/ui/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 { Dialog } from "@opencode-ai/ui/dialog"
 import { List } from "@opencode-ai/ui/list"
 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 { useLanguage } from "@/context/language"
 import { decode64 } from "@/utils/base64"
@@ -18,6 +22,22 @@ const isFree = (provider: string, cost: { input: number } | undefined) =>
   provider === "opencode" && (!cost || cost.input === 0)
 
 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<{
   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) => {
   const dialog = useDialog()
   const language = useLanguage()

+ 100 - 43
packages/app/src/components/prompt-input.tsx

@@ -35,12 +35,14 @@ import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
 import { Icon } from "@opencode-ai/ui/icon"
 import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 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 { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
 import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { Select } from "@opencode-ai/ui/select"
 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 { Persist, persisted } from "@/utils/persist"
 import { usePermission } from "@/context/permission"
@@ -1480,6 +1482,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     model: props.controls.model.selection,
     providerID: props.controls.model.selection.current()?.provider?.id,
     modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"),
+    newLayoutDesigns: props.controls.newLayoutDesigns,
     style: control(),
     onClose: restoreFocus,
     onUnpaidClick: () => {
@@ -1646,6 +1649,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                   <Show when={!providersLoading() && store.mode !== "shell" && showVariantControl()}>
                     <div
                       data-component="prompt-variant-control"
+                      class="[&_[data-action=prompt-model-variant]]:![font-weight:440]"
                       classList={{
                         "animate-in fade-in": providersShouldFadeIn(),
                         "hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
@@ -1662,22 +1666,45 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                           </>
                         }
                       >
-                        <Select
-                          size="normal"
-                          options={variants()}
-                          current={props.controls.model.selection.variant.current() ?? "default"}
-                          label={(x) => (x === "default" ? language.t("common.default") : x)}
+                        <MenuV2
+                          gutter={6}
+                          modal={false}
+                          placement="top-start"
                           onOpenChange={(open) => setStore("variantOpen", open)}
-                          onSelect={(value) => {
-                            props.controls.model.selection.variant.set(value === "default" ? undefined : value)
-                            restoreFocus()
-                          }}
-                          class="capitalize max-w-[160px] justify-start text-v2-text-text-faint"
-                          valueClass="truncate text-[13px] font-[440] leading-5 text-v2-text-text-faint"
-                          triggerStyle={control()}
-                          triggerProps={{ "data-action": "prompt-model-variant" }}
-                          variant="ghost"
-                        />
+                        >
+                          <MenuV2.Trigger
+                            as={ButtonV2}
+                            data-action="prompt-model-variant"
+                            variant="ghost-muted"
+                            size="normal"
+                            class="max-w-[160px] justify-start capitalize"
+                            style={control()}
+                          >
+                            <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>
                     </div>
                   </Show>
@@ -2057,6 +2084,7 @@ type ComposerModelControlState = {
   model: ReturnType<typeof useLocal>["model"]
   providerID?: string
   modelName: string
+  newLayoutDesigns: boolean
   style: JSX.CSSProperties | undefined
   onClose: () => void
   onUnpaidClick: () => void
@@ -2147,36 +2175,65 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
             </>
           }
         >
-          <ModelSelectorPopover
-            model={props.state.model}
-            triggerAs={Button}
-            triggerProps={{
-              variant: "ghost",
-              size: "normal",
-              style: props.state.style,
-              class:
-                "min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group",
-              classList: { "animate-in fade-in": props.state.shouldAnimate },
-              "data-action": "prompt-model",
-            }}
-            onClose={props.state.onClose}
+          <Show
+            when={props.state.newLayoutDesigns}
+            fallback={
+              <ModelSelectorPopover
+                model={props.state.model}
+                triggerAs={Button}
+                triggerProps={{
+                  variant: "ghost",
+                  size: "normal",
+                  style: props.state.style,
+                  class:
+                    "min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group",
+                  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}>
-              {(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="-ml-1 shrink-0 flex size-fit">
-              <Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
-            </span>
-          </ModelSelectorPopover>
+            <ModelSelectorPopoverV2
+              model={props.state.model}
+              triggerAs={ButtonV2}
+              triggerProps={{
+                variant: "ghost-muted",
+                size: "normal",
+                style: props.state.style,
+                class: "min-w-0 max-w-[220px] justify-start ![font-weight:440] group",
+                classList: { "animate-in fade-in": props.state.shouldAnimate },
+                "data-action": "prompt-model",
+              }}
+              onClose={props.state.onClose}
+            >
+              <ModelControlContent state={props.state} v2 />
+            </ModelSelectorPopoverV2>
+          </Show>
         </TooltipV2>
       </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>
+    </>
+  )
+}

+ 57 - 1
packages/app/src/index.css

@@ -194,8 +194,19 @@
     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"]::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: "";
     position: absolute;
     left: 0;
@@ -211,11 +222,26 @@
     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 {
     bottom: 0;
     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) {
     [data-slot="home-projects-scroll"] .scroll-view__viewport {
       scroll-timeline: --home-projects-scroll y;
@@ -233,4 +259,34 @@
       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;
+    }
+  }
 }

+ 2 - 0
packages/storybook/.storybook/mocks/app/components/dialog-select-model.tsx

@@ -5,3 +5,5 @@ export function ModelSelectorPopover(props: { triggerAs: any; triggerProps?: Rec
   const Trigger = local.triggerAs
   return <Trigger {...(local.triggerProps ?? {})}>{local.children}</Trigger>
 }
+
+export const ModelSelectorPopoverV2 = ModelSelectorPopover

+ 4 - 0
packages/ui/src/v2/components/icon.tsx

@@ -93,6 +93,10 @@ const icons = {
     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"/>`,
   },
+  "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": {
     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"/>`,