fix(app): restore branch context in new session

This commit is contained in:
usrnk1
2026-10-20 13:25:17 +02:00
Unverified
parent c748648064
commit a4d8041b06
6 changed files with 329 additions and 11 deletions
@@ -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<string[]>()
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" } } })
+29 -5
View File
@@ -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<string[]>
// 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),
+11 -5
View File
@@ -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={<NewSessionBranch workspace={props.workspace} onDone={props.composer.restoreFocus} />}
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 (
<div class="flex h-9 min-w-0 items-center gap-3 ps-2 pe-2 text-[13px] font-[440] leading-[var(--line-height-compact)] tracking-[-0.04px]">
<div class="flex min-w-0 max-w-[60%] shrink-0 @max-[560px]:max-w-full">
<div class="flex min-h-9 min-w-0 items-center gap-3 ps-2 pe-2 text-[13px] font-[440] leading-[var(--line-height-compact)] tracking-[-0.04px]">
<div class="flex min-w-0 max-w-full flex-wrap items-center gap-x-3 gap-y-1 py-1.5">
<Tooltip
appearance={props.onboarding ? "large" : undefined}
placement="top"
@@ -265,7 +268,9 @@ function NewSessionLocation(props: {
<Show when={worktree()}>
{(name) => (
<>
<Icon name="chevron-right" class="-mx-1 shrink-0 text-v2-icon-icon-faint" />
<span class="shrink-0 ps-1 pe-0.5 text-[11px] font-medium text-v2-text-text-faint" aria-hidden="true">
/
</span>
<span class="min-w-12 shrink-[1000] truncate">{name()}</span>
</>
)}
@@ -279,6 +284,7 @@ function NewSessionLocation(props: {
</Show>
</button>
</Tooltip>
{props.branch}
</div>
<Show when={path()}>
{(directory) => (
@@ -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(),
},
}
}
@@ -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 (
<Show when={props.workspace.bar.branch()}>
<Tooltip
placement="top"
value={label()}
disabled={!state.truncated}
class="flex min-w-0 max-w-[220px]"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<Show
when={creating()}
fallback={
<div class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 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>
}
>
<Menu
placement="bottom-start"
gutter={4}
onOpenChange={(open) => {
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()
}}
>
<Menu.Trigger class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-[var(--line-height-compact)] tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-background-bg-layer-03 hover:text-v2-text-text-muted focus-visible:bg-v2-background-bg-layer-03 focus-visible:text-v2-text-text-muted focus-visible:outline-none data-[expanded]:bg-v2-background-bg-layer-03 data-[expanded]:text-v2-text-text-muted">
<Icon name="branch-out" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span ref={observe} dir="auto" class="min-w-0 truncate">
{label()}
</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none"
onOpenAutoFocus={(event) => {
event.preventDefault()
// Kobalte schedules list autofocus after the focus scope opens.
setTimeout(() => requestAnimationFrame(() => input?.focus({ preventScroll: true })))
}}
>
<div class="flex h-7 shrink-0 items-center gap-2 rounded-sm ps-3 pe-1 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" />
<input
ref={input}
value={state.search}
placeholder={language.t("session.new.workspace.branch.search.placeholder")}
aria-label={language.t("session.new.workspace.branch.search.placeholder")}
autocomplete="off"
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
onInput={(event) => {
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()
}}
/>
<Show when={state.search.trim()}>
<button
type="button"
class="flex size-5 shrink-0 items-center justify-center rounded-sm text-v2-icon-icon-faint hover:text-v2-icon-icon-base focus-visible:text-v2-icon-icon-base"
aria-label={language.t("common.clear")}
onPointerDown={(event) => event.preventDefault()}
onClick={() => {
setState("search", "")
props.workspace.branches.search("")
input?.focus()
}}
>
<Icon name="circle-xmark" />
</button>
</Show>
</div>
<div class="max-h-[224px] overflow-y-auto">
<Show
when={!props.workspace.branches.failed()}
fallback={
<Menu.Item closeOnSelect={false} onSelect={() => void props.workspace.branches.retry()}>
{language.t("common.retry")}
</Menu.Item>
}
>
<Show
when={props.workspace.branches.items().length > 0}
fallback={
<div class="px-3 py-2 text-v2-text-text-faint">
{language.t(props.workspace.branches.loading() ? "common.loading" : "palette.empty")}
</div>
}
>
<Menu.RadioGroup value={props.workspace.bar.branch()}>
<For each={props.workspace.branches.items()}>
{(branch) => (
<Menu.RadioItem
value={branch}
closeOnSelect
class="h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
onSelect={() => setState("pending", branch)}
>
<span dir="auto" class="min-w-0 truncate leading-5">
{branch}
</span>
</Menu.RadioItem>
)}
</For>
</Menu.RadioGroup>
</Show>
</Show>
</div>
</Menu.Content>
</Menu.Portal>
</Menu>
</Show>
</Tooltip>
</Show>
)
}
+2
View File
@@ -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...",