diff --git a/packages/app/e2e/regression/mcp-workspace-toggle.spec.ts b/packages/app/e2e/regression/mcp-workspace-toggle.spec.ts index e4621541985..53827d17a4b 100644 --- a/packages/app/e2e/regression/mcp-workspace-toggle.spec.ts +++ b/packages/app/e2e/regression/mcp-workspace-toggle.spec.ts @@ -117,6 +117,12 @@ for (const surface of ["popover", "dialog"] as const) { await view.panel.getByText("figma-desktop", { exact: true }).click() } + // The dialog opens with its search focused and the first server highlighted. + if (surface === "dialog") { + await expect(view.panel.getByRole("searchbox", { name: "Search", exact: true })).toBeFocused() + await expect(view.panel.getByRole("option")).toHaveAttribute("aria-selected", "true") + } + await reset() await expect(view.toggle).not.toBeChecked() await expect(view.toggle).toBeEnabled() diff --git a/packages/app/e2e/regression/mobile-model-pickers.spec.ts b/packages/app/e2e/regression/mobile-model-pickers.spec.ts index c2996795fe2..3fd14cb4d6f 100644 --- a/packages/app/e2e/regression/mobile-model-pickers.spec.ts +++ b/packages/app/e2e/regression/mobile-model-pickers.spec.ts @@ -245,9 +245,11 @@ test("model dialog toggles a favorite from the keyboard and leads with favorite await expect(sections).toHaveText(["OpenCode"]) await search.press("Escape") await expect(dialog).toBeHidden() + // The dialog opens on its first row with the search focused, not on the selected model further down. await model.click() - await search.press("ArrowUp") + await expect(search).toBeFocused() await expect(dialog.locator('[data-option-key="favorite:opencode:mobile-c"]')).toHaveAttribute("data-active", "") + await expect(dialog.locator('[data-option-key="recent:opencode:mobile-b"]')).not.toHaveAttribute("data-active") await search.press("ControlOrMeta+f") await expect(sections).toHaveText(["Recent", "OpenCode"]) await expect(dialog.locator('[data-option-key="opencode:mobile-c"]')).toHaveAttribute("data-active", "") diff --git a/packages/app/src/providers/connect/mcp-dialog.tsx b/packages/app/src/providers/connect/mcp-dialog.tsx index 77363b0ae03..977b7b607e2 100644 --- a/packages/app/src/providers/connect/mcp-dialog.tsx +++ b/packages/app/src/providers/connect/mcp-dialog.tsx @@ -99,7 +99,7 @@ export const DialogSelectMcp: Component = () => { ) } - // A toggle changes the server's status, which makes the list reset its highlight; keep the keyboard user's place. + // A toggle changes the server's status, which makes the list reset its highlight to the first row; keep the user's place. let kept = "" const list = useFilteredList({ @@ -112,7 +112,6 @@ export const DialogSelectMcp: Component = () => { kept = x.name select(x.name) }, - noInitialSelection: true, }) createEffect( @@ -208,10 +207,13 @@ export const DialogSelectMcp: Component = () => { data-active={list.active() === item.name ? "" : undefined} class="search-list-row" onMouseMove={() => { - kept = "" + kept = item.name list.setActive(item.name) }} - onMouseLeave={() => list.setActive("")} + onMouseLeave={() => { + kept = "" + list.setActive("") + }} onMouseDown={(event) => event.preventDefault()} onClick={() => select(item.name)} > diff --git a/packages/app/src/providers/models/select-dialog.tsx b/packages/app/src/providers/models/select-dialog.tsx index 4c4364eae56..c2952b35a85 100644 --- a/packages/app/src/providers/models/select-dialog.tsx +++ b/packages/app/src/providers/models/select-dialog.tsx @@ -97,37 +97,51 @@ const ModelList: Component<{ collapsed: Record }>({ search: "", - active: props.mobile ? (controller.current() ?? "") : "", + active: "", collapsed: {}, }) - createEffect( - on( - () => props.open, - (open) => { - if (!props.mobile || !open) return - setStore({ search: "", active: controller.current() ?? "" }) - }, - ), - ) const models = createMemo(() => controller.models(store.search)) const modelGroups = createMemo(() => controller.groups(models())) const managed = createMemo(() => consoleModelGroup(controller.all())) const expanded = (provider: string) => store.search.length > 0 || !store.collapsed[provider] const managedIDs = createMemo(() => new Set(managed()?.providers.map((provider) => provider.id) ?? [])) - const visibleModels = () => - models().filter( - (item) => expanded(item.provider.id) && (!managedIDs().has(item.provider.id) || expanded(CONSOLE_GROUP_KEY)), - ) + // Rows in display order: ProviderModelSections nests every managed provider at the first one's position. + const visibleModels = () => { + const groups = modelGroups() + const first = groups.findIndex((group) => managedIDs().has(group.category)) + + return groups.flatMap((group, index) => { + if (!managedIDs().has(group.category)) return expanded(group.category) ? group.items : [] + + if (index !== first || !expanded(CONSOLE_GROUP_KEY)) return [] + + return groups + .filter((item) => managedIDs().has(item.category) && expanded(item.category)) + .flatMap((item) => item.items) + }) + } let scrollRef: HTMLDivElement | undefined const setSearch = (value: string) => { - const first = controller.models(value).find((item) => value.length > 0 || !store.collapsed[item.provider.id]) - setStore({ search: value, active: first ? modelKey(first) : "" }) + setStore("search", value) + const first = visibleModels()[0] + setStore("active", first ? modelKey(first) : "") } + createEffect( + on( + () => props.open, + (open) => { + if (!open) return + setSearch("") + scrollRef?.scrollTo(0, 0) + }, + ), + ) + const moveActive = (delta: number) => { const keys = visibleModels().map(modelKey) @@ -613,7 +627,7 @@ export function DialogSelectModel(props: { provider?: string; model?: ModelState const [store, setStore] = createStore({ search: "", - active: controller.current() ?? "", + active: "", flyout: "", keyboard: true, navigated: false, @@ -653,7 +667,7 @@ export function DialogSelectModel(props: { provider?: string; model?: ModelState const flyoutItem = () => (store.flyout ? rowItem(store.flyout) : undefined) const variantsOf = (item: ModelItem) => Object.keys(item.variants ?? {}) - setStore("active", rows().find((row) => modelKey(row.item) === controller.current())?.id ?? "") + setStore("active", rows()[0]?.id ?? "") const variant = (item: ModelItem) => modelKey(item) in staged ? (staged[modelKey(item)] ?? undefined) : controller.variant(item) @@ -674,8 +688,8 @@ export function DialogSelectModel(props: { provider?: string; model?: ModelState } const setSearch = (value: string) => { - const first = controller.models(value)[0] - setStore({ search: value, active: first ? modelKey(first) : "", flyout: "", keyboard: true, navigated: false }) + setStore({ search: value, flyout: "", keyboard: true, navigated: false }) + setStore("active", rows()[0]?.id ?? "") } const triangle = createSafeTriangle({ diff --git a/packages/app/src/session/commands/fork-dialog.tsx b/packages/app/src/session/commands/fork-dialog.tsx index da9b361514b..f8e48f312f0 100644 --- a/packages/app/src/session/commands/fork-dialog.tsx +++ b/packages/app/src/session/commands/fork-dialog.tsx @@ -99,13 +99,12 @@ export const DialogFork: Component = () => { key: (x) => x.id, filterKeys: ["text"], onSelect: handleSelect, - noInitialSelection: true, }) let resultsRef: HTMLDivElement | undefined const onKeyDown = (event: KeyboardEvent) => { - // Nothing is highlighted until the pointer or arrow keys pick a row, so Enter forks from the newest match. + // The pointer leaving the rows clears the highlight, so Enter then forks from the newest match. if (event.key === "Enter" && !event.isComposing && !list.active()) { event.preventDefault() handleSelect(list.flat()[0]) diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 0fa520c7a20..e40c328aceb 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -138,21 +138,6 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) ), ) - createEffect(() => { - const scroll = scrollRef() - - if (!scroll) return - - if (!props.current) return - const key = props.key(props.current) - requestAnimationFrame(() => { - const element = findByKey(scroll, key) - - if (!element) return - scrollIntoView(scroll, element, "center") - }) - }) - createEffect(() => { const all = flat() diff --git a/packages/ui/src/hooks/use-filtered-list.tsx b/packages/ui/src/hooks/use-filtered-list.tsx index 4e2bfcb9dc5..06c4602c956 100644 --- a/packages/ui/src/hooks/use-filtered-list.tsx +++ b/packages/ui/src/hooks/use-filtered-list.tsx @@ -14,7 +14,6 @@ export interface FilteredListProps { sortGroupsBy?: (a: { category: string; items: T[] }, b: { category: string; items: T[] }) => number skipFilter?: (item: T) => boolean onSelect?: (value: T | undefined, index: number) => void - noInitialSelection?: boolean } export function useFilteredList(props: FilteredListProps) { @@ -67,31 +66,14 @@ export function useFilteredList(props: FilteredListProps) { ) }) - function initialActive() { - if (props.noInitialSelection) return "" - - if (props.current) return props.key(props.current) - - const items = flat() - - if (items.length === 0) return "" - - return props.key(items[0]) - } - + // Lists open on their first row; `current` only marks the selected item. const list = createList({ items: () => flat().map(props.key), - initialActive: initialActive(), + initialActive: flat()[0] ? props.key(flat()[0]) : "", loop: true, }) const reset = () => { - if (props.noInitialSelection) { - list.setActive("") - - return - } - const all = flat() if (all.length === 0) return