feat(app): pin Add project at the top of the location dialog

This commit is contained in:
Dax Raad
2026-10-23 13:48:16 -04:00
Unverified
parent e4b15661b9
commit a5e5f67b6c
3 changed files with 152 additions and 102 deletions
+39 -13
View File
@@ -584,39 +584,61 @@ test("the location dialog lists every ready server's projects in one list and ta
const search = dialog.getByRole("combobox")
await expect(search).toBeFocused()
const options = dialog.getByRole("option")
const rows = dialog.locator(".command-palette-results").getByRole("option")
const add = options.filter({ hasText: "Add project" })
const names = options.locator(".new-session-location-name")
const paths = options.locator(".command-palette-description")
const chip = dialog.getByRole("button", { name: "Back to projects", exact: true })
// Used projects first, then the rest, each by recent activity across servers; only remote ones carry a tag.
await expect(names).toHaveText(["opencodeapollo", "opencode", "console", "sstapollo"])
await expect(paths).toHaveText(["/home/dax/opencode", "C:/work/opencode", "C:/work/console", "/home/dax/sst"])
await expect(options.last()).toHaveText("Add project")
// It opens on the first row, not on the draft's current project (the second row).
await expect(options.nth(0)).toHaveAttribute("aria-selected", "true")
// "Add project" is pinned above the scrolling list and its heading, yet the first project is still the opening row,
// not the draft's current project (the second row).
await expect(options.first()).toHaveText("Add project")
await expect(rows).toHaveCount(4)
await expect
.poll(async () => {
const row = await add.boundingBox()
const heading = await dialog.getByText("Projects", { exact: true }).boundingBox()
return row && heading ? row.y + row.height <= heading.y : false
})
.toBe(true)
await expect(rows.nth(0)).toHaveAttribute("aria-selected", "true")
// The picker shares the palette's full-width search, compact rows, and single-line titles.
await expect(options.nth(0)).toHaveCSS("height", "36px")
await expect(options.nth(0)).toHaveCSS("display", "flex")
await expect(rows.nth(0)).toHaveCSS("height", "36px")
await expect(rows.nth(0)).toHaveCSS("display", "flex")
await expect(names.nth(0).locator(".command-palette-title")).toHaveCSS("font-size", "13px")
await expect(names.nth(0).locator(".command-palette-title")).toHaveCSS("white-space", "nowrap")
await expect(options.nth(0)).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)")
await expect(rows.nth(0)).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)")
// The pinned row shares the list's width and icon column.
await expect
.poll(async () => {
const field = await dialog.locator('[data-component="text-input-v2"]').boundingBox()
const row = await options.nth(0).boundingBox()
const row = await rows.nth(0).boundingBox()
const pinned = await add.boundingBox()
const icon = await add.locator(".command-palette-row-icon").boundingBox()
const avatar = await rows.nth(0).locator('[data-component="project-avatar-v2"]').boundingBox()
return field && row ? Math.abs(field.width - row.width) : Infinity
return field && row && pinned && icon && avatar
? Math.max(Math.abs(field.width - row.width), Math.abs(pinned.width - row.width), Math.abs(icon.x - avatar.x))
: Infinity
})
.toBeLessThanOrEqual(1)
// Searching narrows the projects and keeps "Add project" pinned.
await search.fill("apollo")
await expect(names).toHaveText(["opencodeapollo", "sstapollo"])
await expect(add).toBeVisible()
// The worktree step keeps the server's tag beside the project.
await search.fill("opencode")
await expect(paths).toHaveText(["/home/dax/opencode", "C:/work/opencode"])
await expect(options.nth(0)).toHaveAttribute("aria-selected", "true")
await expect(rows.nth(0)).toHaveAttribute("aria-selected", "true")
await page.keyboard.press("ArrowRight")
// The worktree step has no "Add project".
await expect(add).toHaveCount(0)
await expect(chip).toContainText("opencode")
await expect(chip.locator('[data-component="tag"]')).toHaveText("apollo")
await expect(options.locator(".command-palette-title")).toHaveText(["opencode", "New worktree", "opencode-beta"])
@@ -632,13 +654,17 @@ test("the location dialog lists every ready server's projects in one list and ta
.toBeLessThanOrEqual(0.5)
await page.keyboard.press("Backspace")
await expect(search).toHaveValue("opencode")
await expect(options.nth(0)).toHaveAttribute("aria-selected", "true")
await expect(rows.nth(0)).toHaveAttribute("aria-selected", "true")
// Adding a project picks one of the ready servers first, then browses that server.
// ArrowUp from the first project reaches the pinned row above it; adding a project picks one of the ready servers
// first, then browses that server.
await search.fill("")
await expect(options.nth(0)).toHaveAttribute("aria-selected", "true")
await expect(rows.nth(0)).toHaveAttribute("aria-selected", "true")
await page.keyboard.press("ArrowUp")
await expect(add).toHaveAttribute("aria-selected", "true")
await page.keyboard.press("ArrowDown")
await expect(rows.nth(0)).toHaveAttribute("aria-selected", "true")
await page.keyboard.press("ArrowUp")
await expect(options.nth(4)).toHaveAttribute("aria-selected", "true")
await page.keyboard.press("ArrowRight")
await expect(chip).toHaveText("Add project")
// The server the app runs beside shows its hostname and "This computer" instead of its address.
@@ -66,3 +66,19 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.new-session-location-list {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
}
/* "Add project" stays above the scrolling project list. */
.new-session-location-pinned {
display: flex;
flex-shrink: 0;
flex-direction: column;
padding: 0 6px 6px;
border-bottom: 0.5px solid var(--v2-border-border-muted);
}
@@ -119,13 +119,16 @@ function NewSessionLocationDialog(props: {
return !query || values.some((value) => value?.toLowerCase().includes(query))
}
// Remote projects also match their server's name, which their tag shows.
const addEntry: LocationEntry = { id: "add", type: "add" }
// Remote projects also match their server's name, which their tag shows. "Add project" is pinned above the list but
// navigates last, so the first project stays the opening row and ArrowUp from it wraps to "Add project" above.
const projectEntries = createMemo<LocationEntry[]>(() => [
...props.project
.projects()
.filter((project) => matches(displayName(project), project.worktree, tagged(project.server)?.name))
.map((project) => ({ id: projectEntryID(project), type: "project" as const, project })),
{ id: "add", type: "add" as const },
addEntry,
])
const serverEntries = createMemo<LocationEntry[]>(() =>
@@ -161,6 +164,10 @@ function NewSessionLocationDialog(props: {
return projectEntries()
}
const pinned = () => !store.drilled && !store.picking
const listEntries = () => entries().filter((entry) => entry.type !== "add")
const title = () => {
if (store.drilled) return language.t("session.new.workspace.runIn")
@@ -298,6 +305,72 @@ function NewSessionLocationDialog(props: {
typeahead.onKeyDown(event)
}
const option = (entry: LocationEntry) => (
<button
type="button"
id={typeahead.optionID(entry)}
class="command-palette-row palette-menu-row"
role="option"
aria-selected={activeEntry()?.id === entry.id}
data-active={activeEntry()?.id === entry.id ? "" : undefined}
onMouseMove={(event) => {
// Ignore hover from a static cursor when keyboard scrolling moves rows underneath it.
if (event.movementX === 0 && event.movementY === 0) return
typeahead.setActive(entry.id)
}}
onMouseDown={(event) => event.preventDefault()}
onClick={() => choose(entry)}
>
<Switch>
<Match when={entry.type === "project" && entry}>
{(item) => (
<ProjectRow
project={item().project}
path={item().project.worktree}
onDrill={drillable(item().project) ? () => drill(item().project) : undefined}
drillLabel={language.t("session.new.location.worktrees")}
/>
)}
</Match>
<Match when={entry.type === "add"}>
<div class="command-palette-row-main">
<Icon name="plus" class="command-palette-row-icon" />
<div class="command-palette-row-text">
<span class="command-palette-title palette-menu-title">{language.t("session.new.project.add")}</span>
</div>
</div>
<Show when={choosesServer()}>
<DrillButton label={language.t("session.new.location.servers")} onDrill={pick} />
</Show>
</Match>
<Match when={entry.type === "server" && entry}>
{(item) => (
<div class="command-palette-row-main">
<Icon name={serverIcon(item().server)} class="command-palette-row-icon" />
<div class="command-palette-row-text">
<span class="command-palette-title palette-menu-title">{item().server.name}</span>
<Show when={item().server.hint}>
{(hint) => <span class="command-palette-description">{hint()}</span>}
</Show>
</div>
</div>
)}
</Match>
<Match when={entry.type === "workspace" && store.drilled && entry}>
{(item) => (
<WorkspaceRow
title={workspaceTitle(store.drilled!, item().value)}
value={item().value}
description={
item().value === "create" ? language.t("session.new.workspace.new.tooltip") : item().directory
}
/>
)}
</Match>
</Switch>
</button>
)
return (
<Dialog class="command-palette" size="large">
<DialogBody class="command-palette-body">
@@ -344,94 +417,29 @@ function NewSessionLocationDialog(props: {
onKeyDown={handleKeyDown}
/>
</div>
<ScrollView class="command-palette-scroll" viewportRef={(el) => (resultsRef = el)}>
<div id={typeahead.listID} class="command-palette-results" role="listbox">
<Show
when={entries().length > 0}
fallback={
// The worktree list may still be loading; hold the row's height without flashing a label.
<div class="command-palette-state" classList={{ invisible: !!store.drilled && listed.loading }}>
{language.t("palette.empty")}
<div id={typeahead.listID} class="new-session-location-list" role="listbox">
<Show when={pinned()}>
<div class="new-session-location-pinned">{option(addEntry)}</div>
</Show>
<ScrollView class="command-palette-scroll" viewportRef={(el) => (resultsRef = el)}>
<div class="command-palette-results">
<Show
when={listEntries().length > 0}
fallback={
// The worktree list may still be loading; hold the row's height without flashing a label.
<div class="command-palette-state" classList={{ invisible: !!store.drilled && listed.loading }}>
{language.t("palette.empty")}
</div>
}
>
<div class="command-palette-group">
<div class="command-palette-group-title">{title()}</div>
<For each={listEntries()}>{option}</For>
</div>
}
>
<div class="command-palette-group">
<div class="command-palette-group-title">{title()}</div>
<For each={entries()}>
{(entry) => (
<button
type="button"
id={typeahead.optionID(entry)}
class="command-palette-row palette-menu-row"
role="option"
aria-selected={activeEntry()?.id === entry.id}
data-active={activeEntry()?.id === entry.id ? "" : undefined}
onMouseMove={(event) => {
// Ignore hover from a static cursor when keyboard scrolling moves rows underneath it.
if (event.movementX === 0 && event.movementY === 0) return
typeahead.setActive(entry.id)
}}
onMouseDown={(event) => event.preventDefault()}
onClick={() => choose(entry)}
>
<Switch>
<Match when={entry.type === "project" && entry}>
{(item) => (
<ProjectRow
project={item().project}
path={item().project.worktree}
onDrill={drillable(item().project) ? () => drill(item().project) : undefined}
drillLabel={language.t("session.new.location.worktrees")}
/>
)}
</Match>
<Match when={entry.type === "add"}>
<div class="command-palette-row-main">
<Icon name="plus" class="command-palette-row-icon" />
<div class="command-palette-row-text">
<span class="command-palette-title palette-menu-title">
{language.t("session.new.project.add")}
</span>
</div>
</div>
<Show when={choosesServer()}>
<DrillButton label={language.t("session.new.location.servers")} onDrill={pick} />
</Show>
</Match>
<Match when={entry.type === "server" && entry}>
{(item) => (
<div class="command-palette-row-main">
<Icon name={serverIcon(item().server)} class="command-palette-row-icon" />
<div class="command-palette-row-text">
<span class="command-palette-title palette-menu-title">{item().server.name}</span>
<Show when={item().server.hint}>
{(hint) => <span class="command-palette-description">{hint()}</span>}
</Show>
</div>
</div>
)}
</Match>
<Match when={entry.type === "workspace" && store.drilled && entry}>
{(item) => (
<WorkspaceRow
title={workspaceTitle(store.drilled!, item().value)}
value={item().value}
description={
item().value === "create"
? language.t("session.new.workspace.new.tooltip")
: item().directory
}
/>
)}
</Match>
</Switch>
</button>
)}
</For>
</div>
</Show>
</div>
</ScrollView>
</Show>
</div>
</ScrollView>
</div>
<div class="new-session-location-hints">
<span>
<Keybind keys={["↵"]} variant="neutral" />