Sfoglia il codice sorgente

refactor(app): separate model selector controller and view (#39084)

Brendan Allan 1 mese fa
parent
commit
b72d7b6fa2

+ 93 - 77
packages/app/src/components/dialog-select-model.tsx

@@ -6,9 +6,7 @@ import {
   createMemo,
   For,
   JSX,
-  onCleanup,
   Show,
-  ValidComponent,
 } from "solid-js"
 import { createStore } from "solid-js/store"
 import { useLocal } from "@/context/local"
@@ -29,6 +27,7 @@ import { ModelTooltip } from "./model-tooltip"
 import { useLanguage } from "@/context/language"
 import { decode64 } from "@/utils/base64"
 import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
+import { createMenuDismissController } from "@/utils/menu-dismiss-controller"
 import { createEventListener } from "@solid-primitives/event-listener"
 import { matchesModelSearch } from "./dialog-select-model-search"
 
@@ -122,14 +121,13 @@ const ModelList: Component<{
 }
 
 type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
+type ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => JSX.Element
 type Dismiss = "escape" | "outside" | "select" | "manage" | "provider"
 
 export function ModelSelectorPopover(props: {
   provider?: string
   model?: ModelState
-  children?: JSX.Element
-  triggerAs?: ValidComponent
-  triggerProps?: ModelSelectorTriggerProps
+  trigger: ModelSelectorTrigger
   onClose?: (cause: "escape" | "select") => void
 }) {
   const [store, setStore] = createStore<{
@@ -174,9 +172,7 @@ export function ModelSelectorPopover(props: {
       placement="top-start"
       gutter={4}
     >
-      <Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
-        {props.children}
-      </Kobalte.Trigger>
+      <Kobalte.Trigger as={props.trigger} />
       <Kobalte.Portal>
         <Kobalte.Content
           class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
@@ -237,66 +233,101 @@ export function ModelSelectorPopover(props: {
 export function ModelSelectorPopoverV2(props: {
   provider?: string
   model?: ModelState
-  children?: JSX.Element
-  triggerAs?: ValidComponent
-  triggerProps?: ModelSelectorTriggerProps
+  trigger: ModelSelectorTrigger
   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 controller = createModelSelectorController({
+    model: props.model,
+    provider: () => props.provider,
+    onSelect: () => props.onClose?.(),
+  })
+
+  return (
+    <ModelSelectorPopoverV2View
+      trigger={props.trigger}
+      models={controller.models}
+      groups={controller.groups}
+      current={controller.current}
+      select={controller.select}
+      onManage={() => {
+        void import("./dialog-manage-models").then((module) => {
+          void dialog.show(() => <module.DialogManageModelsV2 />)
+        })
+      }}
+      onClose={() => props.onClose?.()}
+    />
+  )
+}
 
+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) => (props.provider ? item.provider.id === props.provider : true)),
+      .filter((item) => (input.provider() ? item.provider.id === input.provider() : true)),
   )
-  const models = createMemo(() => {
-    const search = store.search.trim()
-    const filtered = search
-      ? allModels().filter((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
-      : 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
+  return {
+    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<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)
+    },
+    current: () => {
+      const value = model.current()
+      return value ? modelKey(value) : undefined
+    },
+    select: (item: ModelItem) => {
+      model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
+      input.onSelect()
+    },
   }
+}
+
+function ModelSelectorPopoverV2View(props: {
+  trigger: ModelSelectorTrigger
+  models: (search: string) => ModelItem[]
+  groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[]
+  current: () => string | undefined
+  select: (item: ModelItem) => void
+  onManage: () => void
+  onClose: () => void
+}) {
+  const language = useLanguage()
+  const [store, setStore] = createStore({ open: false, search: "", active: "" })
+  let searchRef: HTMLInputElement | undefined
+  let contentRef: HTMLDivElement | undefined
+  const dismiss = createMenuDismissController(() => contentRef)
+
+  const models = createMemo(() => props.models(store.search))
+  const groups = createMemo(() => props.groups(models()))
+  const keys = () => [...models().map(modelKey), manageKey]
   const initialActive = () => {
-    const selected = current()
+    const selected = props.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
+      dismiss.allowTriggerRestore()
       setStore({ open: true, active: initialActive() })
       setTimeout(() =>
         requestAnimationFrame(() => {
@@ -308,23 +339,15 @@ export function ModelSelectorPopoverV2(props: {
     }
     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
+    dismiss.preventTriggerRestore()
     setOpen(false)
-    afterClose(() => select(item))
+    dismiss.afterClose(() => props.select(item))
   }
   const manage = () => {
-    restoreTrigger = false
+    dismiss.preventTriggerRestore()
     setOpen(false)
-    afterClose(() => {
-      void import("./dialog-manage-models").then((x) => {
-        dialog.show(() => <x.DialogManageModelsV2 />)
-      })
-    })
+    dismiss.afterClose(props.onManage)
   }
   const selectActive = () => {
     const item = models().find((item) => modelKey(item) === store.active)
@@ -343,10 +366,7 @@ export function ModelSelectorPopoverV2(props: {
     queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
   }
   const setSearch = (value: string) => {
-    const search = value.trim()
-    const first = [...allModels()]
-      .sort((a, b) => a.name.localeCompare(b.name))
-      .find((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
+    const first = props.models(value)[0]
     setStore({ search: value, active: first ? modelKey(first) : manageKey })
   }
 
@@ -362,18 +382,14 @@ export function ModelSelectorPopoverV2(props: {
 
   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.Trigger as={props.trigger} />
       <MenuV2.Portal>
         <MenuV2.Content
-          ref={(el: HTMLDivElement) => (contentRef = el)}
+          ref={(element: HTMLDivElement) => (contentRef = element)}
           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()
-          }}
+          onPointerDownOutside={dismiss.preventTriggerRestore}
+          onFocusOutside={dismiss.preventTriggerRestore}
+          onCloseAutoFocus={dismiss.onCloseAutoFocus}
         >
           <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">
@@ -393,9 +409,9 @@ export function ModelSelectorPopoverV2(props: {
                   event.stopPropagation()
                   if (event.key === "Escape") {
                     event.preventDefault()
-                    restoreTrigger = false
+                    dismiss.preventTriggerRestore()
                     setOpen(false)
-                    afterClose(() => props.onClose?.())
+                    dismiss.afterClose(props.onClose)
                     return
                   }
                   if (event.altKey || event.metaKey) return
@@ -445,7 +461,7 @@ export function ModelSelectorPopoverV2(props: {
                       <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()}>
+                      <MenuV2.RadioGroup value={props.current()}>
                         <For each={group.items}>
                           {(item) => (
                             <TooltipV2
@@ -465,7 +481,7 @@ export function ModelSelectorPopoverV2(props: {
                               <MenuV2.RadioItem
                                 value={modelKey(item)}
                                 data-option-key={modelKey(item)}
-                                data-selected-model={current() === modelKey(item) ? true : undefined}
+                                data-selected-model={props.current() === modelKey(item) ? true : undefined}
                                 class="scroll-my-6 w-full"
                                 classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
                                 onMouseEnter={() => {

+ 15 - 13
packages/app/src/components/prompt-input-v2.tsx

@@ -522,20 +522,22 @@ function PromptInputV2ModelControl(props: {
         >
           <ModelSelectorPopoverV2
             model={props.model}
-            triggerAs={ButtonV2}
-            triggerProps={{
-              variant: "ghost-muted",
-              size: "normal",
-              style: { height: "28px" },
-              class: "min-w-0 max-w-[220px] justify-start ![font-weight:440] group",
-              classList: { "animate-in fade-in": shouldAnimate() },
-              "data-action": "prompt-model",
-              "data-control-type": "popover",
-            }}
+            trigger={(triggerProps) => (
+              <ButtonV2
+                {...triggerProps}
+                variant="ghost-muted"
+                size="normal"
+                style={{ height: "28px" }}
+                class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
+                classList={{ "animate-in fade-in": shouldAnimate() }}
+                data-action="prompt-model"
+                data-control-type="popover"
+              >
+                {content()}
+              </ButtonV2>
+            )}
             onClose={props.onClose}
-          >
-            {content()}
-          </ModelSelectorPopoverV2>
+          />
         </Show>
       </TooltipV2>
     </Show>

+ 24 - 22
packages/app/src/components/prompt-input.tsx

@@ -1723,29 +1723,31 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                         >
                           <ModelSelectorPopover
                             model={props.controls.model.selection}
-                            triggerAs={Button}
-                            triggerProps={{
-                              variant: "ghost",
-                              size: "normal",
-                              style: control(),
-                              class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
-                              "data-action": "prompt-model",
-                            }}
+                            trigger={(triggerProps) => (
+                              <Button
+                                {...triggerProps}
+                                variant="ghost"
+                                size="normal"
+                                style={control()}
+                                class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
+                                data-action="prompt-model"
+                              >
+                                <Show when={props.controls.model.selection.current()?.provider?.id}>
+                                  <ProviderIcon
+                                    id={props.controls.model.selection.current()?.provider?.id ?? ""}
+                                    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.controls.model.selection.current()?.name ??
+                                    language.t("dialog.model.select.title")}
+                                </span>
+                                <Icon name="chevron-down" size="small" class="shrink-0" />
+                              </Button>
+                            )}
                             onClose={restoreFocus}
-                          >
-                            <Show when={props.controls.model.selection.current()?.provider?.id}>
-                              <ProviderIcon
-                                id={props.controls.model.selection.current()?.provider?.id ?? ""}
-                                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.controls.model.selection.current()?.name ??
-                                language.t("dialog.model.select.title")}
-                            </span>
-                            <Icon name="chevron-down" size="small" class="shrink-0" />
-                          </ModelSelectorPopover>
+                          />
                         </TooltipKeybind>
                       </Show>
                     </div>

+ 14 - 22
packages/app/src/components/prompt-project-selector.tsx

@@ -18,6 +18,7 @@ import { useLanguage } from "@/context/language"
 import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
 import { pathKey } from "@/utils/path-key"
 import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
+import { createMenuDismissController } from "@/utils/menu-dismiss-controller"
 
 export type PromptProject = {
   name?: string
@@ -197,8 +198,8 @@ export function PromptProjectSelector(props: {
 }) {
   const [triggerReady, setTriggerReady] = createSignal(false)
   let contentRef: HTMLDivElement | undefined
+  const dismiss = createMenuDismissController(() => contentRef)
   let triggerFrame: number | undefined
-  let restoreTrigger = true
 
   // Floating UI requires a connected anchor; route transitions can construct this trigger before adoption.
   const setTriggerRef = (element: HTMLButtonElement) => {
@@ -221,25 +222,15 @@ export function PromptProjectSelector(props: {
     props.controller.active()
       ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
       : undefined
-  const afterClose = (callback: () => void) => {
-    const complete = () => {
-      if (contentRef?.isConnected) {
-        requestAnimationFrame(complete)
-        return
-      }
-      requestAnimationFrame(() => requestAnimationFrame(callback))
-    }
-    requestAnimationFrame(complete)
-  }
   const selectProject = (project: PromptProject) => {
-    restoreTrigger = false
+    dismiss.preventTriggerRestore()
     props.controller.setOpen(false)
-    afterClose(() => props.controller.select(project))
+    dismiss.afterClose(() => props.controller.select(project))
   }
   const selectAction = (server?: string) => {
-    restoreTrigger = false
+    dismiss.preventTriggerRestore()
     props.controller.setOpen(false)
-    afterClose(() => props.controller.add(server))
+    dismiss.afterClose(() => props.controller.add(server))
   }
   const selectActive = () => {
     const project = props.controller.activeProject()
@@ -267,7 +258,7 @@ export function PromptProjectSelector(props: {
     )
       .filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap"))
       .findLast((element) => element.offsetParent !== null)
-    restoreTrigger = false
+    dismiss.preventTriggerRestore()
     target?.focus()
     queueMicrotask(() => {
       if (props.controller.open()) props.controller.setOpen(false)
@@ -291,7 +282,10 @@ export function PromptProjectSelector(props: {
       placement={props.placement ?? "bottom"}
       gutter={4}
       modal={false}
-      onOpenChange={(open) => props.controller.setOpen(open)}
+      onOpenChange={(open) => {
+        if (open) dismiss.allowTriggerRestore()
+        props.controller.setOpen(open)
+      }}
     >
       <DropdownMenu.Trigger as={ProjectTrigger} ref={setTriggerRef} controller={props.controller} />
       <DropdownMenu.Portal>
@@ -300,11 +294,9 @@ export function PromptProjectSelector(props: {
           id="prompt-project-menu"
           class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none [&[data-closed]]:!animate-none"
           onOpenAutoFocus={(event) => event.preventDefault()}
-          onPointerDownOutside={() => (restoreTrigger = false)}
-          onFocusOutside={() => (restoreTrigger = false)}
-          onCloseAutoFocus={(event) => {
-            if (!restoreTrigger) event.preventDefault()
-          }}
+          onPointerDownOutside={dismiss.preventTriggerRestore}
+          onFocusOutside={dismiss.preventTriggerRestore}
+          onCloseAutoFocus={dismiss.onCloseAutoFocus}
         >
           <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">

+ 30 - 0
packages/app/src/utils/menu-dismiss-controller.ts

@@ -0,0 +1,30 @@
+/** Coordinates focus restoration and actions that must run after menu content unmounts. */
+export function createMenuDismissController(content: () => HTMLElement | undefined) {
+  let restoreTrigger = true
+
+  return {
+    /** Allows the menu primitive to restore focus to its trigger when closing. */
+    allowTriggerRestore() {
+      restoreTrigger = true
+    },
+    /** Keeps focus at its current or next destination instead of returning it to the trigger. */
+    preventTriggerRestore() {
+      restoreTrigger = false
+    },
+    /** Applies the current restoration policy during the menu primitive's close-focus event. */
+    onCloseAutoFocus(event: Event) {
+      if (!restoreTrigger) event.preventDefault()
+    },
+    /** Runs an action after the menu unmounts and its focus-close work has settled. */
+    afterClose(callback: () => void) {
+      const complete = () => {
+        if (content()?.isConnected) {
+          requestAnimationFrame(complete)
+          return
+        }
+        requestAnimationFrame(() => requestAnimationFrame(callback))
+      }
+      requestAnimationFrame(complete)
+    },
+  }
+}

+ 4 - 5
packages/storybook/.storybook/mocks/app/components/dialog-select-model.tsx

@@ -1,9 +1,8 @@
-import { splitProps } from "solid-js"
+import { splitProps, type JSX } from "solid-js"
 
-export function ModelSelectorPopover(props: { triggerAs: any; triggerProps?: Record<string, unknown>; children: any }) {
-  const [local] = splitProps(props, ["triggerAs", "triggerProps", "children"])
-  const Trigger = local.triggerAs
-  return <Trigger {...(local.triggerProps ?? {})}>{local.children}</Trigger>
+export function ModelSelectorPopover(props: { trigger: (props: Record<string, unknown>) => JSX.Element }) {
+  const [local] = splitProps(props, ["trigger"])
+  return <>{local.trigger({})}</>
 }
 
 export const ModelSelectorPopoverV2 = ModelSelectorPopover