diff --git a/packages/app/e2e/regression/new-session.spec.ts b/packages/app/e2e/regression/new-session.spec.ts index f4dbba44f0d..d69d13418d3 100644 --- a/packages/app/e2e/regression/new-session.spec.ts +++ b/packages/app/e2e/regression/new-session.spec.ts @@ -191,6 +191,59 @@ test("the combined location picker shows checkout branches and chooses the base .toEqual([expect.objectContaining({ body: expect.objectContaining({ text: "Use the selected base" }) })]) }) +test("the checkout branch opens the current project's worktree step", async ({ page }) => { + const existing = "C:/OpenCode/BranchWorktrees/.worktrees/quiet-river" + + const view = await openDraft(page, { + name: "BranchWorktrees", + vcs: (directory) => ({ current: directory === existing ? "quiet-river-branch" : "dev", default: "main" }), + worktrees: () => [{ directory: "C:/OpenCode/BranchWorktrees" }, { directory: existing, strategy: "git" }], + }) + + const screen = page.locator('[data-component="new-session"]') + const branch = screen.getByRole("button", { name: "dev", exact: true }) + const dialog = page.getByRole("dialog") + const search = dialog.getByRole("combobox", { name: "Search worktrees", exact: true }) + const options = dialog.getByRole("option") + const back = dialog.getByRole("button", { name: "Back to projects", exact: true }) + + await branch.click() + await expect(search).toBeFocused() + await expect(back).toHaveText("Run session in") + await expect(options.locator(".command-palette-title")).toHaveText([ + "BranchWorktrees", + "New worktree", + "quiet-river", + ]) + await expect(options.nth(0)).toHaveAttribute("aria-selected", "true") + + // Stepping back lands on the projects step with the drilled project active, as after drilling in with →. + await search.press("Backspace") + const projects = dialog.getByRole("combobox", { name: "Search projects", exact: true }) + await expect(projects).toBeFocused() + await expect(back).toHaveCount(0) + await expect(options.filter({ hasText: "BranchWorktrees" })).toHaveAttribute("aria-selected", "true") + await projects.press("Escape") + await expect(dialog).toHaveCount(0) + await expect(view.editor).toBeFocused() + + // Escape also steps back first, then closes. + await branch.focus() + await page.keyboard.press("Enter") + await expect(search).toBeFocused() + await search.press("Escape") + await expect(projects).toBeFocused() + await projects.press("Escape") + await expect(dialog).toHaveCount(0) + + await branch.click() + await options.filter({ hasText: "quiet-river" }).click() + await expect(dialog).toHaveCount(0) + await expect(newSessionLocation(page)).toContainText("quiet-river") + await expect(screen.getByRole("button", { name: "quiet-river-branch", exact: true })).toBeVisible() + await expect(view.editor).toBeFocused() +}) + test("a pending branch picker can be dismissed before its results arrive", async ({ page }) => { const listed = Promise.withResolvers() diff --git a/packages/app/src/new-session/location/dialog.tsx b/packages/app/src/new-session/location/dialog.tsx index 667ee173d88..dae5f102279 100644 --- a/packages/app/src/new-session/location/dialog.tsx +++ b/packages/app/src/new-session/location/dialog.tsx @@ -46,12 +46,14 @@ export function useNewSessionLocationDialog(input: { }) { const dialog = useDialog() - return (options?: { onWorkspace?: (value: string) => void }) => { + /** Pass `project` to open straight on that project's worktree step; stepping back still reaches the projects. */ + return (options?: { project?: PromptProject; onWorkspace?: (value: string) => void }) => { void dialog.show( () => ( ), @@ -63,6 +65,7 @@ export function useNewSessionLocationDialog(input: { function NewSessionLocationDialog(props: { project: PromptProjectController workspace: NewSessionWorkspaceController + drilled?: PromptProject onWorkspace?: (value: string) => void }) { const dialog = useDialog() @@ -81,7 +84,12 @@ function NewSessionLocationDialog(props: { query: string /** The projects view to restore when stepping back out of a project or the server step. */ back?: { query: string; active: string; others: boolean } - }>({ query: "", others: false }) + }>({ + query: "", + others: false, + drilled: props.drilled, + back: props.drilled ? { query: "", active: projectEntryID(props.drilled), others: false } : undefined, + }) const connection = (project: PromptProject) => project.server ? servers.list.find((item) => ServerConnection.key(item) === project.server!.key) : serverSDK.server diff --git a/packages/app/src/new-session/view.tsx b/packages/app/src/new-session/view.tsx index 8d68e6670cd..2c2f931bfa9 100644 --- a/packages/app/src/new-session/view.tsx +++ b/packages/app/src/new-session/view.tsx @@ -76,6 +76,14 @@ export function NewSessionView(props: { const [wordmark, setWordmark] = createStore({ mode: "flat", game: false }) + const location = (project?: PromptProject) => + props.location({ + project, + onWorkspace: (value) => { + if (value !== "main") setOnboarding("used", true) + }, + }) + // Imperative handles for the brick breaker; they are not reactive state. const handles: BrickBreakerHandles = {} let panel!: HTMLDivElement @@ -115,15 +123,15 @@ export function NewSessionView(props: { project={project()} workspace={props.workspace.bar.visible() ? props.workspace.selection.value() : "main"} root={props.workspace.project.root()} - branch={} - onboarding={onboardingReady() && !onboarding.used && props.workspace.bar.visible()} - onClick={() => - props.location({ - onWorkspace: (value) => { - if (value !== "main") setOnboarding("used", true) - }, - }) + branch={ + location(project()) : undefined} + /> } + onboarding={onboardingReady() && !onboarding.used && props.workspace.bar.visible()} + onClick={() => location()} /> )} diff --git a/packages/app/src/new-session/workspace/selector.tsx b/packages/app/src/new-session/workspace/selector.tsx index c1c26f4f7e1..91bd340a46a 100644 --- a/packages/app/src/new-session/workspace/selector.tsx +++ b/packages/app/src/new-session/workspace/selector.tsx @@ -9,7 +9,12 @@ import { Typeahead } from "@opencode/ui/typeahead" import { useLanguage } from "@/runtime/i18n/language" import type { NewSessionWorkspaceController } from "./controller" -export function NewSessionBranch(props: { workspace: NewSessionWorkspaceController; onDone: () => void }) { +export function NewSessionBranch(props: { + workspace: NewSessionWorkspaceController + onDone: () => void + /** Opens the project's worktree picker; the checkout branch is static text without it. */ + onWorktree?: () => void +}) { const language = useLanguage() const [state, setState] = createStore({ open: false, truncated: false }) @@ -64,6 +69,9 @@ export function NewSessionBranch(props: { workspace: NewSessionWorkspaceControll ? language.t("session.new.workspace.fromBranch", { branch: props.workspace.bar.branch() ?? "" }) : props.workspace.bar.branch() + // A truncated name keeps its full-text tooltip; otherwise the clickable checkout branch explains its action. + const hint = () => !creating() && !!props.onWorktree && !state.truncated + const observe = (element: HTMLSpanElement) => createResizeObserver(element, () => setState("truncated", element.scrollWidth > element.clientWidth)) @@ -71,20 +79,39 @@ export function NewSessionBranch(props: { workspace: NewSessionWorkspaceControll - - - {label()} - - + + + + {label()} + + + } + > + + } >