diff --git a/packages/app/e2e/regression/home.spec.ts b/packages/app/e2e/regression/home.spec.ts
index 15c8a15ea9b..15d190d8a43 100644
--- a/packages/app/e2e/regression/home.spec.ts
+++ b/packages/app/e2e/regression/home.spec.ts
@@ -224,11 +224,11 @@ test("Home lists the used server projects by recent activity, hides them, and li
await newSessionLocation(page).click()
const options = page.getByRole("dialog").getByRole("option")
await expect(options.locator(".command-palette-title")).toHaveText([
+ "Add project",
"oldest",
"fresh",
"unused",
"recent",
- "Add project",
])
await options.filter({ hasText: "C:/OpenCode/recent" }).click()
await expect(newSessionLocation(page)).toContainText("recent")
diff --git a/packages/app/e2e/regression/new-session.spec.ts b/packages/app/e2e/regression/new-session.spec.ts
index b2b01b7dd98..075f4135585 100644
--- a/packages/app/e2e/regression/new-session.spec.ts
+++ b/packages/app/e2e/regression/new-session.spec.ts
@@ -590,6 +590,7 @@ test("the location dialog lists every ready server's projects in one list and ta
const paths = options.locator(".command-palette-description")
const chip = dialog.getByRole("button", { name: "Back to projects", exact: true })
const heading = dialog.getByText("Projects", { exact: true })
+ const addButton = dialog.getByRole("button", { name: "Add project", 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"])
@@ -606,6 +607,10 @@ test("the location dialog lists every ready server's projects in one list and ta
})
.toBe(true)
await expect(rows.nth(0)).toHaveAttribute("aria-selected", "true")
+ // The search field also ends with an "Add project" button, next in the Tab order.
+ await page.keyboard.press("Tab")
+ await expect(addButton).toBeFocused()
+ await search.focus()
// The picker shares the palette's full-width search, compact rows, and single-line titles.
await expect(rows.nth(0)).toHaveCSS("height", "36px")
@@ -644,6 +649,7 @@ test("the location dialog lists every ready server's projects in one list and ta
await page.keyboard.press("ArrowRight")
// The worktree step has no "Add project".
await expect(add).toHaveCount(0)
+ await expect(addButton).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"])
@@ -672,6 +678,13 @@ test("the location dialog lists every ready server's projects in one list and ta
await page.keyboard.press("ArrowUp")
await page.keyboard.press("ArrowRight")
await expect(chip).toHaveText("Add project")
+ await expect(addButton).toHaveCount(0)
+ // The search field's button starts the same flow.
+ await page.keyboard.press("Backspace")
+ await expect(addButton).toBeVisible()
+ await page.keyboard.press("Tab")
+ await page.keyboard.press("Enter")
+ await expect(chip).toHaveText("Add project")
// The server the app runs beside shows its hostname and "This computer" instead of its address.
await expect(options).toHaveText(["hailmaryThis computer", "apollo"])
await options.filter({ hasText: "apollo" }).click()
diff --git a/packages/app/src/new-session/location/dialog.css b/packages/app/src/new-session/location/dialog.css
index 37efdecc2e9..93a89864b7b 100644
--- a/packages/app/src/new-session/location/dialog.css
+++ b/packages/app/src/new-session/location/dialog.css
@@ -66,3 +66,41 @@
text-overflow: ellipsis;
white-space: nowrap;
}
+
+.new-session-location-add {
+ display: flex;
+ height: 24px;
+ align-items: center;
+ gap: 4px;
+ padding: 0 8px 0 6px;
+ border-radius: 4px;
+ color: var(--v2-text-text-muted);
+ font-size: 13px;
+ font-weight: 440;
+ line-height: 16px;
+ white-space: nowrap;
+ outline: none;
+}
+
+.new-session-location-add:is(:hover, :focus-visible) {
+ background: var(--v2-overlay-simple-overlay-hover);
+ color: var(--v2-text-text-base);
+}
+
+.new-session-location-add:focus-visible {
+ outline: 2px solid var(--v2-border-border-focus);
+ outline-offset: -2px;
+}
+
+/* Narrow screens keep only the icon; the tooltip names it. */
+@media (max-width: 480px) {
+ .new-session-location-add {
+ width: 24px;
+ justify-content: center;
+ padding: 0;
+ }
+
+ .new-session-location-add > span {
+ display: none;
+ }
+}
diff --git a/packages/app/src/new-session/location/dialog.tsx b/packages/app/src/new-session/location/dialog.tsx
index 969471e359c..0e2817aff22 100644
--- a/packages/app/src/new-session/location/dialog.tsx
+++ b/packages/app/src/new-session/location/dialog.tsx
@@ -11,6 +11,7 @@ import {
ProjectAvatar,
} from "@opencode/ui/project-avatar"
import { TextInput } from "@opencode/ui/text-input"
+import { Tooltip } from "@opencode/ui/tooltip"
import { createTypeahead } from "@opencode/ui/typeahead"
import { getFilename } from "@opencode/util/path"
import { createEffect, createMemo, createResource, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js"
@@ -418,6 +419,21 @@ function NewSessionLocationDialog(props: {
aria-activedescendant={typeahead.activeID()}
onInput={(event) => setStore("query", event.currentTarget.value)}
onKeyDown={handleKeyDown}
+ trailing={
+