mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
fix(app): open select dialogs at the top
Searchable pickers start at the first row with search focused. The current selection keeps its checkmark but isn't scrolled to or highlighted.
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -97,37 +97,51 @@ const ModelList: Component<{
|
||||
collapsed: Record<string, boolean>
|
||||
}>({
|
||||
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({
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -138,21 +138,6 @@ export function List<T>(props: ListProps<T> & { 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()
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ export interface FilteredListProps<T> {
|
||||
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<T>(props: FilteredListProps<T>) {
|
||||
@@ -67,31 +66,14 @@ export function useFilteredList<T>(props: FilteredListProps<T>) {
|
||||
)
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user