diff --git a/packages/app/e2e/regression/command-palette.spec.ts b/packages/app/e2e/regression/command-palette.spec.ts index eebaac745d5..68533559af9 100644 --- a/packages/app/e2e/regression/command-palette.spec.ts +++ b/packages/app/e2e/regression/command-palette.spec.ts @@ -146,31 +146,39 @@ test("Search commands lists only commands and runs the chosen one", async ({ pag await expect(page).toHaveURL("/settings") }) -test("Search projects opens a new session's project picker from a session and from Home", async ({ page }) => { +test("Search projects opens the full location picker from a session, draft, and Home", async ({ page }) => { const editor = page.locator('[data-component="composer-editor"]') const picker = page.getByRole("combobox", { name: "Search projects", exact: true }) - const { dialog, input } = await openCommandPalette(page) - await input.fill("search projects") - await dialog.getByRole("option", { name: /^Search projects/ }).click() + await openPaletteWorkspace(page) + const shortcut = (await page.evaluate(() => navigator.platform.includes("Mac"))) ? "Meta+Shift+O" : "Control+Shift+O" + await page.keyboard.press(shortcut) await expect(page).toHaveURL(/\/new-session\?draftId=.+$/) await expect(picker).toBeFocused() - // Just the projects: no "Add project", no worktrees and no key hints. const projects = page.getByRole("dialog").getByRole("option") - await expect(projects).toHaveCount(1) - await expect(projects).toHaveText(/command-palette/) - await expect(projects.getByRole("button", { name: "Worktrees", exact: true })).toHaveCount(0) - await expect(page.locator(".new-session-location-hints")).toHaveCount(0) + await expect(projects.filter({ hasText: "Add project" })).toBeVisible() + await picker.fill("command-palette") + await expect(projects).toHaveCount(2) + await expect(projects.getByRole("button", { name: "Worktrees", exact: true })).toBeVisible() + await picker.press("ArrowRight") + await expect(page.getByRole("option", { name: /^New worktree/ })).toBeVisible() + await page.getByRole("combobox").press("Escape") await picker.press("Escape") await expect(picker).toHaveCount(0) await expect(editor).toBeFocused() + await page.keyboard.press(shortcut) + await expect(picker).toBeFocused() + await expect(projects.filter({ hasText: "Add project" })).toBeVisible() + await picker.press("Escape") + await page.goto("/") await expect( page.getByRole("region", { name: "Recent sessions" }).getByRole("button", { name: /Palette fixture session/ }), ).toBeEnabled() - await page.keyboard.press("ControlOrMeta+Shift+O") + await page.keyboard.press(shortcut) await expect(page).toHaveURL(/\/new-session\?draftId=.+$/) await expect(picker).toBeFocused() + await expect(projects.filter({ hasText: "Add project" })).toBeVisible() }) declare global { diff --git a/packages/app/e2e/regression/home.spec.ts b/packages/app/e2e/regression/home.spec.ts index c4e5d0ce19e..3ef6662e0bc 100644 --- a/packages/app/e2e/regression/home.spec.ts +++ b/packages/app/e2e/regression/home.spec.ts @@ -179,7 +179,7 @@ test("Home lists the used server projects by recent activity, hides them, and li await page.getByRole("menuitem", { name: "Close", exact: true }).click() await expect(view.rows).toHaveText(["oldest"]) - // The location dialog lists every server project once: used ones first, then the rest, each by recency. + // Closing a project moves it out of the added list, but it remains discoverable in the server inventory. await page .locator('[data-component="home-project-row"]') .filter({ hasText: "oldest" }) @@ -188,13 +188,9 @@ test("Home lists the used server projects by recent activity, hides them, and li .click() 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", - ]) + await expect(options.locator(".command-palette-title")).toHaveText(["oldest", "Other projects", "Add project"]) + await options.filter({ hasText: "Other projects" }).click() + await expect(options.locator(".command-palette-title")).toHaveText(["fresh", "unused", "recent"]) await options.filter({ hasText: "C:/OpenCode/recent" }).click() await expect(newSessionLocation(page)).toContainText("recent") await page.getByRole("button", { name: "Home", exact: true }).click() diff --git a/packages/app/e2e/regression/new-session.spec.ts b/packages/app/e2e/regression/new-session.spec.ts index 03bb8f613eb..ce0622e289c 100644 --- a/packages/app/e2e/regression/new-session.spec.ts +++ b/packages/app/e2e/regression/new-session.spec.ts @@ -51,6 +51,29 @@ test("the new session screen does not show session details", async ({ page }) => await expect(page.getByRole("button", { name: "Session details", exact: true })).toHaveCount(0) }) +for (const row of [ + { + name: "POSIX", + directory: "/Users/alice/.local/share/opencode/worktree/123456/my-project", + pattern: /^\/Users\/.*…\/123456\/my-project$/, + }, + { + name: "Windows", + directory: "C:\\Users\\alice\\Documents\\opencode\\worktree\\123456\\my-project", + pattern: /^C:\\.*…\\123456\\my-project$/, + }, +]) { + test(`the location path keeps its start and final two segments on ${row.name}`, async ({ page }) => { + await page.setViewportSize({ width: 600, height: 700 }) + await openDraft(page, { name: "A project with a long name", directory: row.directory }) + const path = page.locator('[data-slot="new-session-location-path"]') + await expect(path).toHaveText(row.pattern) + await expect.poll(() => path.evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(true) + await path.hover() + await expect(page.getByRole("tooltip")).toHaveText(row.directory) + }) +} + test("the combined location picker shows checkout branches and chooses the base for a new worktree", async ({ page, }) => { @@ -60,7 +83,7 @@ test("the combined location picker shows checkout branches and chooses the base const view = await openWorktreeDraft(page, { name: "BranchContext", - project: { name: "OpenCode" }, + project: { name: "Custom project" }, vcs: (directory) => ({ current: directory === existing ? "model-picker-dialog" : "timeline-quotes", default: "main", @@ -126,7 +149,12 @@ test("the combined location picker shows checkout branches and chooses the base await select(/^stellar-orchid /) await expect(screen.getByText("model-picker-dialog", { exact: true })).toBeVisible() await expect(screen.getByRole("button", { name: /from / })).toHaveCount(0) - await select(/^BranchContext /) + await newSessionLocation(page).click() + await page.getByRole("combobox", { name: "Search projects", exact: true }).press("ArrowRight") + await page.getByRole("combobox", { name: "Search worktrees", exact: true }).fill("Custom project") + await expect(page.getByRole("option")).toHaveCount(1) + await page.getByRole("option", { name: /Custom project C:\/OpenCode\/BranchContext/ }).click() + await expect(view.editor).toBeFocused() await expect(screen.getByText("timeline-quotes", { exact: true })).toBeVisible() await expect(screen.getByRole("button", { name: /from / })).toHaveCount(0) await select(/^New worktree/) @@ -649,9 +677,7 @@ test("executes a selected slash command after creating its worktree", async ({ p expect(mock.prompts).toEqual([]) }) -test("the location dialog lists every ready server's projects in one list and tags the remote ones", async ({ - page, -}) => { +test("the location dialog separates added projects from server projects and tags the remote ones", async ({ page }) => { const apollo = "http://apollo.test:4096" const entry = (id: string, directory: string, active: number) => @@ -698,36 +724,33 @@ test("the location dialog lists every ready server's projects in one list and ta await expect(search).toBeFocused() const options = dialog.getByRole("option") const add = options.filter({ hasText: "Add project" }) + await expect(add.locator('[data-component="keybind-v2"]')).toContainText("O") const rows = options.filter({ has: page.locator(".new-session-location-name") }) 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 }) - 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"]) - // "Add project" is the first row of the scrolling list, above the Projects heading, yet the first project is still - // the opening row, not the draft's current project (the second row). - await expect(dialog.locator("[data-scrollable]").getByRole("option").first()).toHaveText("Add project") - await expect(rows).toHaveCount(4) - await expect - .poll(async () => { - const row = await add.boundingBox() - const title = await heading.boundingBox() - - return row && title ? row.y + row.height <= title.y : false - }) - .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() + const back = dialog.getByRole("button", { name: "Back to projects", exact: true }) + // Only the projects added to this client open expanded; other ready-server projects remain discoverable. + await expect(names).toHaveText(["opencodeapollo", "opencode"]) + await expect(paths).toHaveText(["/home/dax/opencode", "C:/work/opencode"]) + const others = options.filter({ hasText: "Other projects" }) + await expect(others).toBeVisible() + await expect(others.locator(".new-session-location-count")).toHaveText("2") + await expect(dialog.locator(".command-palette-group-title")).toHaveCount(0) + await expect(options.locator(".command-palette-title")).toHaveText([ + "opencode", + "opencode", + "Other projects", + "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") // The picker shares the palette's full-width search, compact rows, and single-line titles. - await expect(rows.nth(0)).toHaveCSS("height", "36px") - await expect(rows.nth(0)).toHaveCSS("display", "flex") + await expect(options.nth(0)).toHaveCSS("height", "28px") + await expect(dialog.locator(".palette-menu-search [data-component='text-input-v2']")).toHaveCSS("height", "28px") + await expect(dialog.locator(".new-session-location-hints")).toHaveCount(0) + await expect(dialog.locator("..")).toHaveCSS("width", "480px") + await expect(options.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(rows.nth(0)).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)") @@ -746,65 +769,99 @@ test("the location dialog lists every ready server's projects in one list and ta }) .toBeLessThanOrEqual(1) - // Searching narrows the projects and keeps "Add project" first; with no match it is the active row. + await others.click() + await expect(names).toHaveText(["console", "sstapollo"]) + await expect(back).toHaveText("Other projects") + await search.fill("no-project-matches") + await expect(options).toHaveCount(0) + await expect(back).toBeVisible() + await search.fill("") + await expect(names).toHaveText(["console", "sstapollo"]) + await search.press("ArrowLeft") + await expect(names).toHaveText(["opencodeapollo", "opencode"]) + await search.press("ArrowRight") + await expect(names).toHaveText(["console", "sstapollo"]) + await search.press("Backspace") + await search.press("Enter") + await expect(names).toHaveText(["console", "sstapollo"]) + await search.press("Escape") + await expect(names).toHaveText(["opencodeapollo", "opencode"]) + await others.click() + await back.click() + await expect(names).toHaveText(["opencodeapollo", "opencode"]) + await search.fill("apollo") await expect(names).toHaveText(["opencodeapollo", "sstapollo"]) - await expect(options.first()).toHaveText("Add project") - await search.fill("nothing matches") - await expect(options).toHaveText(["Add project"]) - await expect(add).toHaveAttribute("aria-selected", "true") - await expect(heading).toHaveCount(0) + await expect(dialog.locator(".command-palette-group-title")).toHaveText("Other projects") - // The worktree step keeps the server's tag beside the project. + // The worktree step keeps the project's avatar and server tag in its main-checkout row. await search.fill("opencode") await expect(paths).toHaveText(["/home/dax/opencode", "C:/work/opencode"]) 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(addButton).toHaveCount(0) - await expect(chip).toContainText("opencode") - await expect(chip.locator('[data-component="tag"]')).toHaveText("apollo") + await expect(back).toHaveText("Run session in") + await expect(options.nth(0).locator('[data-component="tag"]')).toHaveText("apollo") await expect(options.locator(".command-palette-title")).toHaveText(["opencode", "New worktree", "opencode-beta"]) + await expect(dialog.getByRole("separator")).toHaveCount(1) + await search.fill("opencode-beta") + await expect(options).toHaveCount(1) + await expect(dialog.getByRole("separator")).toHaveCount(0) + await search.fill("no-worktree-matches") + await expect(options).toHaveCount(0) + await expect(dialog.getByRole("separator")).toHaveCount(0) + await search.fill("") await expect(options.nth(0)).toHaveAttribute("aria-selected", "true") - // The chip's avatar sits on the same column as the row icons below it. - await expect - .poll(async () => { - const avatar = await chip.locator('[data-component="project-avatar-v2"]').boundingBox() - const icon = await options.nth(0).locator(".command-palette-row-icon").boundingBox() - - return avatar && icon ? Math.abs(avatar.x - icon.x) : Infinity - }) - .toBeLessThanOrEqual(0.5) await page.keyboard.press("Backspace") await expect(search).toHaveValue("opencode") await expect(rows.nth(0)).toHaveAttribute("aria-selected", "true") - // ArrowUp from the first project reaches "Add project" above it; adding a project picks one of the ready servers - // first, then browses that server. + // Adding a project picks one of the ready servers first, then browses that server. await search.fill("") 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 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") + await expect(options.nth(3)).toHaveAttribute("aria-selected", "true") + await page.keyboard.press((await page.evaluate(() => navigator.platform.includes("Mac"))) ? "Meta+o" : "Control+o") + await expect(back).toHaveText("Servers") // The server the app runs beside shows its hostname and "This computer" instead of its address. await expect(options).toHaveText(["hailmaryThis computer", "apollo"]) + await back.click() + await options.filter({ hasText: "Add project" }).click() + await expect(options).toHaveText(["hailmaryThis computer", "apollo"]) await options.filter({ hasText: "apollo" }).click() await expect(page.getByRole("button", { name: "Select folder", exact: true })).toBeEnabled() await expect.poll(() => files.length).toBeGreaterThan(0) }) +for (const binding of ["mod+alt+p", "none"]) { + test(`the location dialog honors the Open project shortcut override: ${binding}`, async ({ page }) => { + await openDraft(page, { + name: "ProjectShortcut", + fileList: () => [], + seed: { settings: { keybinds: { "project.open": binding } } }, + }) + await newSessionLocation(page).click() + const search = page.getByRole("combobox", { name: "Search projects", exact: true }) + await expect(search).toBeFocused() + const keys = page.getByRole("option", { name: /^Add project/ }).locator('[data-component="keybind-v2"]') + + if (binding === "none") await expect(keys).toHaveCount(0) + + if (binding !== "none") await expect(keys).toContainText("P") + + const modifier = (await page.evaluate(() => navigator.platform.includes("Mac"))) ? "Meta" : "Control" + await search.press(`${modifier}+o`) + await search.fill("still searching") + await expect(search).toHaveValue("still searching") + + if (binding === "none") return + + await search.press(`${modifier}+Alt+p`) + await expect(page.getByRole("button", { name: "Select folder", exact: true })).toBeEnabled() + }) +} + const accentRoot = "C:/OpenCode/WorkspaceAccent" const accentWorkspace = `${accentRoot}/.worktrees/feature` diff --git a/packages/app/e2e/utils/workspace.ts b/packages/app/e2e/utils/workspace.ts index 167f90bb39f..7a003567481 100644 --- a/packages/app/e2e/utils/workspace.ts +++ b/packages/app/e2e/utils/workspace.ts @@ -1,4 +1,5 @@ import { expect, type Page } from "@playwright/test" +import type { Schema } from "effect" import { NO_PROVIDER, REMOTE_SERVER, @@ -20,9 +21,9 @@ export type WorkspaceInput = Partial + project?: Partial[0]> // Defaults to one session titled `name`. Omitted fields come from `session()`. - sessions?: ({ id: string; title?: string } & Record)[] + sessions?: (Partial[0]> & { id: string })[] // Merged over the default seed: the project used before, and a tab for every session. seed?: SeedInput } @@ -31,7 +32,7 @@ export type WorkspaceInput = Partial session({ directory, projectID, ...item }), @@ -94,9 +95,9 @@ export function newSessionLocation(page: Page) { export async function openWorktreeDraft(page: Page, input: WorkspaceInput & { draftID?: string }) { const worktree = Promise.withResolvers<{ status: number; json: unknown }>() const calls: string[] = [] - const worktreeRequests: { url: URL; body: Record }[] = [] - const creates: Record[] = [] - const prompts: { sessionID: string; body: Record }[] = [] + const worktreeRequests: { url: URL; body: Schema.JsonObject }[] = [] + const creates: Schema.JsonObject[] = [] + const prompts: { sessionID: string; body: Schema.JsonObject }[] = [] page.on("request", (request) => { if (request.method() !== "POST") return const url = new URL(request.url()) @@ -140,7 +141,9 @@ export async function openWorktreeDraft(page: Page, input: WorkspaceInput & { dr await expect(editor).toBeVisible({ timeout: APP_READY_TIMEOUT }) await newSessionLocation(page).click() // The dialog opens on projects; → lists the current project's worktrees. - await page.keyboard.press("ArrowRight") + const search = page.getByRole("combobox", { name: "Search projects", exact: true }) + await expect(search).toBeFocused() + await search.press("ArrowRight") await page.getByRole("option", { name: /^New worktree/ }).click() await expect(newSessionLocation(page)).toContainText("New worktree") await expect(editor).toBeEditable() diff --git a/packages/app/src/new-session/location/dialog.css b/packages/app/src/new-session/location/dialog.css index 93a89864b7b..466356d188b 100644 --- a/packages/app/src/new-session/location/dialog.css +++ b/packages/app/src/new-session/location/dialog.css @@ -1,7 +1,150 @@ +[data-component="dialog-v2"]:has(.new-session-location) { + align-items: flex-start; +} + +[data-component="dialog-v2"]:has(.new-session-location) [data-slot="dialog-container"] { + width: min(calc(100vw - 24px), 480px); + height: auto; + max-height: min(calc(100vh - 96px), 480px); + min-height: 0; + margin-top: max(48px, calc((100vh - 480px) / 2)); + background: var(--v2-background-bg-layer-01); + box-shadow: var(--v2-elevation-overlay); +} + +.new-session-location .palette-menu-search { + padding: 6px 6px 0; +} + +.new-session-location .palette-menu-search [data-component="text-input-v2"] { + height: 28px; + padding-inline-end: 6px; + background: transparent; +} + +.new-session-location .palette-menu-search [data-slot="text-input-v2-value"] { + gap: 6px; +} + +.new-session-location .palette-menu-search [data-slot="text-input-v2-leading-icon"] { + padding-inline-start: 6px; +} + +.new-session-location .palette-menu-search [data-slot="text-input-v2-input"]::placeholder { + color: var(--v2-text-text-base); + opacity: 0.5; +} + +.new-session-location-divider { + position: relative; + height: 9px; + flex-shrink: 0; + margin-block: 4px; +} + +.new-session-location-divider::after { + position: absolute; + inset-inline: 0; + top: 4px; + border-top: 0.5px solid var(--v2-border-border-base); + content: ""; +} + +.new-session-location .command-palette-results { + gap: 4px; + padding: 0 6px 6px; +} + +.new-session-location .command-palette-group { + gap: 4px; +} + +.new-session-location-action-divider { + height: 1px; + flex-shrink: 0; + margin-block: 2px; + margin-inline: -6px; + background: var(--v2-border-border-muted); + transform: scaleY(0.5); +} + +.new-session-location .command-palette-group-title { + display: flex; + height: 28px; + flex-shrink: 0; + align-items: center; + margin: 0; + padding: 0 6px; + color: var(--v2-text-text-muted); + line-height: var(--line-height-compact); +} + +.new-session-location-back { + border: 0; + border-radius: 2px; + background: transparent; + font: inherit; + font-size: 13px; + text-align: start; + cursor: pointer; +} + +.new-session-location-back:focus-visible { + outline: 2px solid var(--v2-border-border-focus); + outline-offset: 2px; +} + +.new-session-location .palette-menu-row[data-size="compact"] { + padding-inline: 6px; +} + +.new-session-location .palette-menu-row:is([data-active], :focus-visible) { + background: var(--v2-overlay-simple-overlay-hover); +} + +.new-session-location .command-palette-row-text { + flex: 1; +} + +.new-session-location .command-palette-description { + flex: 1; + color: var(--v2-text-text-faint); +} + +.new-session-location .new-session-location-workspace .command-palette-description { + font-size: 11px; + letter-spacing: 0.05px; +} + +.new-session-location-workspace .command-palette-row-icon { + color: var(--v2-icon-icon-base); +} + +.new-session-location-workspace .command-palette-row-text > .command-palette-title { + max-width: 60%; + flex-shrink: 0; +} + +.new-session-location .new-session-location-option .command-palette-title { + color: var(--v2-text-text-muted); +} + +.new-session-location-other { + gap: 8px; +} + +.new-session-location-count { + color: var(--v2-text-text-faint); + font-size: 13px; + font-weight: 440; + line-height: var(--line-height-compact); + font-variant-numeric: tabular-nums; +} + .new-session-location-drill { display: flex; - width: 20px; - height: 20px; + width: 16px; + height: 16px; flex-shrink: 0; align-items: center; justify-content: center; @@ -9,40 +152,12 @@ color: var(--v2-icon-icon-faint); } -.command-palette-row[data-active] .new-session-location-drill { - color: var(--v2-icon-icon-base); -} - -.new-session-location-drill:hover { - background: var(--v2-overlay-simple-overlay-hover); -} - -.new-session-location-hints { - display: flex; - flex-shrink: 0; - align-items: center; - gap: 16px; - padding: 8px 14px; - border-top: 0.5px solid var(--v2-border-border-muted); - color: var(--v2-text-text-muted); - font-size: 12px; - font-weight: 440; - line-height: 16px; - letter-spacing: -0.04px; - user-select: none; -} - -.new-session-location-hints > span { - display: flex; - align-items: center; - gap: 6px; -} - -/* The project name truncates before its server tag. */ +/* Keep the name legible while the path gives up space first. */ .new-session-location-name { display: flex; min-width: 0; - max-width: 100%; + max-width: 55%; + flex-shrink: 0; align-items: center; gap: 6px; } @@ -62,45 +177,17 @@ .new-session-location-tag > span { min-width: 0; overflow: hidden; - line-height: 16px; + line-height: var(--line-height-compact); 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; +@media (max-width: 640px) { + .new-session-location .palette-menu-row:has(.command-palette-description) { + min-height: 40px; } - .new-session-location-add > span { - display: none; + .new-session-location-name { + max-width: 100%; } } diff --git a/packages/app/src/new-session/location/dialog.tsx b/packages/app/src/new-session/location/dialog.tsx index 63cee7421fe..667ee173d88 100644 --- a/packages/app/src/new-session/location/dialog.tsx +++ b/packages/app/src/new-session/location/dialog.tsx @@ -11,10 +11,10 @@ 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 { createEventListener } from "@solid-primitives/event-listener" import { getFilename } from "@opencode/util/path" -import { createEffect, createMemo, createResource, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js" +import { createEffect, createMemo, createResource, For, Match, onCleanup, Show, Switch } from "solid-js" import { createStore } from "solid-js/store" import type { PromptProject, PromptProjectController, PromptServer } from "@/new-session/project/selector" import type { NewSessionWorkspaceController } from "@/new-session/workspace/controller" @@ -22,6 +22,7 @@ import { useLanguage } from "@/runtime/i18n/language" import { useServerSDK } from "@/runtime/server/client" import { ServerConnection, useServers } from "@/runtime/server/registry" import { useGlobal } from "@/runtime/server/runtime" +import { useCommand } from "@/shell/commands/command" import { workspaceSelectionDirectories } from "@/workspaces/paths" import "@/shell/commands/dialog.css" import "@/shell/palette-menu.css" @@ -30,13 +31,13 @@ import "./dialog.css" type LocationEntry = | { id: string; type: "project"; project: PromptProject } | { id: string; type: "add" } + | { id: string; type: "other" } | { id: string; type: "server"; server: PromptServer } | { id: string; type: "workspace"; value: string; directory: string } /** - * Opens the new-session location dialog: one project list across every ready server, → on a Git project lists where - * the session can run, and → on "Add project" picks the server when there are several. Search projects passes - * `projectsOnly` for just the project list: no "Add project", no worktrees and no key hints, like the other palettes. + * Opens the new-session location dialog: added projects across ready servers, a separate list of other server projects, + * and the worktree destinations for each Git project. Adding a project picks the server when there are several. */ export function useNewSessionLocationDialog(input: { project: PromptProjectController @@ -45,14 +46,13 @@ export function useNewSessionLocationDialog(input: { }) { const dialog = useDialog() - return (options?: { onWorkspace?: (value: string) => void; projectsOnly?: boolean }) => { + return (options?: { onWorkspace?: (value: string) => void }) => { void dialog.show( () => ( ), input.onDone, @@ -64,10 +64,10 @@ function NewSessionLocationDialog(props: { project: PromptProjectController workspace: NewSessionWorkspaceController onWorkspace?: (value: string) => void - projectsOnly?: boolean }) { const dialog = useDialog() const language = useLanguage() + const command = useCommand() const global = useGlobal() const servers = useServers() const serverSDK = useServerSDK() @@ -76,10 +76,12 @@ function NewSessionLocationDialog(props: { drilled?: PromptProject /** Picking the server to add a project to. */ picking?: boolean + /** Browsing server projects this client has not added. */ + others: boolean query: string /** The projects view to restore when stepping back out of a project or the server step. */ - back?: { query: string; active: string } - }>({ query: "" }) + back?: { query: string; active: string; others: boolean } + }>({ query: "", others: false }) const connection = (project: PromptProject) => project.server ? servers.list.find((item) => ServerConnection.key(item) === project.server!.key) : serverSDK.server @@ -124,18 +126,28 @@ function NewSessionLocationDialog(props: { return !query || values.some((value) => value?.toLowerCase().includes(query)) } - const addEntry: LocationEntry = { id: "add", type: "add" } + const otherCount = createMemo(() => props.project.projects().filter((project) => !project.used).length) - // Remote projects also match their server's name, which their tag shows. "Add project" is shown first, above the - // heading, but navigates last, so the first project stays the opening row and the arrow keys' wrap-around still - // follows the rows on screen. - const projectEntries = createMemo(() => [ - ...props.project - .projects() - .filter((project) => matches(displayName(project), project.worktree, tagged(project.server)?.name)) - .map((project) => ({ id: projectEntryID(project), type: "project" as const, project })), - ...(props.projectsOnly ? [] : [addEntry]), - ]) + const projectEntries = createMemo(() => { + return [ + ...props.project + .projects() + .filter((project) => (store.others ? !project.used : project.used || !!store.query.trim())) + // Remote projects also match their server's name, which their tag shows. + .filter((project) => matches(displayName(project), project.worktree, tagged(project.server)?.name)) + .map((project) => ({ id: projectEntryID(project), type: "project" as const, project })), + ...(!store.others + ? [ + ...(!store.query.trim() && otherCount() ? [{ id: "other", type: "other" as const }] : []), + { id: "add", type: "add" as const }, + ] + : []), + ] + }) + + const firstOther = createMemo( + () => projectEntries().find((entry) => entry.type === "project" && !entry.project.used)?.id, + ) const serverEntries = createMemo(() => props.project @@ -170,16 +182,16 @@ function NewSessionLocationDialog(props: { return projectEntries() } - const drillable = (project: PromptProject) => !props.projectsOnly && !!project.id && project.vcs === "git" - - const grouped = () => entries().filter((entry) => entry !== addEntry) + const drillable = (project: PromptProject) => !!project.id && project.vcs === "git" const title = () => { if (store.drilled) return language.t("session.new.workspace.runIn") if (store.picking) return language.t("session.new.location.servers") - return language.t("home.projects") + if (store.others) return language.t("session.new.location.otherProjects") + + return undefined } const placeholder = () => { @@ -191,6 +203,21 @@ function NewSessionLocationDialog(props: { } const choosesServer = () => props.project.servers().length > 1 + command.register("new-session-location", () => [ + { + id: "project.open", + title: language.t("command.project.open"), + category: language.t("command.category.project"), + keybind: "mod+o", + onSelect: () => choose({ id: "add", type: "add" }), + }, + ]) + createEventListener(document, "keydown", (event) => { + if (event.isComposing || !command.matches("project.open", event)) return + event.preventDefault() + event.stopPropagation() + command.trigger("project.open", "keybind") + }) // Every step opens on its first row; the user's choice holds while it stays in the results. const typeahead = createTypeahead({ @@ -215,7 +242,7 @@ function NewSessionLocationDialog(props: { }) function workspaceTitle(project: PromptProject, value: string) { - if (value === "main") return getFilename(project.worktree) + if (value === "main") return displayName(project) if (value === "create") return language.t("workspace.new") @@ -223,18 +250,33 @@ function NewSessionLocationDialog(props: { } function drill(project: PromptProject) { - setStore({ drilled: project, query: "", back: { query: store.query, active: projectEntryID(project) } }) + setStore({ + drilled: project, + query: "", + back: { query: store.query, active: projectEntryID(project), others: store.others }, + }) inputRef?.focus() } function pick() { - setStore({ picking: true, query: "", back: { query: store.query, active: "add" } }) + setStore({ + drilled: undefined, + picking: true, + query: "", + back: { query: store.query, active: "add", others: store.others }, + }) inputRef?.focus() } function back() { - const active = store.back?.active - setStore({ drilled: undefined, picking: undefined, query: store.back?.query ?? "", back: undefined }) + const active = store.drilled || store.picking ? store.back?.active : "other" + setStore({ + drilled: undefined, + picking: undefined, + others: store.drilled || store.picking ? (store.back?.others ?? false) : false, + query: store.drilled || store.picking ? (store.back?.query ?? "") : "", + back: undefined, + }) typeahead.setActive(active) inputRef?.focus() } @@ -242,6 +284,13 @@ function NewSessionLocationDialog(props: { function choose(entry: LocationEntry | undefined) { if (!entry) return + if (entry.type === "other") { + setStore({ others: true, query: "" }) + inputRef?.focus() + + return + } + if (entry.type === "add" && choosesServer()) { pick() @@ -286,26 +335,36 @@ function NewSessionLocationDialog(props: { const atEnd = target.selectionStart === target.value.length && target.selectionEnd === target.value.length const atStart = target.selectionStart === 0 && target.selectionEnd === 0 const active = activeEntry() + const forward = language.direction() === "rtl" ? "ArrowLeft" : "ArrowRight" + const backward = language.direction() === "rtl" ? "ArrowRight" : "ArrowLeft" - if (event.key === "ArrowRight" && atEnd && active?.type === "project" && drillable(active.project)) { + if (event.key === forward && atEnd && active?.type === "project" && drillable(active.project)) { event.preventDefault() drill(active.project) return } - if (event.key === "ArrowRight" && atEnd && active?.type === "add" && choosesServer()) { + if (event.key === forward && atEnd && active?.type === "add" && choosesServer()) { event.preventDefault() pick() return } + if (active?.type === "other" && atEnd && event.key === forward) { + event.preventDefault() + choose(active) + + return + } + if ( - (store.drilled || store.picking) && - ((event.key === "ArrowLeft" && atStart) || (event.key === "Backspace" && !target.value)) + (store.drilled || store.picking || store.others) && + ((event.key === backward && atStart) || (event.key === "Backspace" && !target.value) || event.key === "Escape") ) { event.preventDefault() + event.stopPropagation() back() return @@ -314,74 +373,8 @@ function NewSessionLocationDialog(props: { typeahead.onKeyDown(event) } - const option = (entry: LocationEntry) => ( - - ) - return ( - + + ) @@ -529,27 +598,11 @@ function DrillButton(props: { label: string; onDrill: () => void }) { props.onDrill() }} > - + ) } -// The negative start margin cancels the chip's padding, so its icon sits where the search icon would and lines up with -// the row icons, leaving the chip the same 6px inset from the field on every side. -function BackChip(props: { label: string; onBack: () => void; children: JSX.Element }) { - return ( - - ) -} - /** The server a project lives on; projects on the current server have none. */ function ServerTag(props: { server?: PromptServer }) { return ( @@ -576,11 +629,7 @@ function WorkspaceRow(props: { title: string; value: string; description: string return ( <>
- +
{props.title} diff --git a/packages/app/src/new-session/screen.tsx b/packages/app/src/new-session/screen.tsx index 2ddd5a342b2..1e5a170886b 100644 --- a/packages/app/src/new-session/screen.tsx +++ b/packages/app/src/new-session/screen.tsx @@ -53,7 +53,7 @@ export default function NewSessionPage(props: { draftId: string }) { restoreFocus: model.restoreFocus, project: { empty: project.empty, - open: () => location({ projectsOnly: true }), + open: () => location(), }, workspace: { enabled: workspace.bar.visible, @@ -67,7 +67,7 @@ export default function NewSessionPage(props: { draftId: string }) { if (!ready) return // Search projects opened this draft; the picker returns focus to the composer when it closes. - if (layout.draft.projectSearch.take()) location({ projectsOnly: true }) + if (layout.draft.projectSearch.take()) location() else model.restoreFocus() }), ) diff --git a/packages/app/src/new-session/view.tsx b/packages/app/src/new-session/view.tsx index 31df80ccd03..c2ad7b2f8bf 100644 --- a/packages/app/src/new-session/view.tsx +++ b/packages/app/src/new-session/view.tsx @@ -2,6 +2,8 @@ import { useDialog } from "@opencode/ui/context/dialog" import { Tooltip } from "@opencode/ui/tooltip" import { Icon } from "@opencode/ui/icon" import { Slash } from "@opencode/ui/slash" +import { Keybind } from "@opencode/ui/keybind" +import { useCommand } from "@/shell/commands/command" import { Show, createMemo, createSignal, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { createResizeObserver } from "@solid-primitives/resize-observer" @@ -221,9 +223,7 @@ function NewSessionTips(props: { selection: ComposerModel["model"]["selection"]; class="flex h-6 min-w-0 items-center rounded-[4px] pl-1.5 text-[13px] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-muted focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-muted focus-visible:outline-none" onClick={open} > - - {language.t("home.providerTip")} - + {language.t("home.providerTip")} @@ -249,6 +249,29 @@ function NewSessionTips(props: { selection: ComposerModel["model"]["selection"]; ) } +// Keeps as many leading segments as fit, then "…/", then the last two segments. The cut always lands on a slash. +function fitPath(value: string, width: number, font: string) { + const separator = value.includes("/") ? "/" : "\\" + const parts = value.split(separator) + const context = document.createElement("canvas").getContext("2d") + + if (!context) return { text: value, minimum: 0 } + context.font = font + const fits = (text: string) => context.measureText(text).width <= width + const first = parts[0] === "" ? 2 : 1 + + const candidates = Array.from({ length: Math.max(0, parts.length - 2 - first) }, (_, index) => + [...parts.slice(0, parts.length - 3 - index), "…", ...parts.slice(-2)].join(separator), + ) + + const minimum = candidates.at(-1) ?? value + + return { + text: !width || fits(value) ? value : (candidates.find(fits) ?? minimum), + minimum: context.measureText(minimum).width, + } +} + /** The composer tray: combined location picker, Git context, and the session's directory. */ function NewSessionLocation(props: { project: PromptProject @@ -260,7 +283,8 @@ function NewSessionLocation(props: { onClick: () => void }) { const language = useLanguage() - const [truncated, setTruncated] = createSignal(false) + const command = useCommand() + const [layout, setLayout] = createStore({ width: 0, font: "" }) const worktree = () => { if (props.workspace === "main") return @@ -276,6 +300,8 @@ function NewSessionLocation(props: { return props.workspace === "main" ? props.root : props.workspace } + const fitted = createMemo(() => fitPath(path() ?? "", layout.width, layout.font)) + return (
@@ -337,22 +363,56 @@ function NewSessionLocation(props: {
{(directory) => ( -
+
- createResizeObserver(element, () => setTruncated(element.scrollWidth > element.clientWidth)) + ref={(row) => + createResizeObserver(row, () => { + const icon = row.querySelector("[data-slot=new-session-location-icon]") + const style = getComputedStyle(row) + + setLayout({ + width: row.clientWidth - (icon?.clientWidth ?? 0) - parseFloat(style.columnGap), + font: style.font, + }) + }) } - data-slot="new-session-location-path" - class="block min-w-0 truncate text-v2-text-text-faint [direction:rtl] [text-align:end]" + class="flex min-w-0 flex-1 items-center justify-end gap-1.5" + dir="ltr" > - {directory()} + + {language.t("session.location.toggle")} + + + } + > + + + + + + {fitted().text} +
diff --git a/packages/app/src/new-session/workspace/selector.tsx b/packages/app/src/new-session/workspace/selector.tsx index 4027fc73550..c1c26f4f7e1 100644 --- a/packages/app/src/new-session/workspace/selector.tsx +++ b/packages/app/src/new-session/workspace/selector.tsx @@ -80,7 +80,7 @@ export function NewSessionBranch(props: { workspace: NewSessionWorkspaceControll when={creating()} fallback={
- + {label()} diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 5153c30c52d..138ad8dbe15 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -60,6 +60,7 @@ export const dict = { "command.usage.view": "View usage", "command.usage.view.description": "See your OpenCode Go allowance and Console budgets", "command.session.location.cycle": "Cycle session location", + "session.location.toggle": "Toggle session location", "command.palette": "Search everything", "command.commands.search": "Search commands", @@ -1128,6 +1129,7 @@ export const dict = { "session.new.workspace.search.placeholder": "Search worktrees", "session.new.workspace.search.empty": "No matching worktrees", "session.new.location.select": "Select", + "session.new.location.otherProjects": "Other projects", "session.new.location.worktrees": "Worktrees", "session.new.location.back": "Back to projects", "session.new.location.servers": "Servers",