feat(app): open the worktree picker from the new session branch (#60263)

This commit is contained in:
Dax
2026-10-29 11:52:38 -04:00
committed by GitHub
Unverified
parent d4e48f54da
commit 3829291126
4 changed files with 115 additions and 19 deletions
@@ -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<string[]>()
@@ -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(
() => (
<NewSessionLocationDialog
project={input.project}
workspace={input.workspace}
drilled={options?.project}
onWorkspace={options?.onWorkspace}
/>
),
@@ -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
+16 -8
View File
@@ -76,6 +76,14 @@ export function NewSessionView(props: {
const [wordmark, setWordmark] = createStore<WordmarkState>({ 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={<NewSessionBranch workspace={props.workspace} onDone={props.composer.restoreFocus} />}
onboarding={onboardingReady() && !onboarding.used && props.workspace.bar.visible()}
onClick={() =>
props.location({
onWorkspace: (value) => {
if (value !== "main") setOnboarding("used", true)
},
})
branch={
<NewSessionBranch
workspace={props.workspace}
onDone={props.composer.restoreFocus}
onWorktree={project().id && project().vcs === "git" ? () => location(project()) : undefined}
/>
}
onboarding={onboardingReady() && !onboarding.used && props.workspace.bar.visible()}
onClick={() => location()}
/>
)}
</Show>
@@ -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
<Show when={props.workspace.bar.branch()}>
<Tooltip
placement="top"
value={label()}
disabled={!state.truncated}
openDelay={hint() ? 800 : undefined}
value={hint() ? language.t("session.location.worktree") : label()}
disabled={!state.truncated && !hint()}
class="flex min-w-0 max-w-[220px]"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<Show
when={creating()}
fallback={
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 ps-1.5 pe-2 text-v2-text-text-faint">
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span ref={observe} dir="auto" class="min-w-0 truncate">
{label()}
</span>
</div>
<Show
when={props.onWorktree}
fallback={
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 ps-1.5 pe-2 text-v2-text-text-faint">
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span ref={observe} dir="auto" class="min-w-0 truncate">
{label()}
</span>
</div>
}
>
<button
type="button"
aria-haspopup="dialog"
aria-description={language.t("session.location.worktree")}
class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 rounded-sm ps-1.5 pe-2 text-v2-text-text-faint 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"
onClick={() => props.onWorktree?.()}
>
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span ref={observe} dir="auto" class="min-w-0 truncate">
{label()}
</span>
</button>
</Show>
}
>
<Popover