From 112427aeb9f97b6d7587cd98cc0cec97f1398b83 Mon Sep 17 00:00:00 2001 From: usrnk1 <7547651+usrnk1@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:04:01 +0100 Subject: [PATCH] fix(app): make dropdowns open instantly (#59268) --- .../app/e2e/regression/new-session.spec.ts | 118 +++++++++++++++- .../src/new-session/workspace/controller.ts | 6 +- .../src/new-session/workspace/selector.tsx | 133 +++++++++++++----- packages/ui/src/components/popover.css | 32 ----- packages/ui/src/forms/typeahead/typeahead.tsx | 68 +++++---- packages/ui/src/navigation/menu/menu.css | 30 ---- 6 files changed, 257 insertions(+), 130 deletions(-) diff --git a/packages/app/e2e/regression/new-session.spec.ts b/packages/app/e2e/regression/new-session.spec.ts index 24a93a7d776..03bb8f613eb 100644 --- a/packages/app/e2e/regression/new-session.spec.ts +++ b/packages/app/e2e/regression/new-session.spec.ts @@ -76,13 +76,14 @@ test("the combined location picker shows checkout branches and chooses the base const screen = page.locator('[data-component="new-session"]') const base = screen.getByRole("button", { name: "from timeline-quotes", exact: true }) await expect(base).toBeVisible() - expect(searches).toEqual([]) + await expect.poll(() => searches).toEqual([{ directory: view.directory, search: "", limit: 50 }]) await base.click() const search = page.getByRole("combobox", { name: "Search branches", exact: true }) - await expect(search).toBeFocused() - await expect(search).toHaveAttribute("aria-busy", "true") + await expect(search).toHaveCount(0) await expect(newSessionLocation(page)).toBeVisible() listed.resolve(["timeline-quotes", "release/base", "origin/dev"]) + await expect(search).toBeFocused() + await expect(search).toHaveAttribute("aria-busy", "false") await expect(page.getByRole("option", { name: "release/base", exact: true })).toBeVisible() await search.fill("release") await expect(search).toHaveAttribute("aria-busy", "false") @@ -96,6 +97,15 @@ test("the combined location picker shows checkout branches and chooses the base { directory: view.directory, search: "release", limit: 50 }, ]) + await selected.click() + await expect(search).toBeFocused() + await expect(search).toHaveValue("") + await expect(page.getByRole("option")).toHaveCount(3) + // Reopening revalidates the branch list. + expect(searches).toHaveLength(3) + await page.getByRole("option", { name: "release/base", exact: true }).click() + await expect(view.editor).toBeFocused() + await page.setViewportSize({ width: 390, height: 844 }) await expect .poll(async () => { @@ -136,6 +146,55 @@ test("the combined location picker shows checkout branches and chooses the base .toEqual([expect.objectContaining({ body: expect.objectContaining({ text: "Use the selected base" }) })]) }) +test("a pending branch picker can be dismissed before its results arrive", async ({ page }) => { + const listed = Promise.withResolvers() + + const view = await openWorktreeDraft(page, { + name: "BranchDismiss", + vcs: () => ({ current: "dev", default: "dev" }), + branches: () => listed.promise, + }) + + const base = page.getByRole("button", { name: "from dev", exact: true }) + const search = page.getByRole("combobox", { name: "Search branches", exact: true }) + await base.click() + await expect(base).toHaveAttribute("aria-expanded", "true") + await expect(search).toHaveCount(0) + await view.editor.click() + await expect(base).toHaveAttribute("aria-expanded", "false") + await base.click() + await base.press("Escape") + await expect(base).toHaveAttribute("aria-expanded", "false") + await base.click() + await base.click() + await expect(base).toHaveAttribute("aria-expanded", "false") + listed.resolve(["dev", "origin/dev"]) + await expect(base).toHaveAttribute("aria-busy", "false") + await expect(search).toHaveCount(0) + await base.click() + await expect(search).toBeFocused() + await expect(page.getByRole("option")).toHaveText(["dev", "origin/dev"]) +}) + +test("reopening the branch picker shows branches created since it last opened", async ({ page }) => { + let created = false + + await openWorktreeDraft(page, { + name: "BranchRefresh", + vcs: () => ({ current: "dev", default: "dev" }), + branches: () => (created ? ["dev", "feature/new"] : ["dev"]), + }) + + const base = page.getByRole("button", { name: "from dev", exact: true }) + await base.click() + await expect(page.getByRole("option")).toHaveText(["dev"]) + created = true + await base.click() + await expect(page.getByRole("combobox", { name: "Search branches", exact: true })).toHaveCount(0) + await base.click() + await expect(page.getByRole("option")).toHaveText(["dev", "feature/new"]) +}) + test("branch search keeps results on screen while a query is pending", async ({ page }) => { const held = Promise.withResolvers() @@ -174,11 +233,64 @@ test("branch search keeps results on screen while a query is pending", async ({ await search.pressSequentially("-missing") await expect(options).toHaveText(["timeline-quotes"]) await expect(page.getByRole("listbox")).not.toHaveAttribute("data-emptied") + held.resolve([]) await expect(page.getByText("No results found", { exact: true })).toBeVisible() await expect(search).toHaveAttribute("aria-busy", "false") }) +test("the empty branch search state is as tall as a single result", async ({ page }) => { + await openWorktreeDraft(page, { + name: "BranchEmptyHeight", + vcs: () => ({ current: "dev", default: "dev" }), + branches: () => ["dev"], + }) + + await page.getByRole("button", { name: "from dev", exact: true }).click() + await expect(page.getByRole("option")).toHaveCount(1) + const panel = page.getByRole("dialog") + // Measured on open, before any search animates the panel. + const single = await panel.evaluate((element) => element.getBoundingClientRect().height) + + await page.getByRole("combobox", { name: "Search branches", exact: true }).fill("missing") + await expect(page.getByText("No results found", { exact: true })).toBeVisible() + await expect.poll(() => panel.evaluate((element) => element.getBoundingClientRect().height)).toBe(single) +}) + +test("backspacing keeps confirmed branch results until the broader search answers", async ({ page }) => { + const broader = Promise.withResolvers() + const closed = ["closed-search-beta", "closed-session-search", "reopen-closed-tab", "recently-closed-projects"] + + await openWorktreeDraft(page, { + name: "BranchBackspace", + vcs: () => ({ current: "dev", default: "dev" }), + branches: (input) => { + if (input.search === "clo") return broader.promise + + if (input.search) return closed + + return [...closed, "origin/cloudflare-run-images", "origin/cloudflare-run-functions"] + }, + }) + + await page.getByRole("button", { name: "from dev", exact: true }).click() + const search = page.getByRole("combobox", { name: "Search branches", exact: true }) + const options = page.getByRole("option") + await expect(options).toHaveCount(6) + await search.fill("closed") + await expect(search).toHaveAttribute("aria-busy", "false") + await expect(options).toHaveText(closed) + await search.press("Backspace") + await search.press("Backspace") + await search.press("Backspace") + await expect(search).toHaveValue("clo") + await expect(search).toHaveAttribute("aria-busy", "true") + await expect(options).toHaveText(closed) + broader.resolve([...closed, "clover-fix"]) + await expect(search).toHaveAttribute("aria-busy", "false") + await expect(options).toHaveText([...closed, "clover-fix"]) +}) + test("the dark new session panel exposes no lighter background at its rounded corners", async ({ page }) => { await page.setViewportSize({ width: 935, height: 522 }) await openDraft(page, { name: "PanelCorner", seed: { theme: { id: "oc-2", scheme: "dark" } } }) diff --git a/packages/app/src/new-session/workspace/controller.ts b/packages/app/src/new-session/workspace/controller.ts index 7a572289cf3..aec07897723 100644 --- a/packages/app/src/new-session/workspace/controller.ts +++ b/packages/app/src/new-session/workspace/controller.ts @@ -60,9 +60,7 @@ export function createNewSessionWorkspaceController(input: { const settings = useSettings() const tabs = useTabs() - const [state, setState] = createStore({ - existing: undefined as { projectID: string; directory: string } | undefined, - }) + const [state, setState] = createStore<{ existing?: { projectID: string; directory: string } }>({}) const currentProject = createMemo(() => { const projectID = data.location.info({ directory: sdk().directory })?.project.id @@ -267,7 +265,7 @@ export function createNewSessionWorkspaceController(input: { visible, branch, }, - branches: (search: string, signal: AbortSignal) => + branches: (search: string, signal?: AbortSignal) => serverSDK.api.vcs.branch .list({ location: { directory: projectRoot() }, search, limit: 50 }, { signal }) .then((response) => response.data), diff --git a/packages/app/src/new-session/workspace/selector.tsx b/packages/app/src/new-session/workspace/selector.tsx index babce57d280..4027fc73550 100644 --- a/packages/app/src/new-session/workspace/selector.tsx +++ b/packages/app/src/new-session/workspace/selector.tsx @@ -1,6 +1,7 @@ -import { Show } from "solid-js" +import { createEffect, createResource, Show } from "solid-js" import { createStore } from "solid-js/store" import { Popover } from "@kobalte/core/popover" +import { makeEventListener } from "@solid-primitives/event-listener" import { createResizeObserver } from "@solid-primitives/resize-observer" import { Icon } from "@opencode/ui/icon" import { Tooltip } from "@opencode/ui/tooltip" @@ -14,8 +15,50 @@ export function NewSessionBranch(props: { workspace: NewSessionWorkspaceControll const [state, setState] = createStore({ open: false, truncated: false }) let outside = false + let trigger: HTMLButtonElement | undefined const creating = () => props.workspace.selection.value() === "create" + // Fetch before opening and retain the result across closes, rather than showing a smaller loading panel. + const [branches, branchActions] = createResource( + () => (creating() ? props.workspace.project.root() : undefined), + async (directory) => ({ + directory, + ...(await props.workspace.branches("").then( + (items) => ({ items, failed: false }), + () => ({ items: [], failed: true }), + )), + }), + ) + + const initial = () => { + if (branches.state !== "ready" && branches.state !== "refreshing") return + const result = branches.latest + + return result?.directory === props.workspace.project.root() ? result : undefined + } + + // Until content mounts, Kobalte has no dismissable layer to cancel a pending open. + createEffect(() => { + if (!state.open || initial()) return + + const close = (event: Event) => { + if (event.target instanceof Node && trigger?.contains(event.target)) return + setState("open", false) + } + + makeEventListener(document, "pointerdown", close, { capture: true }) + makeEventListener(document, "focusin", close) + makeEventListener(window, "blur", close) + makeEventListener( + window, + "keydown", + (event) => { + if (event.key === "Escape") setState("open", false) + }, + { capture: true }, + ) + }) + const label = () => creating() ? language.t("session.new.workspace.fromBranch", { branch: props.workspace.bar.branch() ?? "" }) @@ -46,11 +89,20 @@ export function NewSessionBranch(props: { workspace: NewSessionWorkspaceControll > setState("open", open)} + onOpenChange={(open) => { + setState("open", open) + + // Revalidate on every open, as before the preload; the loaded list stays on screen meanwhile. + if (open && initial()) branchActions.refetch() + }} placement="bottom-start" gutter={4} > - + {label()} @@ -58,37 +110,50 @@ export function NewSessionBranch(props: { workspace: NewSessionWorkspaceControll - (outside = true)} - onCloseAutoFocus={(event) => { - // An outside click keeps focus where the user put it. - if (event.defaultPrevented || outside) return (outside = false) - event.preventDefault() - props.onDone() - }} - > - branch.toLowerCase().includes(query.toLowerCase())} - delay={100} - key={(branch) => branch} - selected={props.workspace.bar.branch()} - onSelect={(branch) => { - props.workspace.selection.branch(branch) - setState("open", false) - }} - > - {(branch) => ( - - {branch} - - )} - - + + {(initial) => ( + (outside = true)} + onCloseAutoFocus={(event) => { + // An outside click keeps focus where the user put it. + if (event.defaultPrevented || outside) return (outside = false) + event.preventDefault() + props.onDone() + }} + > + { + const directory = props.workspace.project.root() + const items = await props.workspace.branches(query, signal) + + if (!query && directory === props.workspace.project.root()) + branchActions.mutate({ directory, items, failed: false }) + + return items + }} + match={(branch, query) => branch.toLowerCase().includes(query.toLowerCase())} + delay={100} + key={(branch) => branch} + selected={props.workspace.bar.branch()} + onSelect={(branch) => { + props.workspace.selection.branch(branch) + setState("open", false) + }} + > + {(branch) => ( + + {branch} + + )} + + + )} + diff --git a/packages/ui/src/components/popover.css b/packages/ui/src/components/popover.css index 4d50c4cb620..8eb42c1a4a1 100644 --- a/packages/ui/src/components/popover.css +++ b/packages/ui/src/components/popover.css @@ -13,20 +13,10 @@ background-clip: padding-box; box-shadow: var(--shadow-md); - transform-origin: var(--kb-popover-content-transform-origin); - &:focus-within { outline: none; } - &[data-closed] { - animation: popover-close 0.15s ease-out; - } - - &[data-expanded] { - animation: popover-open 0.15s ease-out; - } - [data-slot="popover-header"] { display: flex; padding: 12px; @@ -74,25 +64,3 @@ fill: var(--surface-raised-stronger-non-alpha); } } - -@keyframes popover-open { - from { - opacity: 0; - transform: scale(0.96); - } - to { - opacity: 1; - transform: scale(1); - } -} - -@keyframes popover-close { - from { - opacity: 1; - transform: scale(1); - } - to { - opacity: 0; - transform: scale(0.96); - } -} diff --git a/packages/ui/src/forms/typeahead/typeahead.tsx b/packages/ui/src/forms/typeahead/typeahead.tsx index a45756db94d..c4351085c1d 100644 --- a/packages/ui/src/forms/typeahead/typeahead.tsx +++ b/packages/ui/src/forms/typeahead/typeahead.tsx @@ -1,5 +1,5 @@ import { createResizeObserver } from "@solid-primitives/resize-observer" -import { createEffect, createMemo, For, type JSX, onCleanup, Show } from "solid-js" +import { createEffect, createMemo, For, type JSX, on, onCleanup, Show } from "solid-js" import { createStore } from "solid-js/store" import { Icon } from "@opencode/ui/icon" import { abort } from "@opencode/util/abort" @@ -12,6 +12,8 @@ export { createTypeahead } from "./controller" /** A search field over results from `source` that keeps rows on screen while a query is pending. */ export function Typeahead(props: { source: (query: string, signal: AbortSignal) => Promise + /** Preloaded opening results, including a failed request that the user can retry. */ + initial?: { items: T[]; failed?: boolean } /** Narrows earlier results while the request for the current query is pending. */ match: (item: T, query: string) => boolean key: (item: T) => string @@ -27,17 +29,23 @@ export function Typeahead(props: { }) { const i18n = useI18n() - const [store, setStore] = createStore<{ query: string; answered?: string; failed?: boolean; loading?: boolean }>({ - query: "", - }) + const [store, setStore] = createStore<{ + query: string + searched: boolean + answered?: string + failed?: boolean + loading?: boolean + }>({ query: "", searched: false, answered: props.initial ? "" : undefined }) const cache = new Map() - let seen: T[] = [] let request: AbortController | undefined let input: HTMLInputElement | undefined onCleanup(() => abort(request)) const query = () => store.query.trim() + // The parent owns the opening list, so a refresh reaches an open panel without remounting the input. + const opening = () => (query() ? undefined : props.initial) + const failed = () => (opening() ? opening()?.failed : store.failed) const pending = () => store.loading || store.answered !== query() const search = (query: string) => { @@ -49,9 +57,7 @@ export function Typeahead(props: { props.source(query, current.signal).then( (items) => { if (current.signal.aborted) return - const keys = new Set(items.map(props.key)) cache.set(query, items) - seen = [...items, ...seen.filter((item) => !keys.has(props.key(item)))].slice(0, 500) setStore({ answered: query, failed: false, loading: false }) }, () => !current.signal.aborted && setStore({ answered: query, failed: true, loading: false }), @@ -59,27 +65,36 @@ export function Typeahead(props: { } // A cached or empty query answers at once; a typed one waits, and the request it supersedes is aborted. - createEffect(() => { - const next = query() + createEffect( + on( + query, + (next) => { + if (!next || cache.has(next)) return search(next) + abort(request) + const timer = setTimeout(() => search(next), props.delay) + onCleanup(() => clearTimeout(timer)) + }, + { defer: !!props.initial }, + ), + ) - if (!next || cache.has(next)) return search(next) - abort(request) - const timer = setTimeout(() => search(next), props.delay) - onCleanup(() => clearTimeout(timer)) - }) - - // While a query is pending: its cached answer, else earlier rows that match it, else the rows on screen, marked stale. + // Narrow visible rows while pending; broadening waits for a cached or server answer, avoiding speculative rows flashing. const results = createMemo<{ items: T[]; stale: boolean }>((previous) => { const next = query() + const preloaded = opening() + + if (preloaded) return { items: preloaded.failed ? [] : preloaded.items, stale: false } if (store.answered === next && store.failed) return { items: [], stale: false } - const cached = cache.get(next) ?? seen.filter((item) => props.match(item, next)) + const cached = cache.get(next) ?? previous?.items.filter((item) => props.match(item, next)) ?? [] if (cache.has(next) || cached.length > 0) return { items: cached, stale: false } return { items: previous?.items ?? [], stale: !!previous?.items.length } }) + const animateHeight = () => store.searched && results().items.length > 0 + const typeahead = createTypeahead({ items: () => results().items, key: (item) => props.key(item), @@ -119,7 +134,7 @@ export function Typeahead(props: { autocomplete="off" spellcheck={false} class="h-8 min-w-0 flex-1 outline-none placeholder:text-v2-text-text-faint" - onInput={(event) => setStore("query", event.currentTarget.value)} + onInput={(event) => setStore({ query: event.currentTarget.value, searched: true })} onKeyDown={typeahead.onKeyDown} /> @@ -137,8 +152,11 @@ export function Typeahead(props: { - {/* Height follows the results on a critically damped spring (0.3 s, bounce 0); the first measurement snaps. */} -
+ {/* Initial results and the empty state snap into place; other search resizing animates. */} +
createResizeObserver(element, (rect) => @@ -146,11 +164,7 @@ export function Typeahead(props: { ) } > -
+
{(item) => (
(props: { {/* Stays mounted while a retry or a newer query is pending, so the list never collapses to nothing. */}
- +