fix(app): make dropdowns open instantly (#59268)

This commit is contained in:
usrnk1
2026-10-27 15:04:01 +01:00
committed by GitHub
Unverified
parent d78b817631
commit 112427aeb9
6 changed files with 257 additions and 130 deletions
+115 -3
View File
@@ -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<string[]>()
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<string[]>()
@@ -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<string[]>()
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" } } })
@@ -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),
@@ -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
>
<Popover
open={state.open}
onOpenChange={(open) => 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}
>
<Popover.Trigger class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 rounded-sm px-1.5 text-v2-text-text-muted transition-colors hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-base focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-base">
<Popover.Trigger
ref={trigger}
aria-busy={branches.loading}
class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 rounded-sm px-1.5 text-v2-text-text-muted transition-colors hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-base focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-base"
>
<Icon name="branch-out" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span ref={observe} dir="auto" class="min-w-0 truncate">
{label()}
@@ -58,37 +110,50 @@ export function NewSessionBranch(props: { workspace: NewSessionWorkspaceControll
<Icon name="chevron-down" size="small" class="-ms-0.5 shrink-0 text-v2-icon-icon-muted" />
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
class="w-[243px] overflow-hidden rounded-lg bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none"
onInteractOutside={() => (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()
}}
>
<Typeahead
placeholder={language.t("session.new.workspace.branch.search.placeholder")}
emptyLabel={language.t("palette.empty")}
retryLabel={language.t("common.retry")}
source={props.workspace.branches}
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) => (
<span dir="auto" class="min-w-0 truncate">
{branch}
</span>
)}
</Typeahead>
</Popover.Content>
<Show when={initial()}>
{(initial) => (
<Popover.Content
class="w-[243px] overflow-hidden rounded-lg bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none"
onInteractOutside={() => (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()
}}
>
<Typeahead
initial={initial()}
placeholder={language.t("session.new.workspace.branch.search.placeholder")}
emptyLabel={language.t("palette.empty")}
retryLabel={language.t("common.retry")}
source={async (query, signal) => {
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) => (
<span dir="auto" class="min-w-0 truncate">
{branch}
</span>
)}
</Typeahead>
</Popover.Content>
)}
</Show>
</Popover.Portal>
</Popover>
</Show>
-32
View File
@@ -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);
}
}
+41 -27
View File
@@ -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<T>(props: {
source: (query: string, signal: AbortSignal) => Promise<T[]>
/** 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<T>(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<string, T[]>()
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<T>(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<T>(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<T>({
items: () => results().items,
key: (item) => props.key(item),
@@ -119,7 +134,7 @@ export function Typeahead<T>(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}
/>
<Show when={store.query}>
@@ -137,8 +152,11 @@ export function Typeahead<T>(props: {
</button>
</Show>
</div>
{/* Height follows the results on a critically damped spring (0.3 s, bounce 0); the first measurement snaps. */}
<div class="overflow-hidden transition-[height] duration-[350ms] ease-[linear(0,0.097,0.282,0.466,0.619,0.736,0.821,0.881,0.921,0.949,0.967,0.979,0.986,0.991,1)] motion-reduce:transition-none">
{/* Initial results and the empty state snap into place; other search resizing animates. */}
<div
class="overflow-hidden duration-[200ms] ease-[cubic-bezier(0.645,0.045,0.355,1)] motion-reduce:transition-none"
classList={{ "transition-[height]": animateHeight(), "transition-none": !animateHeight() }}
>
<div
ref={(element) =>
createResizeObserver(element, (rect) =>
@@ -146,11 +164,7 @@ export function Typeahead<T>(props: {
)
}
>
<div
id={typeahead.listID}
role="listbox"
class="max-h-56 overflow-y-auto px-1 pb-1 empty:hidden"
>
<div id={typeahead.listID} role="listbox" class="max-h-56 overflow-y-auto px-1 pb-1 empty:hidden">
<For each={results().items}>
{(item) => (
<div
@@ -178,10 +192,10 @@ export function Typeahead<T>(props: {
{/* Stays mounted while a retry or a newer query is pending, so the list never collapses to nothing. */}
<Show when={results().items.length === 0}>
<div
class="mx-1 mb-1 flex h-7 items-center px-2 text-v2-text-text-faint"
class="mx-1 flex h-8 items-center px-2 pb-1 text-v2-text-text-faint"
classList={{ invisible: store.answered === undefined }}
>
<Show when={store.failed} fallback={props.emptyLabel}>
<Show when={failed()} fallback={props.emptyLabel}>
<button
type="button"
class="-mx-2 flex h-7 flex-1 items-center rounded-sm px-2 text-start text-v2-text-text-base outline-none hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover"
-30
View File
@@ -23,9 +23,6 @@
-moz-osx-font-smoothing: grayscale;
text-rendering: geometricPrecision;
transform-origin: var(--kb-menu-content-transform-origin);
animation: menu-v2-in 120ms ease-out;
&:focus-visible {
outline: none;
}
@@ -196,11 +193,6 @@
background-clip: padding-box;
box-shadow: var(--shadow-md);
z-index: 60;
animation: dropdown-menu-open 150ms ease-out;
&[data-closed] {
animation: dropdown-menu-close 150ms ease-out;
}
[data-component="menu-v2-item"] {
position: relative;
@@ -285,28 +277,6 @@
}
}
@keyframes dropdown-menu-open {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes dropdown-menu-close {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.96);
}
}
@keyframes menu-v2-in {
from {
opacity: 0;