From a4d8041b06ef7a93a3253d8388ffce29d2de16e1 Mon Sep 17 00:00:00 2001 From: usrnk1 <7547651+usrnk1@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:25:17 +0200 Subject: [PATCH] fix(app): restore branch context in new session --- .../app/e2e/regression/new-session.spec.ts | 84 +++++++++ packages/app/e2e/utils/mock-server.ts | 34 +++- packages/app/src/new-session/view.tsx | 16 +- .../src/new-session/workspace/controller.ts | 45 ++++- .../src/new-session/workspace/selector.tsx | 159 ++++++++++++++++++ packages/app/src/runtime/i18n/en.ts | 2 + 6 files changed, 329 insertions(+), 11 deletions(-) create mode 100644 packages/app/src/new-session/workspace/selector.tsx diff --git a/packages/app/e2e/regression/new-session.spec.ts b/packages/app/e2e/regression/new-session.spec.ts index f478b142893..7ac8f59b72d 100644 --- a/packages/app/e2e/regression/new-session.spec.ts +++ b/packages/app/e2e/regression/new-session.spec.ts @@ -51,6 +51,90 @@ test("the new session screen does not show session details", async ({ page }) => await expect(page.getByRole("button", { name: "Session details", exact: true })).toHaveCount(0) }) +test("the combined location picker shows checkout branches and chooses the base for a new worktree", async ({ + page, +}) => { + const listed = Promise.withResolvers() + const searches: { directory: string; search?: string; limit?: number }[] = [] + const existing = "C:/OpenCode/BranchContext/.worktrees/stellar-orchid" + + const view = await openWorktreeDraft(page, { + name: "BranchContext", + project: { name: "OpenCode" }, + vcs: (directory) => ({ + current: directory === existing ? "model-picker-dialog" : "timeline-quotes", + default: "main", + }), + worktrees: () => [{ directory: "C:/OpenCode/BranchContext" }, { directory: existing, strategy: "git" }], + branches: (input) => { + searches.push(input) + + return listed.promise + }, + }) + + const screen = page.locator('[data-component="new-session"]') + const base = screen.getByRole("button", { name: "from timeline-quotes", exact: true }) + await expect(base).toBeVisible() + expect(searches).toEqual([]) + await base.click() + const search = page.getByRole("textbox", { name: "Search branches", exact: true }) + await expect(search).toBeFocused() + await expect(page.getByText("Loading", { exact: true })).toBeVisible() + await expect(newSessionLocation(page)).toBeVisible() + listed.resolve(["timeline-quotes", "release/base", "origin/dev"]) + await expect(page.getByRole("menuitemradio", { name: "release/base", exact: true })).toBeVisible() + await search.fill("release") + await expect(page.getByRole("menuitemradio")).toHaveText(["release/base"]) + await page.getByRole("menuitemradio", { name: "release/base", exact: true }).click() + const selected = screen.getByRole("button", { name: "from release/base", exact: true }) + await expect(selected).toBeVisible() + await expect(view.editor).toBeFocused() + expect(searches).toEqual([ + { directory: view.directory, search: "", limit: 50 }, + { directory: view.directory, search: "release", limit: 50 }, + ]) + + await page.setViewportSize({ width: 390, height: 844 }) + await expect + .poll(async () => { + const location = await newSessionLocation(page).boundingBox() + const branch = await selected.boundingBox() + + return !!location && !!branch && branch.y >= location.y + location.height + }) + .toBe(true) + + const select = async (name: RegExp) => { + await newSessionLocation(page).click() + await page.getByRole("textbox", { name: "Search projects", exact: true }).press("ArrowRight") + await page.getByRole("option", { name }).click() + await expect(view.editor).toBeFocused() + } + + 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 expect(screen.getByText("timeline-quotes", { exact: true })).toBeVisible() + await expect(screen.getByRole("button", { name: /from / })).toHaveCount(0) + await select(/^New worktree/) + await expect(base).toBeVisible() + await base.click() + await expect(search).toBeFocused() + await page.getByRole("menuitemradio", { name: "origin/dev", exact: true }).click() + await expect(screen.getByRole("button", { name: "from origin/dev", exact: true })).toBeVisible() + await view.editor.fill("Use the selected base") + await submit(page).click() + await expect + .poll(() => view.worktreeRequests.map((request) => request.body)) + .toEqual([expect.objectContaining({ from: view.directory, branch: "origin/dev" })]) + view.worktree.resolve({ status: 200, json: { directory: existing } }) + await expect + .poll(() => view.prompts) + .toEqual([expect.objectContaining({ body: expect.objectContaining({ text: "Use the selected base" }) })]) +}) + test("the dark new session panel exposes no lighter background at its rounded corners", async ({ page }) => { await page.setViewportSize({ width: 935, height: 522 }) await openDraft(page, { name: "PanelCorner", seed: { theme: { id: "oc-2", scheme: "dark" } } }) diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index b31aa57aa41..e0086d6511e 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -82,7 +82,8 @@ export interface MockServerConfig { items: SessionMessageInfo[] cursor?: string } - vcs?: { current: string; default: string } + vcs?: { current: string; default: string } | ((directory: string) => { current: string; default: string }) + branches?: (input: { directory: string; search?: string; limit?: number }) => string[] | Promise // Initializes the mock project's VCS; without a handler this mutation answers 501. onVcsInit?: (input: { directory: string; provider?: string }) => void vcsDiff?: unknown[] | ((input: { mode?: string }) => unknown[]) @@ -1018,10 +1019,14 @@ function mockHandlers( }), ), formRequests: () => Effect.succeed({ location: location(config), data: resolve(config.forms ?? []) }), - vcs: () => + vcs: (ctx) => Effect.succeed({ - location: location(config), - data: { branch: config.vcs ?? { current: "main", default: "main" } }, + location: location(config, requestDirectory(config, ctx.request)), + data: { + branch: Predicate.isFunction(config.vcs) + ? config.vcs(requestDirectory(config, ctx.request)) + : (config.vcs ?? { current: "main", default: "main" }), + }, }), vcsInit: (ctx) => { if (!config.onVcsInit) return unsupported("initialize VCS", "onVcsInit") @@ -1044,7 +1049,26 @@ function mockHandlers( }).pipe(Effect.andThen(noContent)) }, vcsStatus: () => Effect.succeed({ location: location(config), data: [] }), - vcsBranches: () => Effect.succeed({ location: location(config), data: ["main"] }), + vcsBranches: (ctx) => { + const query = new URL(ctx.request.url, "http://localhost").searchParams + const search = query.get("search") ?? "" + const limit = Number(query.get("limit") ?? 50) + + return Effect.promise(() => + Promise.resolve( + config.branches?.({ + directory: requestDirectory(config, ctx.request), + search, + limit, + }) ?? ["main"], + ), + ).pipe( + Effect.map((items) => ({ + location: location(config, requestDirectory(config, ctx.request)), + data: items.filter((branch) => branch.toLowerCase().includes(search.toLowerCase())).slice(0, limit), + })), + ) + }, vcsDiff: (ctx) => Effect.sync(() => ({ location: location(config), diff --git a/packages/app/src/new-session/view.tsx b/packages/app/src/new-session/view.tsx index 5706f80f9f3..aa0b321c747 100644 --- a/packages/app/src/new-session/view.tsx +++ b/packages/app/src/new-session/view.tsx @@ -1,7 +1,7 @@ import { useDialog } from "@opencode/ui/context/dialog" import { Tooltip } from "@opencode/ui/tooltip" import { Icon } from "@opencode/ui/icon" -import { Show, createMemo, createSignal } from "solid-js" +import { Show, createMemo, createSignal, type JSX } from "solid-js" import { createResizeObserver } from "@solid-primitives/resize-observer" import { getFilename } from "@opencode/util/path" import { @@ -29,6 +29,7 @@ import { Persist, persisted } from "@/runtime/persistence/storage" import { Persistence } from "@/runtime/persistence/schema" import { useSettings } from "@/settings/model" import type { NewSessionWorkspaceController } from "./workspace/controller" +import { NewSessionBranch } from "./workspace/selector" import { NewSessionPinBoard } from "./pin-board" import { NewSessionWordmark } from "./wordmark" @@ -85,6 +86,7 @@ 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({ @@ -198,12 +200,13 @@ function NewSessionTips(props: { selection: ComposerModel["model"]["selection"]; ) } -/** The tray under the Composer: the location picker on the left and the session's directory on the right. */ +/** The composer tray: combined location picker, Git context, and the session's directory. */ function NewSessionLocation(props: { project: PromptProject /** "main", "create", or a worktree directory. */ workspace: string root: string + branch?: JSX.Element onboarding: boolean onClick: () => void }) { @@ -225,8 +228,8 @@ function NewSessionLocation(props: { } return ( -
-
+
+
{(name) => ( <> - + {name()} )} @@ -279,6 +284,7 @@ function NewSessionLocation(props: { + {props.branch}
{(directory) => ( diff --git a/packages/app/src/new-session/workspace/controller.ts b/packages/app/src/new-session/workspace/controller.ts index 30a51968a2f..f840ede3618 100644 --- a/packages/app/src/new-session/workspace/controller.ts +++ b/packages/app/src/new-session/workspace/controller.ts @@ -1,3 +1,4 @@ +import { debounce } from "@solid-primitives/scheduled" import { createEffect, createMemo, createResource, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { useWorkspaceLocation } from "@/workspaces/location" @@ -61,9 +62,14 @@ export function createNewSessionWorkspaceController(input: { const tabs = useTabs() const [state, setState] = createStore({ + branchesOpen: false, + search: "", existing: undefined as { projectID: string; directory: string } | undefined, }) + const searchBranches = debounce((search: string) => setState("search", search.trim()), 100) + onCleanup(searchBranches.clear) + const currentProject = createMemo(() => { const projectID = data.location.info({ directory: sdk().directory })?.project.id const current = projectID ? data.project.get(projectID) : undefined @@ -170,6 +176,27 @@ export function createNewSessionWorkspaceController(input: { ) const projectRoot = createMemo(() => currentProject()?.worktree ?? sdk().directory) + const branchDirectory = () => (value() === "create" ? projectRoot() : sdk().directory) + + const [branches, branchActions] = createResource( + () => + state.branchesOpen && value() === "create" && visible() + ? { directory: projectRoot(), search: state.search } + : undefined, + async (source) => + serverSDK.api.vcs.branch + .list({ location: { directory: source.directory }, search: source.search, limit: 50 }) + .then((response) => ({ ...source, items: response.data, failed: false })) + .catch(() => ({ ...source, items: [], failed: true })), + ) + + // Branch search must not suspend the draft or expose another project's previous results. + const branchResults = () => { + if (branches.state !== "ready" && branches.state !== "refreshing") return + const loaded = branches.latest + + return loaded?.directory === projectRoot() && loaded.search === state.search ? loaded : undefined + } createEffect(() => { void Promise.all([ @@ -178,6 +205,10 @@ export function createNewSessionWorkspaceController(input: { data.location.vcs.sync({ directory: sdk().directory }), ]).catch(() => undefined) }) + createEffect(() => { + if (value() !== "create" || sameDirectory(projectRoot(), sdk().directory)) return + void data.location.vcs.sync({ directory: projectRoot() }).catch(() => undefined) + }) // Only the selected worktree feeds the branch label. Syncing every worktree in the inventory boots // each one on the server, which then emits `agent.updated` and makes the client run the full // catalog fan-out for every directory. @@ -194,7 +225,7 @@ export function createNewSessionWorkspaceController(input: { const branch = createMemo(() => resolveNewSessionBranch({ worktree: value(), - directory: sdk().directory, + directory: branchDirectory(), createBranch: input.selectedBranch(), worktreeBranch: (worktree) => data.location.vcs.info({ directory: worktree })?.branch.current, }), @@ -249,6 +280,7 @@ export function createNewSessionWorkspaceController(input: { }, remember, set: select, + branch: (branch: string) => input.setSelectedBranch(branch), cycle: () => select(cycleNewSessionWorktree({ current: value(), existing: existing() })), }, project: { @@ -261,6 +293,17 @@ export function createNewSessionWorkspaceController(input: { visible, branch, }, + branches: { + items: () => branchResults()?.items ?? [], + loading: () => branches.loading, + failed: () => branchResults()?.failed ?? false, + search: searchBranches, + open: (open: boolean) => { + searchBranches.clear() + setState({ branchesOpen: open, search: "" }) + }, + retry: () => branchActions.refetch(), + }, } } diff --git a/packages/app/src/new-session/workspace/selector.tsx b/packages/app/src/new-session/workspace/selector.tsx new file mode 100644 index 00000000000..74d4492698e --- /dev/null +++ b/packages/app/src/new-session/workspace/selector.tsx @@ -0,0 +1,159 @@ +import { For, Show } from "solid-js" +import { createStore } from "solid-js/store" +import { createResizeObserver } from "@solid-primitives/resize-observer" +import { Icon } from "@opencode/ui/icon" +import { Menu } from "@opencode/ui/menu" +import { Tooltip } from "@opencode/ui/tooltip" +import { useLanguage } from "@/runtime/i18n/language" +import type { NewSessionWorkspaceController } from "./controller" + +export function NewSessionBranch(props: { workspace: NewSessionWorkspaceController; onDone: () => void }) { + const language = useLanguage() + + const [state, setState] = createStore<{ search: string; truncated: boolean; pending?: string }>({ + search: "", + truncated: false, + }) + + let input: HTMLInputElement | undefined + const creating = () => props.workspace.selection.value() === "create" + + const label = () => + creating() + ? language.t("session.new.workspace.fromBranch", { branch: props.workspace.bar.branch() ?? "" }) + : props.workspace.bar.branch() + + const observe = (element: HTMLSpanElement) => + createResizeObserver(element, () => setState("truncated", element.scrollWidth > element.clientWidth)) + + return ( + + + + + + {label()} + +
+ } + > + { + props.workspace.branches.open(open) + + if (open) { + setState({ search: "", pending: undefined }) + + return + } + + if (state.pending) props.workspace.selection.branch(state.pending) + setState("pending", undefined) + props.onDone() + }} + > + + + + {label()} + + + + + { + event.preventDefault() + // Kobalte schedules list autofocus after the focus scope opens. + setTimeout(() => requestAnimationFrame(() => input?.focus({ preventScroll: true }))) + }} + > +
+ + { + setState("search", event.currentTarget.value) + props.workspace.branches.search(event.currentTarget.value) + }} + onKeyDown={(event) => { + if (["Escape", "ArrowDown", "ArrowUp", "Enter"].includes(event.key)) return + event.stopPropagation() + }} + /> + + + +
+
+ void props.workspace.branches.retry()}> + {language.t("common.retry")} + + } + > + 0} + fallback={ +
+ {language.t(props.workspace.branches.loading() ? "common.loading" : "palette.empty")} +
+ } + > + + + {(branch) => ( + setState("pending", branch)} + > + + {branch} + + + )} + + +
+
+
+
+
+
+ + + + ) +} diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index c90756d4166..815e4afcda0 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -587,6 +587,8 @@ export const dict = { "session.new.workspace.triggerLocal": "Local", "session.new.workspace.local": "Local repository", "session.new.workspace.existingLabel": "Worktree", + "session.new.workspace.fromBranch": "from {{branch}}", + "session.new.workspace.branch.search.placeholder": "Search branches", "session.new.git.none": "No Git", "session.search.placeholder": "Find...",