diff --git a/packages/app/e2e/global-setup.ts b/packages/app/e2e/global-setup.ts index b71da5ba0..03df64010 100644 --- a/packages/app/e2e/global-setup.ts +++ b/packages/app/e2e/global-setup.ts @@ -328,6 +328,12 @@ interface PairingDaemonClient { async function createFakeEditorBin(): Promise { const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-editor-bin-")); + let realGhPath = ""; + try { + realGhPath = execSync("which gh").toString().trim(); + } catch { + // The local PR fixture below remains usable without a system gh binary. + } const fakeEditorSource = `#!/usr/bin/env node const fs = require("fs"); @@ -349,6 +355,70 @@ if (recordPath) { await chmod(editorPath, 0o755); } + const fakeGhPath = path.join(binDir, "gh"); + const fakeGhSource = `#!/usr/bin/env node +const { spawnSync } = require("child_process"); +const args = process.argv.slice(2); +const fixtureRemote = "https://github.com/paseo-e2e/local-fixture.git"; +const origin = spawnSync("git", ["config", "--get", "remote.origin.url"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"] +}).stdout?.trim(); + +if (origin === fixtureRemote) { + const command = args.slice(0, 2).join(" "); + if (command === "auth status") process.exit(0); + if (command === "repo view") { + process.stdout.write(JSON.stringify({ owner: { login: "paseo-e2e" }, name: "local-fixture", parent: null })); + process.exit(0); + } + if (command === "issue list") { + process.stdout.write("[]"); + process.exit(0); + } + if (command === "pr list" || command === "pr view") { + const pr = { + number: 1, + title: "Use pasted PR as start ref", + url: "https://github.com/paseo-e2e/local-fixture/pull/1", + state: "OPEN", + body: null, + labels: [], + baseRefName: "main", + headRefName: "pr-branch-1", + updatedAt: "2026-01-01T00:00:00Z" + }; + process.stdout.write(JSON.stringify(command === "pr list" ? [pr] : pr)); + process.exit(0); + } + if (command === "api graphql" && args.some((arg) => arg.includes("PullRequestCheckoutTarget"))) { + process.stdout.write(JSON.stringify({ + data: { repository: { pullRequest: { + number: 1, + baseRefName: "main", + headRefName: "pr-branch-1", + isCrossRepository: false, + headRepositoryOwner: { login: "paseo-e2e" }, + headRepository: { + sshUrl: "git@github.com:paseo-e2e/local-fixture.git", + url: fixtureRemote + } + } } } + })); + process.exit(0); + } + process.stderr.write("Unsupported local GitHub fixture command: " + args.join(" ") + "\\n"); + process.exit(1); +} + +const realGhPath = ${JSON.stringify(realGhPath)}; +if (!realGhPath) process.exit(127); +const result = spawnSync(realGhPath, args, { stdio: "inherit" }); +process.exit(result.status ?? 1); +`; + await writeFile(fakeGhPath, fakeGhSource); + await chmod(fakeGhPath, 0o755); + return binDir; } diff --git a/packages/app/e2e/helpers/github-fixtures.ts b/packages/app/e2e/helpers/github-fixtures.ts index e3694b671..484c65a3c 100644 --- a/packages/app/e2e/helpers/github-fixtures.ts +++ b/packages/app/e2e/helpers/github-fixtures.ts @@ -1,5 +1,5 @@ import { execFileSync, execSync } from "node:child_process"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import path from "node:path"; export function hasGithubAuth(): boolean { @@ -59,6 +59,12 @@ export interface GhDefaultBranchClone { cleanup(): Promise; } +export interface LocalGhPrFixture { + pr: GhPrFixture; + mainCheckout: GhDefaultBranchClone; + cleanup(): Promise; +} + function gh(args: string[], opts?: { cwd?: string }): string { return execFileSync("gh", args, { cwd: opts?.cwd, @@ -281,3 +287,57 @@ export async function cloneGithubRepoDefaultBranchOnly( }, }; } + +export async function createLocalGithubPrFixture(): Promise { + const fixtureRoot = await mkdtemp(path.join("/tmp", "paseo-e2e-local-github-pr-")); + const basePath = path.join(fixtureRoot, "base"); + const remotePath = path.join(fixtureRoot, "remote.git"); + const checkoutPath = path.join(fixtureRoot, "main-only"); + const githubUrl = "https://github.com/paseo-e2e/local-fixture.git"; + await mkdir(basePath); + + git(["init", "-b", "main"], basePath); + git(["config", "user.email", "e2e@paseo.test"], basePath); + git(["config", "user.name", "Paseo E2E"], basePath); + git(["config", "commit.gpgsign", "false"], basePath); + await writeFile(path.join(basePath, "README.md"), "# Local GitHub fixture\n"); + git(["add", "README.md"], basePath); + git(["commit", "-m", "Initial commit"], basePath); + git(["checkout", "-b", "pr-branch-1"], basePath); + await writeFile(path.join(basePath, "pr-1.txt"), "PR 1\n"); + git(["add", "pr-1.txt"], basePath); + git(["commit", "-m", "Add PR 1"], basePath); + git(["checkout", "main"], basePath); + + execFileSync("git", ["clone", "--quiet", "--bare", basePath, remotePath], { + stdio: ["ignore", "pipe", "pipe"], + }); + git(["update-ref", "refs/pull/1/head", "refs/heads/pr-branch-1"], remotePath); + execFileSync( + "git", + ["clone", "--quiet", "--single-branch", "--branch", "main", remotePath, checkoutPath], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + git(["remote", "set-url", "origin", githubUrl], checkoutPath); + git(["config", `url.${remotePath}.insteadOf`, githubUrl], checkoutPath); + git(["config", "user.email", "e2e@paseo.test"], checkoutPath); + git(["config", "user.name", "Paseo E2E"], checkoutPath); + git(["config", "commit.gpgsign", "false"], checkoutPath); + + return { + pr: { + number: 1, + title: "Use pasted PR as start ref", + url: "https://github.com/paseo-e2e/local-fixture/pull/1", + branch: "pr-branch-1", + localPath: basePath, + }, + mainCheckout: { + path: checkoutPath, + cleanup: async () => {}, + }, + cleanup: async () => { + await rm(fixtureRoot, { recursive: true, force: true }); + }, + }; +} diff --git a/packages/app/e2e/helpers/new-workspace.ts b/packages/app/e2e/helpers/new-workspace.ts index e8ef24e32..9e330541b 100644 --- a/packages/app/e2e/helpers/new-workspace.ts +++ b/packages/app/e2e/helpers/new-workspace.ts @@ -1,4 +1,4 @@ -import { expect, type Page } from "@playwright/test"; +import { expect, type BrowserContext, type Page } from "@playwright/test"; import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client"; import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes"; import { connectDaemonClient } from "./daemon-client-loader"; @@ -290,6 +290,13 @@ export async function selectBranchInPicker(page: Page, name: string): Promise { + const searchInput = page.getByPlaceholder("Search branches and PRs"); + await expect(searchInput).toBeVisible({ timeout: 30_000 }); + await searchInput.fill(name); + await selectBranchInPicker(page, name); +} + export async function selectGitHubPrInPicker(page: Page, number: number): Promise { const prRow = page.getByTestId(`new-workspace-ref-picker-pr-${number}`); await expect(prRow).toBeVisible({ timeout: 30_000 }); @@ -351,6 +358,18 @@ export async function expectComposerGithubAttachmentPill( await expect(pills.first()).toContainText(input.title); } +export async function pasteGithubPrUrl( + page: Page, + context: BrowserContext, + url: string, +): Promise { + const composer = page.getByRole("textbox", { name: "Message agent..." }); + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + await page.evaluate((value) => navigator.clipboard.writeText(value), url); + await composer.focus(); + await page.keyboard.press("Control+V"); +} + export async function assertNewWorkspaceSidebarAndHeader( page: Page, input: { diff --git a/packages/app/e2e/new-workspace.spec.ts b/packages/app/e2e/new-workspace.spec.ts index f8a93b31b..cd5cd8608 100644 --- a/packages/app/e2e/new-workspace.spec.ts +++ b/packages/app/e2e/new-workspace.spec.ts @@ -17,11 +17,14 @@ import { expectPickerOpen, expectPickerSelected, expectStartingRefPickerTriggerPr, + fillNewWorkspaceDraft, openGlobalNewWorkspaceComposer, openBranchPicker, openNewWorkspaceComposer, openProjectViaDaemon, openStartingRefPicker, + pasteGithubPrUrl, + searchAndSelectBranchInPicker, selectBranchInPicker, selectGitHubPrInPicker, selectPickerOptionByKeyboard, @@ -30,9 +33,11 @@ import { } from "./helpers/new-workspace"; import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace"; import { + createLocalGithubPrFixture, cloneGithubRepoDefaultBranchOnly, createTempGithubRepo, hasGithubAuth, + type LocalGhPrFixture, } from "./helpers/github-fixtures"; import { getServerId } from "./helpers/server-id"; import { getE2EDaemonPort } from "./helpers/daemon-port"; @@ -191,6 +196,7 @@ test.describe("New workspace flow", () => { let client: Awaited>; const localWorkspaceIds = new Set(); const createdWorktreeDirectories = new Set(); + const localGithubFixtures = new Set(); test.describe.configure({ timeout: 240_000 }); @@ -212,6 +218,13 @@ test.describe("New workspace flow", () => { await client?.close().catch(() => undefined); }); + test.afterAll(async () => { + for (const fixture of localGithubFixtures) { + await fixture.cleanup(); + } + localGithubFixtures.clear(); + }); + test("adds a project from the selected empty host", async ({ page }) => { const repo = await createTempGitRepo("new-workspace-project-picker-"); const primaryServerId = getServerId(); @@ -807,6 +820,100 @@ test.describe("New workspace flow", () => { } }); + test("pasted GitHub PR replaces a selected branch and creates its worktree", async ({ + page, + context, + }) => { + const fixture = await createLocalGithubPrFixture(); + localGithubFixtures.add(fixture); + const { pr, mainCheckout } = fixture; + + const openedProject = await openProjectViaDaemon(client, mainCheckout.path); + localWorkspaceIds.add(openedProject.workspaceId); + + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openNewWorkspaceComposer(page, { + projectKey: openedProject.projectKey, + projectDisplayName: openedProject.projectDisplayName, + }); + await selectWorkspaceIsolation(page, "worktree"); + await openStartingRefPicker(page); + await selectBranchInPicker(page, "main"); + + await pasteGithubPrUrl(page, context, pr.url); + + await expect(page.getByTestId("workspace-create-submit")).toBeDisabled(); + await expectComposerGithubAttachmentPill(page, { + number: pr.number, + title: pr.title, + }); + await expectStartingRefPickerTriggerPr(page, { + number: pr.number, + title: pr.title, + headRef: pr.branch, + }); + + await submitNewWorkspaceWithoutPrompt(page); + + const worktree = await assertNewWorkspaceSidebarAndHeader(page, { + serverId: getServerId(), + client, + previousWorkspaceId: openedProject.workspaceId, + projectDisplayName: openedProject.projectDisplayName, + }); + createdWorktreeDirectories.add(worktree.workspaceDirectory); + + const branchInfo = await readWorktreeBranchInfo({ + worktreePath: worktree.workspaceDirectory, + }); + expect(branchInfo.currentBranch).toBe(pr.branch); + expect(existsSync(path.join(worktree.workspaceDirectory, "pr-1.txt"))).toBe(true); + }); + + test("branches remain searchable after a pasted PR and determine the created worktree", async ({ + page, + context, + }) => { + const fixture = await createLocalGithubPrFixture(); + localGithubFixtures.add(fixture); + const { pr, mainCheckout } = fixture; + + const openedProject = await openProjectViaDaemon(client, mainCheckout.path); + localWorkspaceIds.add(openedProject.workspaceId); + + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openNewWorkspaceComposer(page, { + projectKey: openedProject.projectKey, + projectDisplayName: openedProject.projectDisplayName, + }); + await selectWorkspaceIsolation(page, "worktree"); + await pasteGithubPrUrl(page, context, pr.url); + await expectStartingRefPickerTriggerPr(page, { + number: pr.number, + title: pr.title, + headRef: pr.branch, + }); + + await openStartingRefPicker(page); + await searchAndSelectBranchInPicker(page, "main"); + await expectPickerSelected(page, "main"); + await fillNewWorkspaceDraft(page, `${pr.url}\nKeep this checkout on main`); + await expectPickerSelected(page, "main"); + await submitNewWorkspaceWithoutPrompt(page); + + const worktree = await assertNewWorkspaceSidebarAndHeader(page, { + serverId: getServerId(), + client, + previousWorkspaceId: openedProject.workspaceId, + projectDisplayName: openedProject.projectDisplayName, + }); + createdWorktreeDirectories.add(worktree.workspaceDirectory); + + expect(existsSync(path.join(worktree.workspaceDirectory, "pr-1.txt"))).toBe(false); + }); + test("selected GitHub PR creates the worktree from the PR head even when the head branch is not fetched", async ({ page, }) => { diff --git a/packages/app/src/composer/github/auto-attach.test.tsx b/packages/app/src/composer/github/auto-attach.test.tsx index 4c196c5a3..8aee13f80 100644 --- a/packages/app/src/composer/github/auto-attach.test.tsx +++ b/packages/app/src/composer/github/auto-attach.test.tsx @@ -48,7 +48,9 @@ interface SearchCall { interface HarnessInput { initialAttachments?: UserComposerAttachment[]; + initialCwd?: string; initialText?: string; + onPullRequestDetected?: () => void; remote?: string | null; } @@ -72,6 +74,14 @@ function createSearchClient(items: ForgeSearchItem[]): ForgeSearchClient & { cal }; } +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + function createWrapper() { const queryClient = new QueryClient({ defaultOptions: { @@ -87,6 +97,8 @@ function createWrapper() { function useHarness(client: ForgeSearchClient, input: HarnessInput = {}) { const [text, setText] = useState(input.initialText ?? ""); + const [searchClient, setSearchClient] = useState(client); + const [workingDirectory, setWorkingDirectory] = useState(input.initialCwd ?? cwd); const [attachments, setAttachments] = useState( input.initialAttachments ?? [], ); @@ -94,18 +106,22 @@ function useHarness(client: ForgeSearchClient, input: HarnessInput = {}) { text, remoteUrl: input.remote ?? remoteUrl, attachments, - client, + client: searchClient, isConnected: true, serverId: "server-1", - cwd, + cwd: workingDirectory, setAttachments, + onPullRequestDetected: input.onPullRequestDetected, }); return { text, setText, + setSearchClient, + setWorkingDirectory, attachments, setAttachments, + isResolving: autoAttach.isResolving, markGithubAttachmentRemoved: autoAttach.markGithubAttachmentRemoved, }; } @@ -121,14 +137,20 @@ describe("useComposerGithubAutoAttach", () => { it("adds a matching pasted GitHub PR URL as a composer attachment", async () => { vi.useFakeTimers(); const client = createSearchClient([pr101]); - const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() }); + const onPullRequestDetected = vi.fn(); + const { result } = renderHook(() => useHarness(client, { onPullRequestDetected }), { + wrapper: createWrapper(), + }); act(() => { result.current.setText("Please review https://github.com/acme/paseo/pull/101"); }); + expect(result.current.isResolving).toBe(true); + expect(onPullRequestDetected).toHaveBeenCalledTimes(1); await flushDebounce(); expect(result.current.attachments).toEqual([{ kind: "forge_change_request", item: pr101 }]); + expect(result.current.isResolving).toBe(false); expect(client.calls).toEqual([{ cwd, query: "101", limit: 20 }]); vi.useRealTimers(); }); @@ -208,4 +230,115 @@ describe("useComposerGithubAutoAttach", () => { ]); vi.useRealTimers(); }); + + it("stays resolving while overlapping lookups share a ref", async () => { + vi.useFakeTimers(); + const firstLookup = deferred(); + const secondLookup = deferred(); + const client: ForgeSearchClient = { + searchForge: vi + .fn() + .mockReturnValueOnce(firstLookup.promise) + .mockReturnValueOnce(secondLookup.promise), + }; + const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() }); + + act(() => { + result.current.setText( + "Refs https://github.com/acme/paseo/pull/101 and https://github.com/acme/paseo/pull/202", + ); + }); + await flushDebounce(); + + act(() => { + result.current.setText("Still https://github.com/acme/paseo/pull/202"); + }); + await flushDebounce(); + + await act(async () => { + firstLookup.resolve(githubPayload([], "search-101")); + await Promise.resolve(); + }); + expect(result.current.isResolving).toBe(true); + + await act(async () => { + secondLookup.resolve(githubPayload([], "search-202")); + await Promise.resolve(); + }); + expect(result.current.isResolving).toBe(false); + vi.useRealTimers(); + }); + + it("stops resolving when an in-flight PR URL is removed", async () => { + vi.useFakeTimers(); + const lookup = deferred(); + const client: ForgeSearchClient = { + searchForge: vi.fn().mockReturnValue(lookup.promise), + }; + const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() }); + + act(() => { + result.current.setText("Review https://github.com/acme/paseo/pull/101"); + }); + await flushDebounce(); + act(() => { + result.current.setText(""); + }); + + expect(result.current.isResolving).toBe(false); + vi.useRealTimers(); + }); + + it("ignores a lookup that finishes after the target changes", async () => { + vi.useFakeTimers(); + const lookup = deferred(); + const client: ForgeSearchClient = { + searchForge: vi.fn().mockReturnValue(lookup.promise), + }; + const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() }); + + act(() => { + result.current.setText("Review https://github.com/acme/paseo/pull/101"); + }); + await flushDebounce(); + + act(() => { + result.current.setWorkingDirectory("/other-repo"); + }); + await flushDebounce(); + await act(async () => { + lookup.resolve(githubPayload([pr101], "search-101")); + await Promise.resolve(); + }); + + expect(result.current.attachments).toEqual([]); + expect(client.searchForge).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); + + it("accepts a lookup after the transport client is replaced for the same target", async () => { + vi.useFakeTimers(); + const lookup = deferred(); + const firstClient: ForgeSearchClient = { + searchForge: vi.fn().mockReturnValue(lookup.promise), + }; + const replacementClient = createSearchClient([pr101]); + const { result } = renderHook(() => useHarness(firstClient), { wrapper: createWrapper() }); + + act(() => { + result.current.setText("Review https://github.com/acme/paseo/pull/101"); + }); + await flushDebounce(); + act(() => { + result.current.setSearchClient(replacementClient); + }); + await act(async () => { + lookup.resolve(githubPayload([pr101], "search-101")); + await Promise.resolve(); + }); + + expect(result.current.attachments).toEqual([{ kind: "forge_change_request", item: pr101 }]); + expect(replacementClient.calls).toEqual([]); + vi.useRealTimers(); + }); }); diff --git a/packages/app/src/composer/github/auto-attach.ts b/packages/app/src/composer/github/auto-attach.ts index 5873b438d..d73e1dc69 100644 --- a/packages/app/src/composer/github/auto-attach.ts +++ b/packages/app/src/composer/github/auto-attach.ts @@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, + useState, type Dispatch, type RefObject, type SetStateAction, @@ -26,9 +27,12 @@ interface ComposerGithubAutoAttachInput { cwd: string; supportsForgeSearch?: boolean; setAttachments: Dispatch>; + onPullRequestDetected?: () => void; + onPullRequestAdded?: (item: ForgeSearchItem) => void; } interface ComposerGithubAutoAttachResult { + isResolving: boolean; markGithubAttachmentRemoved: (attachment: ComposerAttachment | undefined) => void; } @@ -39,10 +43,24 @@ export function useComposerGithubAutoAttach( const latestRef = useRef(params); const removedRefKeysRef = useRef(new Set()); const pendingRefKeysRef = useRef(new Set()); + const presentPullRequestKeysRef = useRef(new Set()); + const previousTargetRef = useRef({ serverId: params.serverId, cwd: params.cwd }); + const [resolvingRefCounts, setResolvingRefCounts] = useState>( + () => new Map(), + ); latestRef.current = params; useEffect(() => { + suppressRefsCarriedAcrossTargets({ + params: latestRef.current, + previousTargetRef, + removedRefKeys: removedRefKeysRef.current, + }); + notifyNewPullRequestRefs({ + params: latestRef.current, + presentPullRequestKeysRef, + }); const refs = refsReadyForLookup({ params: latestRef.current, removedRefKeys: removedRefKeysRef.current, @@ -52,6 +70,15 @@ export function useComposerGithubAutoAttach( return; } + const refKeys = refs.map(githubRefKey); + setResolvingRefCounts((current) => addKeys(current, refKeys)); + let resolvingReleased = false; + const releaseResolving = () => { + if (resolvingReleased) return; + resolvingReleased = true; + clearResolvingKeys(setResolvingRefCounts, refKeys); + }; + const timerId = setTimeout(() => { void attachRefs({ refs, @@ -59,11 +86,12 @@ export function useComposerGithubAutoAttach( latestRef, removedRefKeys: removedRefKeysRef.current, pendingRefKeys: pendingRefKeysRef.current, - }); + }).finally(releaseResolving); }, AUTO_ATTACH_DEBOUNCE_MS); return () => { clearTimeout(timerId); + releaseResolving(); }; }, [ params.text, @@ -86,12 +114,84 @@ export function useComposerGithubAutoAttach( return useMemo( () => ({ + isResolving: resolvingRefCounts.size > 0, markGithubAttachmentRemoved, }), - [markGithubAttachmentRemoved], + [markGithubAttachmentRemoved, resolvingRefCounts.size], ); } +function suppressRefsCarriedAcrossTargets({ + params, + previousTargetRef, + removedRefKeys, +}: { + params: ComposerGithubAutoAttachInput; + previousTargetRef: RefObject<{ serverId: string; cwd: string }>; + removedRefKeys: Set; +}): void { + const previous = previousTargetRef.current; + const targetChanged = + previous.cwd.trim().length > 0 && + params.cwd.trim().length > 0 && + (previous.serverId !== params.serverId || previous.cwd !== params.cwd); + previousTargetRef.current = { serverId: params.serverId, cwd: params.cwd }; + if (!targetChanged) return; + + for (const ref of extractGithubRefs(params.text, params.remoteUrl)) { + removedRefKeys.add(githubRefKey(ref)); + } +} + +function notifyNewPullRequestRefs({ + params, + presentPullRequestKeysRef, +}: { + params: ComposerGithubAutoAttachInput; + presentPullRequestKeysRef: RefObject>; +}): void { + const currentKeys = new Set( + extractGithubRefs(params.text, params.remoteUrl) + .filter((ref) => ref.kind === "pull") + .map(githubRefKey), + ); + for (const key of currentKeys) { + if (!presentPullRequestKeysRef.current.has(key)) { + params.onPullRequestDetected?.(); + } + } + presentPullRequestKeysRef.current = currentKeys; +} + +function addKeys( + current: ReadonlyMap, + keys: readonly string[], +): ReadonlyMap { + const nextCounts = new Map(current); + for (const key of keys) nextCounts.set(key, (nextCounts.get(key) ?? 0) + 1); + return nextCounts; +} + +function removeKeys( + current: ReadonlyMap, + keys: readonly string[], +): ReadonlyMap { + const next = new Map(current); + for (const key of keys) { + const count = next.get(key) ?? 0; + if (count <= 1) next.delete(key); + else next.set(key, count - 1); + } + return next; +} + +function clearResolvingKeys( + setResolvingRefCounts: Dispatch>>, + keys: readonly string[], +): void { + setResolvingRefCounts((current) => removeKeys(current, keys)); +} + async function attachRefs({ refs, queryClient, @@ -142,16 +242,28 @@ async function attachRef({ return; } const item = search.items.find((candidate) => githubItemMatchesRef(candidate, ref)); - if (!item || removedRefKeys.has(key) || !isRefStillPresent(ref, latestRef.current)) { + const current = latestRef.current; + if ( + !item || + removedRefKeys.has(key) || + !isSameLookupTarget(snapshot, current) || + !isRefStillPresent(ref, current) + ) { return; } - latestRef.current.setAttachments((current) => { - if (removedRefKeys.has(key) || isAttachmentSelectedForGithubItem(current, item)) { - return current; + if (isAttachmentSelectedForGithubItem(current.attachments, item)) { + return; + } + current.setAttachments((attachments) => { + if (removedRefKeys.has(key) || isAttachmentSelectedForGithubItem(attachments, item)) { + return attachments; } - return toggleGithubAttachment(current, item); + return toggleGithubAttachment(attachments, item); }); + if (item.kind === "change_request") { + current.onPullRequestAdded?.(item); + } } function refsReadyForLookup({ @@ -212,6 +324,17 @@ function isRefStillPresent(ref: GithubRef, params: ComposerGithubAutoAttachInput ); } +function isSameLookupTarget( + initial: ComposerGithubAutoAttachInput, + current: ComposerGithubAutoAttachInput, +): boolean { + return ( + initial.serverId === current.serverId && + initial.cwd === current.cwd && + initial.remoteUrl === current.remoteUrl + ); +} + function hasGithubAttachment(attachments: UserComposerAttachment[], ref: GithubRef): boolean { return attachments.some((attachment) => attachmentKey(attachment) === githubRefKey(ref)); } diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index c2e3e5dbf..216342744 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -768,6 +768,8 @@ interface ComposerProps { submitIcon?: "arrow" | "return"; /** Externally controlled loading state. When true, disables the submit button. */ isSubmitLoading?: boolean; + /** When true, waits for pasted GitHub links to resolve before enabling submit. */ + waitForGithubAutoAttachOnSubmit?: boolean; submitBehavior?: "clear" | "preserve-and-lock"; /** When true, blurs the input immediately when submitting. */ blurOnSubmit?: boolean; @@ -777,6 +779,8 @@ interface ComposerProps { attachmentScopeKeys?: readonly string[]; onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void; onChangeAttachments: (updater: AttachmentListUpdater) => void; + onGithubPrDetected?: () => void; + onGithubPrAutoAttach?: (item: ForgeSearchItem) => void; cwd: string; clearDraft: (lifecycle: "sent" | "abandoned") => void; /** When true, auto-focuses the text input on web. */ @@ -981,6 +985,7 @@ export function Composer({ submitButtonTestID, submitIcon = "arrow", isSubmitLoading = false, + waitForGithubAutoAttachOnSubmit = false, submitBehavior = "clear", blurOnSubmit = false, value, @@ -989,6 +994,8 @@ export function Composer({ attachmentScopeKeys = EMPTY_ATTACHMENT_SCOPE_KEYS, onOpenWorkspaceAttachment, onChangeAttachments, + onGithubPrDetected, + onGithubPrAutoAttach, cwd, clearDraft, autoFocus = false, @@ -1070,6 +1077,8 @@ export function Composer({ cwd, supportsForgeSearch, setAttachments: setSelectedAttachments, + onPullRequestDetected: onGithubPrDetected, + onPullRequestAdded: onGithubPrAutoAttach, }); const [cursorIndex, setCursorIndex] = useState(0); const [isProcessing, setIsProcessing] = useState(false); @@ -1949,7 +1958,11 @@ export function Composer({ const messageInputContainerRef = useRef(null); - const isSubmitBusy = isProcessing || isSubmitLoading || isUploadingFile; + const isSubmitBusy = + isProcessing || + isSubmitLoading || + isUploadingFile || + (waitForGithubAutoAttachOnSubmit && githubAutoAttach.isResolving); // Disable drops while submitting/uploading: the submit path clears and restores attachments, // so a drop in that window would be lost or land on a locked draft. `disabled` hides the diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index f9516c371..a46d3861f 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -985,9 +985,6 @@ export const ar: TranslationResources = { refPicker: { startingRef: "بدء المرجع", chooseStart: "اختر من أين تبدأ", - checkoutHint: "تحقق من {{noun}} {{numberPrefix}}{{number}}؟", - checkoutPr: "تحقق من {{noun}} {{numberPrefix}}{{number}}", - dismissCheckoutHint: "تجاهل تلميح الخروج {{noun}} {{numberPrefix}}{{number}}", intoBase: "إلى {{baseRef}}", searching: "جارٍ البحث...", noMatchingRefs: "لا توجد مراجع مطابقة.", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 412892d42..1518531c5 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -995,9 +995,6 @@ export const en = { refPicker: { startingRef: "Starting ref", chooseStart: "Choose where to start from", - checkoutHint: "Check out {{noun}} {{numberPrefix}}{{number}}?", - checkoutPr: "Check out {{noun}} {{numberPrefix}}{{number}}", - dismissCheckoutHint: "Dismiss {{noun}} {{numberPrefix}}{{number}} checkout hint", intoBase: "into {{baseRef}}", searching: "Searching...", noMatchingRefs: "No matching refs.", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index b2c333630..e433b4e0e 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1016,9 +1016,6 @@ export const es: TranslationResources = { refPicker: { startingRef: "Árbitro inicial", chooseStart: "Elige por dónde empezar", - checkoutHint: "¿Mira {{noun}} {{numberPrefix}}{{number}}?", - checkoutPr: "Echa un vistazo a {{noun}} {{numberPrefix}}{{number}}", - dismissCheckoutHint: "Descartar la sugerencia de pago de {{noun}} {{numberPrefix}}{{number}}", intoBase: "en {{baseRef}}", searching: "Búsqueda...", noMatchingRefs: "No hay árbitros coincidentes.", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index aa0c82f83..8cf561f67 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1015,9 +1015,6 @@ export const fr: TranslationResources = { refPicker: { startingRef: "Réf de départ", chooseStart: "Choisissez par où commencer", - checkoutHint: "Découvrez {{noun}} {{numberPrefix}}{{number}} ?", - checkoutPr: "Découvrez {{noun}} {{numberPrefix}}{{number}}", - dismissCheckoutHint: "Ignorer l'indice de paiement {{noun}} {{numberPrefix}}{{number}}", intoBase: "dans {{baseRef}}", searching: "Recherche...", noMatchingRefs: "Aucune référence correspondante.", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 491987dcb..651199194 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -996,9 +996,6 @@ export const ja: TranslationResources = { refPicker: { startingRef: "開始Ref", chooseStart: "開始点を選択", - checkoutHint: "{{noun}} {{numberPrefix}}{{number}}をチェックアウトしますか?", - checkoutPr: "{{noun}} {{numberPrefix}}{{number}}をチェックアウト", - dismissCheckoutHint: "{{noun}} {{numberPrefix}}{{number}}のチェックアウトヒントを閉じる", intoBase: "{{baseRef}}に", searching: "検索中...", noMatchingRefs: "一致するRefがありません。", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 0831981fb..01a6eb324 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1007,9 +1007,6 @@ export const ptBR: TranslationResources = { refPicker: { startingRef: "Ref inicial", chooseStart: "Escolha de onde começar", - checkoutHint: "Fazer checkout da {{noun}} {{numberPrefix}}{{number}}?", - checkoutPr: "Fazer checkout da {{noun}} {{numberPrefix}}{{number}}", - dismissCheckoutHint: "Dispensar dica de checkout da {{noun}} {{numberPrefix}}{{number}}", intoBase: "em {{baseRef}}", searching: "Buscando...", noMatchingRefs: "Nenhuma ref correspondente.", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 98115495d..08db24c1a 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1007,10 +1007,6 @@ export const ru: TranslationResources = { refPicker: { startingRef: "Начальная ссылка", chooseStart: "Выберите, с чего начать", - checkoutHint: "Проверьте {{noun}} {{numberPrefix}}{{number}}?", - checkoutPr: "Проверьте {{noun}} {{numberPrefix}}{{number}}", - dismissCheckoutHint: - "Отклонить подсказку по оформлению заказа {{noun}} {{numberPrefix}}{{number}}", intoBase: "в {{baseRef}}", searching: "Идет поиск...", noMatchingRefs: "Нет подходящих ссылок.", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 38e57badb..1b52b0333 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -974,9 +974,6 @@ export const zhCN: TranslationResources = { refPicker: { startingRef: "起始 ref", chooseStart: "选择起始位置", - checkoutHint: "Checkout {{noun}} {{numberPrefix}}{{number}}?", - checkoutPr: "Checkout {{noun}} {{numberPrefix}}{{number}}", - dismissCheckoutHint: "忽略 {{noun}} {{numberPrefix}}{{number}} checkout 提示", intoBase: "进入 {{baseRef}}", searching: "正在搜索...", noMatchingRefs: "没有匹配的 refs。", diff --git a/packages/app/src/screens/new-workspace-picker-state.test.ts b/packages/app/src/screens/new-workspace-picker-state.test.ts index ce9489e0b..08f5e8723 100644 --- a/packages/app/src/screens/new-workspace-picker-state.test.ts +++ b/packages/app/src/screens/new-workspace-picker-state.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from "vitest"; import type { UserComposerAttachment } from "@/attachments/types"; import { clearPickerPrAttachmentForTargetChange, - findCheckoutHintPrAttachment, + initialPickerSelectionState, + reducePickerSelection, syncPickerPrAttachment, } from "./new-workspace-picker-state"; import type { ForgeSearchItem } from "@getpaseo/protocol/messages"; @@ -28,21 +29,28 @@ function prAttachment( return { kind: "github_pr", item, ...(owner ? { owner } : {}) }; } -function issueAttachment(number: number): UserComposerAttachment { +function forgePrAttachment( + item: ForgeSearchItem, +): Extract { + return { kind: "forge_change_request", item }; +} + +function makeIssueItem(number: number): ForgeSearchItem { return { - kind: "github_issue", - item: { - kind: "issue", - number, - title: `Issue ${number}`, - url: `https://example.com/issues/${number}`, - state: "open", - body: null, - labels: [], - }, + kind: "issue", + number, + title: `Issue ${number}`, + url: `https://example.com/issues/${number}`, + state: "open", + body: null, + labels: [], }; } +function issueAttachment(number: number): UserComposerAttachment { + return { kind: "github_issue", item: makeIssueItem(number) }; +} + describe("syncPickerPrAttachment", () => { it("selects a PR when no previous picker PR is set", () => { const pr = makePrItem(202, "Refactor picker"); @@ -91,6 +99,15 @@ describe("syncPickerPrAttachment", () => { expect(result).toEqual([prAttachment(pr)]); }); + it("does not duplicate a generalized PR attachment", () => { + const pr = makePrItem(202, "Refactor picker"); + const result = syncPickerPrAttachment({ + attachments: [forgePrAttachment(pr)], + item: { kind: "github-pr", item: pr }, + }); + expect(result).toEqual([forgePrAttachment(pr)]); + }); + it("clears a persisted picker selection without removing user-added attachments", () => { const pickerPr = prAttachment(makePrItem(202, "Picker PR"), "new-workspace-picker"); const manuallyAttachedPr = prAttachment(makePrItem(303, "Manual PR")); @@ -119,67 +136,82 @@ describe("clearPickerPrAttachmentForTargetChange", () => { ).toBe(attachments); }); - it("clears only the picker-owned PR when the target changes", () => { + it("clears all PR attachments when the target changes", () => { const pickerPr = prAttachment(makePrItem(202, "Picker PR"), "new-workspace-picker"); const manualPr = prAttachment(makePrItem(303, "Manual PR")); + const forgePr = forgePrAttachment(makePrItem(404, "Forge PR")); + const issue = issueAttachment(44); expect( clearPickerPrAttachmentForTargetChange({ - attachments: [pickerPr, manualPr], + attachments: [issue, pickerPr, manualPr, forgePr], currentTargetId: "server-a", nextTargetId: "server-b", }), - ).toEqual([manualPr]); + ).toEqual([issue]); }); }); -describe("findCheckoutHintPrAttachment", () => { - it("returns the first attached PR that is not selected or dismissed", () => { - const first = prAttachment(makePrItem(101, "A")); - const second = prAttachment(makePrItem(202, "B")); +describe("reducePickerSelection", () => { + it("selects a PR that was newly detected and added", () => { + const item = { kind: "github-pr" as const, item: makePrItem(101, "A") }; + const detected = reducePickerSelection(initialPickerSelectionState, { type: "pr-detected" }); - expect( - findCheckoutHintPrAttachment({ - attachments: [issueAttachment(44), first, second], - selectedItem: null, - dismissedPrNumbers: new Set(), - }), - ).toBe(first); + expect(reducePickerSelection(detected, { type: "pr-added", item })).toEqual({ + selectedItem: item, + allowAutoPrSelection: false, + }); }); - it("skips the selected PR and offers the next attached PR", () => { - const selected = prAttachment(makePrItem(101, "A")); - const next = prAttachment(makePrItem(202, "B")); + it("keeps the first PR selected when one edit adds multiple PRs", () => { + const detected = reducePickerSelection(initialPickerSelectionState, { type: "pr-detected" }); + const first = reducePickerSelection(detected, { + type: "pr-added", + item: { kind: "github-pr", item: makePrItem(101, "A") }, + }); expect( - findCheckoutHintPrAttachment({ - attachments: [selected, next], - selectedItem: { kind: "github-pr", item: selected.item }, - dismissedPrNumbers: new Set(), + reducePickerSelection(first, { + type: "pr-added", + item: { kind: "github-pr", item: makePrItem(202, "B") }, }), - ).toBe(next); + ).toEqual(first); }); - it("skips dismissed PRs and ignores issues", () => { - const dismissed = prAttachment(makePrItem(101, "A")); - const next = prAttachment(makePrItem(202, "B")); + it("keeps a branch selected after a pending PR is added", () => { + const detected = reducePickerSelection(initialPickerSelectionState, { type: "pr-detected" }); + const branchSelected = reducePickerSelection(detected, { + type: "picker-selected", + item: { kind: "branch", name: "main" }, + }); expect( - findCheckoutHintPrAttachment({ - attachments: [issueAttachment(44), dismissed, next], - selectedItem: null, - dismissedPrNumbers: new Set([101]), + reducePickerSelection(branchSelected, { + type: "pr-added", + item: { kind: "github-pr", item: makePrItem(101, "A") }, }), - ).toBe(next); + ).toEqual(branchSelected); }); - it("returns null when only issues qualify", () => { + it("does not derive checkout selection from an existing attachment", () => { expect( - findCheckoutHintPrAttachment({ - attachments: [issueAttachment(44)], - selectedItem: null, - dismissedPrNumbers: new Set(), + reducePickerSelection(initialPickerSelectionState, { + type: "pr-added", + item: { kind: "github-pr", item: makePrItem(101, "A") }, }), - ).toBeNull(); + ).toEqual(initialPickerSelectionState); + }); + + it("lets a newly detected PR replace an earlier explicit branch", () => { + const branchSelected = reducePickerSelection(initialPickerSelectionState, { + type: "picker-selected", + item: { kind: "branch", name: "main" }, + }); + const detected = reducePickerSelection(branchSelected, { type: "pr-detected" }); + const pr = { kind: "github-pr" as const, item: makePrItem(101, "A") }; + + expect(reducePickerSelection(detected, { type: "pr-added", item: pr }).selectedItem).toEqual( + pr, + ); }); }); diff --git a/packages/app/src/screens/new-workspace-picker-state.ts b/packages/app/src/screens/new-workspace-picker-state.ts index 2caa8c588..53e80bc35 100644 --- a/packages/app/src/screens/new-workspace-picker-state.ts +++ b/packages/app/src/screens/new-workspace-picker-state.ts @@ -4,6 +4,46 @@ import { } from "@/attachments/types"; import type { PickerItem } from "./new-workspace-picker-item"; +export interface PickerSelectionState { + selectedItem: PickerItem | null; + allowAutoPrSelection: boolean; +} + +export type PickerSelectionEvent = + | { type: "pr-detected" } + | { type: "pr-added"; item: Extract } + | { type: "picker-selected"; item: PickerItem } + | { type: "target-changed" }; + +export const initialPickerSelectionState: PickerSelectionState = { + selectedItem: null, + allowAutoPrSelection: false, +}; + +export function reducePickerSelection( + state: PickerSelectionState, + event: PickerSelectionEvent, +): PickerSelectionState { + switch (event.type) { + case "pr-detected": + return { ...state, allowAutoPrSelection: true }; + case "pr-added": + return state.allowAutoPrSelection + ? { selectedItem: event.item, allowAutoPrSelection: false } + : state; + case "picker-selected": + return { selectedItem: event.item, allowAutoPrSelection: false }; + case "target-changed": + return initialPickerSelectionState; + } +} + +function isPrAttachment( + attachment: UserComposerAttachment, +): attachment is Extract { + return attachment.kind === "forge_change_request" || attachment.kind === "github_pr"; +} + function isPickerOwnedPrAttachment(attachment: UserComposerAttachment): attachment is Extract< UserComposerAttachment, { kind: "github_pr" } @@ -28,8 +68,7 @@ export function syncPickerPrAttachment(input: { if (input.item?.kind === "github-pr") { const selectedPr = input.item.item; const hasExistingPrAttachment = nextAttachments.some( - (attachment) => - attachment.kind === "github_pr" && attachment.item.number === selectedPr.number, + (attachment) => isPrAttachment(attachment) && attachment.item.number === selectedPr.number, ); if (!hasExistingPrAttachment) { return [ @@ -54,24 +93,5 @@ export function clearPickerPrAttachmentForTargetChange(input: { if (input.currentTargetId === input.nextTargetId) { return input.attachments; } - return syncPickerPrAttachment({ attachments: input.attachments, item: null }); -} - -export function findCheckoutHintPrAttachment(input: { - attachments: ReadonlyArray; - selectedItem: PickerItem | null; - dismissedPrNumbers: ReadonlySet; -}): Extract | null { - const selectedPrNumber = - input.selectedItem?.kind === "github-pr" ? input.selectedItem.item.number : null; - - for (const attachment of input.attachments) { - if (attachment.kind !== "github_pr") continue; - const prNumber = attachment.item.number; - if (prNumber === selectedPrNumber) continue; - if (input.dismissedPrNumbers.has(prNumber)) continue; - return attachment; - } - - return null; + return input.attachments.filter((attachment) => !isPrAttachment(attachment)); } diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index fdc4d9905..a2a2fea7d 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; import type { ReactElement, RefObject } from "react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; @@ -9,15 +9,7 @@ import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles" import { useSafeAreaInsets } from "react-native-safe-area-context"; import { createNameId } from "mnemonic-id"; import { useQuery } from "@tanstack/react-query"; -import { - Check, - ChevronDown, - Folder, - FolderPlus, - GitBranch, - GitPullRequest, - X, -} from "lucide-react-native"; +import { ChevronDown, Folder, FolderPlus, GitBranch, GitPullRequest } from "lucide-react-native"; import { Composer } from "@/composer"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control"; @@ -80,7 +72,7 @@ import { } from "@/projects/host-projects"; import { useProjectIconDataByProjectKey } from "@/projects/project-icons"; import { ICON_SIZE, type Theme } from "@/styles/theme"; -import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types"; +import type { ComposerAttachment } from "@/attachments/types"; import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; import type { MessagePayload } from "@/composer/types"; import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages"; @@ -99,7 +91,8 @@ import { } from "./new-workspace-picker-item"; import { clearPickerPrAttachmentForTargetChange, - findCheckoutHintPrAttachment, + initialPickerSelectionState, + reducePickerSelection, syncPickerPrAttachment, } from "./new-workspace-picker-state"; import { @@ -186,10 +179,6 @@ interface PickerOptionData { itemById: Map; } -interface PickerSelection { - item: PickerItem; -} - const BRANCH_OPTION_PREFIX = "branch:"; const PR_OPTION_PREFIX = "github-pr:"; const PROJECT_ICON_FALLBACK_FONT_SIZE = 10; @@ -335,50 +324,6 @@ function ProjectPickerTrigger({ ); } -function CheckoutHintBadge({ - label, - acceptLabel, - dismissLabel, - onAccept, - onDismiss, - iconColor, - iconSize, -}: { - label: string; - acceptLabel: string; - dismissLabel: string; - onAccept: () => void; - onDismiss: () => void; - iconColor: string; - iconSize: number; -}) { - return ( - - - {label} - - - - - - - - - ); -} - function PickerOptionItem({ testID, label, @@ -646,14 +591,6 @@ function formatPrLabel(item: Pick return `${presentation.numberPrefix}${item.number} ${item.title}`; } -function getCheckoutHintPresentation(item: ForgeSearchItem) { - const presentation = getForgePresentation(item.forge ?? "github"); - return { - noun: presentation.changeRequestAbbrev, - numberPrefix: presentation.numberPrefix, - }; -} - function pickerItemLabel(item: PickerItem): string { return item.kind === "branch" ? item.name : formatPrLabel(item.item); } @@ -803,11 +740,6 @@ function getContentStyle(input: { isCompact: boolean; insetBottom: number }) { return [styles.content, styles.contentCentered]; } -function getSelectedPickerItem(selection: PickerSelection | null): PickerItem | null { - if (!selection) return null; - return selection.item; -} - function normalizeBranchDetails( data: | { branchDetails?: Array<{ name: string; committerDate: number }>; branches?: string[] } @@ -1060,47 +992,6 @@ function buildComposerConfig(input: { }; } -function collectAttachedPrNumbers(attachments: ReadonlyArray): Set { - const numbers = new Set(); - for (const attachment of attachments) { - if (attachment.kind === "github_pr") { - numbers.add(attachment.item.number); - } - } - return numbers; -} - -function pruneDismissedCheckoutHintPrNumbers( - dismissed: ReadonlySet, - attached: ReadonlySet, -): ReadonlySet { - let changed = false; - const next = new Set(); - for (const prNumber of dismissed) { - if (attached.has(prNumber)) { - next.add(prNumber); - } else { - changed = true; - } - } - return changed ? next : dismissed; -} - -function useCheckoutHintDismissals(attachments: ReadonlyArray) { - const [dismissedPrNumbers, setDismissedPrNumbers] = useState>( - () => new Set(), - ); - const attachedPrNumbers = useMemo(() => collectAttachedPrNumbers(attachments), [attachments]); - - useEffect(() => { - setDismissedPrNumbers((current) => - pruneDismissedCheckoutHintPrNumbers(current, attachedPrNumbers), - ); - }, [attachedPrNumbers]); - - return [dismissedPrNumbers, setDismissedPrNumbers] as const; -} - function usePendingWorkspaceDraftSetup( draftId: string | undefined, ): PendingWorkspaceDraftSetup | null { @@ -1647,7 +1538,6 @@ export function NewWorkspaceScreen({ typeof normalizeWorkspaceDescriptor > | null>(null); const [pendingAction, setPendingAction] = useState<"chat" | "empty" | null>(null); - const [manualPickerSelection, setManualPickerSelection] = useState(null); const [pickerOpen, setPickerOpen] = useState(false); const [projectPickerOpen, setProjectPickerOpen] = useState(false); const openAddProjectPicker = useOpenAddProject(); @@ -1718,10 +1608,22 @@ export function NewWorkspaceScreen({ }), }); const composerState = chatDraft.composerState; - const [dismissedCheckoutHintPrNumbers, setDismissedCheckoutHintPrNumbers] = - useCheckoutHintDismissals(chatDraft.attachments); + const [pickerSelection, dispatchPickerSelection] = useReducer( + reducePickerSelection, + initialPickerSelectionState, + ); + const selectedItem = pickerSelection.selectedItem; - const selectedItem = getSelectedPickerItem(manualPickerSelection); + const handleGithubPrDetected = useCallback(() => { + dispatchPickerSelection({ type: "pr-detected" }); + }, []); + + const handleGithubPrAutoAttach = useCallback((item: ForgeSearchItem) => { + dispatchPickerSelection({ + type: "pr-added", + item: { kind: "github-pr", item }, + }); + }, []); const withConnectedClient = useCallback(() => { if (!client || !isConnected) { @@ -1822,7 +1724,7 @@ export function NewWorkspaceScreen({ item, }); - setManualPickerSelection({ item }); + dispatchPickerSelection({ type: "picker-selected", item }); chatDraft.setAttachments(nextAttachments); setPickerOpen(false); }, @@ -1838,7 +1740,7 @@ export function NewWorkspaceScreen({ [itemById, selectPickerItem], ); - const clearManualPickerSelectionForTargetChange = useCallback( + const clearPickerSelectionForTargetChange = useCallback( (currentTargetId: string, nextTargetId: string) => { const nextAttachments = clearPickerPrAttachmentForTargetChange({ attachments: chatDraft.attachments, @@ -1847,7 +1749,7 @@ export function NewWorkspaceScreen({ }); if (nextAttachments === chatDraft.attachments) return; chatDraft.setAttachments(nextAttachments); - setManualPickerSelection(null); + dispatchPickerSelection({ type: "target-changed" }); }, [chatDraft], ); @@ -1859,17 +1761,17 @@ export function NewWorkspaceScreen({ // canCreateWorktree or non-git projects become unselectable. selectProjectOption(id); setProjectPickerOpen(false); - clearManualPickerSelectionForTargetChange(selectedProjectOptionId, id); + clearPickerSelectionForTargetChange(selectedProjectOptionId, id); }, - [clearManualPickerSelectionForTargetChange, selectProjectOption, selectedProjectOptionId], + [clearPickerSelectionForTargetChange, selectProjectOption, selectedProjectOptionId], ); const handleSelectWorkspaceHost = useCallback( (id: string) => { handleSelectHost(id); - clearManualPickerSelectionForTargetChange(selectedServerId, id); + clearPickerSelectionForTargetChange(selectedServerId, id); }, - [clearManualPickerSelectionForTargetChange, handleSelectHost, selectedServerId], + [clearPickerSelectionForTargetChange, handleSelectHost, selectedServerId], ); const handleAddProject = useCallback(() => { @@ -1877,32 +1779,6 @@ export function NewWorkspaceScreen({ openAddProjectPicker(selectedServerId); }, [openAddProjectPicker, selectedServerId]); - const checkoutHintPrAttachment = useMemo( - () => - findCheckoutHintPrAttachment({ - attachments: chatDraft.attachments, - selectedItem, - dismissedPrNumbers: dismissedCheckoutHintPrNumbers, - }), - [chatDraft.attachments, dismissedCheckoutHintPrNumbers, selectedItem], - ); - - const acceptCheckoutHint = useCallback(() => { - if (!checkoutHintPrAttachment) return; - selectPickerItem({ kind: "github-pr", item: checkoutHintPrAttachment.item }); - }, [checkoutHintPrAttachment, selectPickerItem]); - - const dismissCheckoutHint = useCallback(() => { - if (!checkoutHintPrAttachment) return; - const prNumber = checkoutHintPrAttachment.item.number; - setDismissedCheckoutHintPrNumbers((current) => { - if (current.has(prNumber)) return current; - const next = new Set(current); - next.add(prNumber); - return next; - }); - }, [checkoutHintPrAttachment, setDismissedCheckoutHintPrNumbers]); - const openPicker = useCallback(() => { setPickerOpen(true); }, []); @@ -2234,42 +2110,11 @@ export function NewWorkspaceScreen({ }); const composerFooter = useMemo( - () => ( - <> - {agentControlsWithDisabled ? ( - - ) : null} - {checkoutHintPrAttachment ? ( - - ) : null} - - ), - [ - acceptCheckoutHint, - agentControlsWithDisabled, - checkoutHintPrAttachment, - dismissCheckoutHint, - t, - theme.colors.foregroundMuted, - theme.iconSize.sm, - ], + () => + agentControlsWithDisabled ? ( + + ) : null, + [agentControlsWithDisabled], ); const screenHeaderLeft = useMemo(() => , []); @@ -2294,6 +2139,7 @@ export function NewWorkspaceScreen({ submitButtonTestID="workspace-create-submit" submitIcon="return" isSubmitLoading={isPending} + waitForGithubAutoAttachOnSubmit submitBehavior="preserve-and-lock" blurOnSubmit={true} value={chatDraft.text} @@ -2301,6 +2147,8 @@ export function NewWorkspaceScreen({ attachments={chatDraft.attachments} attachmentScopeKeys={visibleDraftContextScopeKeys} onChangeAttachments={chatDraft.setAttachments} + onGithubPrDetected={handleGithubPrDetected} + onGithubPrAutoAttach={handleGithubPrAutoAttach} cwd={selectedSourceDirectory ?? ""} clearDraft={handleClearDraft} autoFocus @@ -2387,23 +2235,6 @@ const styles = StyleSheet.create((theme) => ({ borderRadius: theme.borderRadius["2xl"], gap: theme.spacing[1], }, - checkoutHintBadge: { - flexDirection: "row", - alignItems: "center", - height: BADGE_HEIGHT, - maxWidth: 240, - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius["2xl"], - gap: theme.spacing[1], - backgroundColor: theme.colors.surface1, - }, - checkoutHintAction: { - width: theme.iconSize.md, - height: theme.iconSize.md, - alignItems: "center", - justifyContent: "center", - borderRadius: theme.borderRadius.full, - }, badgeHovered: { backgroundColor: theme.colors.surface2, }, diff --git a/packages/server/src/server/worktree-core.posix.test.ts b/packages/server/src/server/worktree-core.posix.test.ts index 10308f59d..2d4aaf87d 100644 --- a/packages/server/src/server/worktree-core.posix.test.ts +++ b/packages/server/src/server/worktree-core.posix.test.ts @@ -621,6 +621,52 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { expect(result.worktree.branchName).toBe("pr-123"); }); + test("tracks a same-repo PR branch from a single-branch clone", async () => { + const { tempDir, repoDir, remoteDir, paseoHome } = createSameRepoGitHubPrRemoteRepo(); + cleanupPaths.push(tempDir); + execFileSync("git", ["config", "--unset-all", "remote.origin.fetch"], { + cwd: repoDir, + stdio: "pipe", + }); + execFileSync( + "git", + ["config", "--add", "remote.origin.fetch", "+refs/heads/main:refs/remotes/origin/main"], + { cwd: repoDir, stdio: "pipe" }, + ); + execFileSync("git", ["update-ref", "-d", "refs/remotes/origin/daemon-shutdown-diagnostics"], { + cwd: repoDir, + stdio: "pipe", + }); + const github = { + ...createGitHubServiceStub(), + getPullRequestCheckoutTarget: async () => ({ + number: 1790, + baseRefName: "main", + headRefName: "daemon-shutdown-diagnostics", + headOwnerLogin: "getpaseo", + headRepositorySshUrl: remoteDir, + headRepositoryUrl: remoteDir, + isCrossRepository: false, + }), + }; + + const result = await createCoreWorktree( + { + cwd: repoDir, + action: "checkout", + githubPrNumber: 1790, + paseoHome, + runSetup: false, + }, + createCoreDeps({ github }), + ); + + expect(result.worktree.branchName).toBe("daemon-shutdown-diagnostics"); + expect(getBranchUpstream(result.worktree.worktreePath)).toBe( + "origin/daemon-shutdown-diagnostics", + ); + }); + test("checks out a GitLab MR source branch through the resolved forge service", async () => { const { tempDir, repoDir, paseoHome } = createGitRepoWithOriginFeatureBranch(); cleanupPaths.push(tempDir); diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 64d5ae2c7..5943d5ddf 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -1511,7 +1511,33 @@ async function tryFetchWorktreeTrackingRemote(options: { acceptExitCodes: [0, 1, 128], }, ); - return result.exitCode === 0 ? { name: options.remoteName, headRef: options.headRef } : undefined; + if (result.exitCode !== 0) { + return undefined; + } + await ensureRemoteFetchesBranch(options); + return { name: options.remoteName, headRef: options.headRef }; +} + +async function ensureRemoteFetchesBranch(options: { + cwd: string; + remoteName: string; + headRef: string; +}): Promise { + const configKey = `remote.${options.remoteName}.fetch`; + const exactRefspec = `refs/heads/${options.headRef}:refs/remotes/${options.remoteName}/${options.headRef}`; + const wildcardRefspec = `refs/heads/*:refs/remotes/${options.remoteName}/*`; + const { stdout } = await runGitCommand(["config", "--get-all", configKey], { + cwd: options.cwd, + acceptExitCodes: [0, 1], + }); + const alreadyTracked = stdout + .split("\n") + .map((refspec) => refspec.trim().replace(/^\+/, "")) + .some((refspec) => refspec === exactRefspec || refspec === wildcardRefspec); + if (alreadyTracked) { + return; + } + await runGitCommand(["config", "--add", configKey, `+${exactRefspec}`], { cwd: options.cwd }); } async function getWorktreeRemotePushUrl(