diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index 4fb7891ec95..e323c6f99cc 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/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, "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} > - - {props.children} - + 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 ( + { + void import("./dialog-manage-models").then((module) => { + void dialog.show(() => ) + }) + }} + 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() - 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() + 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(`[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(() => ) - }) - }) + 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 ( - - {props.children} - + (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} >
@@ -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: { {group.items[0].provider.name} - + {(item) => ( { diff --git a/packages/app/src/components/prompt-input-v2.tsx b/packages/app/src/components/prompt-input-v2.tsx index 113019ed0ac..44d9d48d386 100644 --- a/packages/app/src/components/prompt-input-v2.tsx +++ b/packages/app/src/components/prompt-input-v2.tsx @@ -522,20 +522,22 @@ function PromptInputV2ModelControl(props: { > ( + + {content()} + + )} onClose={props.onClose} - > - {content()} - + /> diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index f70cb399289..7923e18ee2a 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1723,29 +1723,31 @@ export const PromptInput: Component = (props) => { > ( + + )} onClose={restoreFocus} - > - - - - - {props.controls.model.selection.current()?.name ?? - language.t("dialog.model.select.title")} - - - + />
diff --git a/packages/app/src/components/prompt-project-selector.tsx b/packages/app/src/components/prompt-project-selector.tsx index c1109659617..b84afdb56bf 100644 --- a/packages/app/src/components/prompt-project-selector.tsx +++ b/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(`[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) + }} > @@ -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} >
diff --git a/packages/app/src/utils/menu-dismiss-controller.ts b/packages/app/src/utils/menu-dismiss-controller.ts new file mode 100644 index 00000000000..0a3009eb716 --- /dev/null +++ b/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) + }, + } +} diff --git a/packages/storybook/.storybook/mocks/app/components/dialog-select-model.tsx b/packages/storybook/.storybook/mocks/app/components/dialog-select-model.tsx index 0c7ff8bbdd6..0741f7e4ef9 100644 --- a/packages/storybook/.storybook/mocks/app/components/dialog-select-model.tsx +++ b/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; children: any }) { - const [local] = splitProps(props, ["triggerAs", "triggerProps", "children"]) - const Trigger = local.triggerAs - return {local.children} +export function ModelSelectorPopover(props: { trigger: (props: Record) => JSX.Element }) { + const [local] = splitProps(props, ["trigger"]) + return <>{local.trigger({})} } export const ModelSelectorPopoverV2 = ModelSelectorPopover