Merge branch 'main' of github.com:getpaseo/paseo

This commit is contained in:
Mohamed Boudra
2026-07-21 15:26:27 +02:00
20 changed files with 768 additions and 313 deletions

View File

@@ -328,6 +328,12 @@ interface PairingDaemonClient {
async function createFakeEditorBin(): Promise<string> { async function createFakeEditorBin(): Promise<string> {
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-editor-bin-")); 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 fakeEditorSource = `#!/usr/bin/env node
const fs = require("fs"); const fs = require("fs");
@@ -349,6 +355,70 @@ if (recordPath) {
await chmod(editorPath, 0o755); 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; return binDir;
} }

View File

@@ -1,5 +1,5 @@
import { execFileSync, execSync } from "node:child_process"; 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"; import path from "node:path";
export function hasGithubAuth(): boolean { export function hasGithubAuth(): boolean {
@@ -59,6 +59,12 @@ export interface GhDefaultBranchClone {
cleanup(): Promise<void>; cleanup(): Promise<void>;
} }
export interface LocalGhPrFixture {
pr: GhPrFixture;
mainCheckout: GhDefaultBranchClone;
cleanup(): Promise<void>;
}
function gh(args: string[], opts?: { cwd?: string }): string { function gh(args: string[], opts?: { cwd?: string }): string {
return execFileSync("gh", args, { return execFileSync("gh", args, {
cwd: opts?.cwd, cwd: opts?.cwd,
@@ -281,3 +287,57 @@ export async function cloneGithubRepoDefaultBranchOnly(
}, },
}; };
} }
export async function createLocalGithubPrFixture(): Promise<LocalGhPrFixture> {
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 });
},
};
}

View File

@@ -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 type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes"; import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
import { connectDaemonClient } from "./daemon-client-loader"; import { connectDaemonClient } from "./daemon-client-loader";
@@ -290,6 +290,13 @@ export async function selectBranchInPicker(page: Page, name: string): Promise<vo
await branchRow.click(); await branchRow.click();
} }
export async function searchAndSelectBranchInPicker(page: Page, name: string): Promise<void> {
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<void> { export async function selectGitHubPrInPicker(page: Page, number: number): Promise<void> {
const prRow = page.getByTestId(`new-workspace-ref-picker-pr-${number}`); const prRow = page.getByTestId(`new-workspace-ref-picker-pr-${number}`);
await expect(prRow).toBeVisible({ timeout: 30_000 }); await expect(prRow).toBeVisible({ timeout: 30_000 });
@@ -351,6 +358,18 @@ export async function expectComposerGithubAttachmentPill(
await expect(pills.first()).toContainText(input.title); await expect(pills.first()).toContainText(input.title);
} }
export async function pasteGithubPrUrl(
page: Page,
context: BrowserContext,
url: string,
): Promise<void> {
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( export async function assertNewWorkspaceSidebarAndHeader(
page: Page, page: Page,
input: { input: {

View File

@@ -17,11 +17,14 @@ import {
expectPickerOpen, expectPickerOpen,
expectPickerSelected, expectPickerSelected,
expectStartingRefPickerTriggerPr, expectStartingRefPickerTriggerPr,
fillNewWorkspaceDraft,
openGlobalNewWorkspaceComposer, openGlobalNewWorkspaceComposer,
openBranchPicker, openBranchPicker,
openNewWorkspaceComposer, openNewWorkspaceComposer,
openProjectViaDaemon, openProjectViaDaemon,
openStartingRefPicker, openStartingRefPicker,
pasteGithubPrUrl,
searchAndSelectBranchInPicker,
selectBranchInPicker, selectBranchInPicker,
selectGitHubPrInPicker, selectGitHubPrInPicker,
selectPickerOptionByKeyboard, selectPickerOptionByKeyboard,
@@ -30,9 +33,11 @@ import {
} from "./helpers/new-workspace"; } from "./helpers/new-workspace";
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace"; import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
import { import {
createLocalGithubPrFixture,
cloneGithubRepoDefaultBranchOnly, cloneGithubRepoDefaultBranchOnly,
createTempGithubRepo, createTempGithubRepo,
hasGithubAuth, hasGithubAuth,
type LocalGhPrFixture,
} from "./helpers/github-fixtures"; } from "./helpers/github-fixtures";
import { getServerId } from "./helpers/server-id"; import { getServerId } from "./helpers/server-id";
import { getE2EDaemonPort } from "./helpers/daemon-port"; import { getE2EDaemonPort } from "./helpers/daemon-port";
@@ -191,6 +196,7 @@ test.describe("New workspace flow", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>; let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
const localWorkspaceIds = new Set<string>(); const localWorkspaceIds = new Set<string>();
const createdWorktreeDirectories = new Set<string>(); const createdWorktreeDirectories = new Set<string>();
const localGithubFixtures = new Set<LocalGhPrFixture>();
test.describe.configure({ timeout: 240_000 }); test.describe.configure({ timeout: 240_000 });
@@ -212,6 +218,13 @@ test.describe("New workspace flow", () => {
await client?.close().catch(() => undefined); 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 }) => { test("adds a project from the selected empty host", async ({ page }) => {
const repo = await createTempGitRepo("new-workspace-project-picker-"); const repo = await createTempGitRepo("new-workspace-project-picker-");
const primaryServerId = getServerId(); 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 ({ test("selected GitHub PR creates the worktree from the PR head even when the head branch is not fetched", async ({
page, page,
}) => { }) => {

View File

@@ -48,7 +48,9 @@ interface SearchCall {
interface HarnessInput { interface HarnessInput {
initialAttachments?: UserComposerAttachment[]; initialAttachments?: UserComposerAttachment[];
initialCwd?: string;
initialText?: string; initialText?: string;
onPullRequestDetected?: () => void;
remote?: string | null; remote?: string | null;
} }
@@ -72,6 +74,14 @@ function createSearchClient(items: ForgeSearchItem[]): ForgeSearchClient & { cal
}; };
} }
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
function createWrapper() { function createWrapper() {
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@@ -87,6 +97,8 @@ function createWrapper() {
function useHarness(client: ForgeSearchClient, input: HarnessInput = {}) { function useHarness(client: ForgeSearchClient, input: HarnessInput = {}) {
const [text, setText] = useState(input.initialText ?? ""); const [text, setText] = useState(input.initialText ?? "");
const [searchClient, setSearchClient] = useState(client);
const [workingDirectory, setWorkingDirectory] = useState(input.initialCwd ?? cwd);
const [attachments, setAttachments] = useState<UserComposerAttachment[]>( const [attachments, setAttachments] = useState<UserComposerAttachment[]>(
input.initialAttachments ?? [], input.initialAttachments ?? [],
); );
@@ -94,18 +106,22 @@ function useHarness(client: ForgeSearchClient, input: HarnessInput = {}) {
text, text,
remoteUrl: input.remote ?? remoteUrl, remoteUrl: input.remote ?? remoteUrl,
attachments, attachments,
client, client: searchClient,
isConnected: true, isConnected: true,
serverId: "server-1", serverId: "server-1",
cwd, cwd: workingDirectory,
setAttachments, setAttachments,
onPullRequestDetected: input.onPullRequestDetected,
}); });
return { return {
text, text,
setText, setText,
setSearchClient,
setWorkingDirectory,
attachments, attachments,
setAttachments, setAttachments,
isResolving: autoAttach.isResolving,
markGithubAttachmentRemoved: autoAttach.markGithubAttachmentRemoved, markGithubAttachmentRemoved: autoAttach.markGithubAttachmentRemoved,
}; };
} }
@@ -121,14 +137,20 @@ describe("useComposerGithubAutoAttach", () => {
it("adds a matching pasted GitHub PR URL as a composer attachment", async () => { it("adds a matching pasted GitHub PR URL as a composer attachment", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const client = createSearchClient([pr101]); const client = createSearchClient([pr101]);
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() }); const onPullRequestDetected = vi.fn();
const { result } = renderHook(() => useHarness(client, { onPullRequestDetected }), {
wrapper: createWrapper(),
});
act(() => { act(() => {
result.current.setText("Please review https://github.com/acme/paseo/pull/101"); result.current.setText("Please review https://github.com/acme/paseo/pull/101");
}); });
expect(result.current.isResolving).toBe(true);
expect(onPullRequestDetected).toHaveBeenCalledTimes(1);
await flushDebounce(); await flushDebounce();
expect(result.current.attachments).toEqual([{ kind: "forge_change_request", item: pr101 }]); 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 }]); expect(client.calls).toEqual([{ cwd, query: "101", limit: 20 }]);
vi.useRealTimers(); vi.useRealTimers();
}); });
@@ -208,4 +230,115 @@ describe("useComposerGithubAutoAttach", () => {
]); ]);
vi.useRealTimers(); vi.useRealTimers();
}); });
it("stays resolving while overlapping lookups share a ref", async () => {
vi.useFakeTimers();
const firstLookup = deferred<ForgeSearchPayload>();
const secondLookup = deferred<ForgeSearchPayload>();
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<ForgeSearchPayload>();
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<ForgeSearchPayload>();
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<ForgeSearchPayload>();
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();
});
}); });

View File

@@ -4,6 +4,7 @@ import {
useEffect, useEffect,
useMemo, useMemo,
useRef, useRef,
useState,
type Dispatch, type Dispatch,
type RefObject, type RefObject,
type SetStateAction, type SetStateAction,
@@ -26,9 +27,12 @@ interface ComposerGithubAutoAttachInput {
cwd: string; cwd: string;
supportsForgeSearch?: boolean; supportsForgeSearch?: boolean;
setAttachments: Dispatch<SetStateAction<UserComposerAttachment[]>>; setAttachments: Dispatch<SetStateAction<UserComposerAttachment[]>>;
onPullRequestDetected?: () => void;
onPullRequestAdded?: (item: ForgeSearchItem) => void;
} }
interface ComposerGithubAutoAttachResult { interface ComposerGithubAutoAttachResult {
isResolving: boolean;
markGithubAttachmentRemoved: (attachment: ComposerAttachment | undefined) => void; markGithubAttachmentRemoved: (attachment: ComposerAttachment | undefined) => void;
} }
@@ -39,10 +43,24 @@ export function useComposerGithubAutoAttach(
const latestRef = useRef(params); const latestRef = useRef(params);
const removedRefKeysRef = useRef(new Set<string>()); const removedRefKeysRef = useRef(new Set<string>());
const pendingRefKeysRef = useRef(new Set<string>()); const pendingRefKeysRef = useRef(new Set<string>());
const presentPullRequestKeysRef = useRef(new Set<string>());
const previousTargetRef = useRef({ serverId: params.serverId, cwd: params.cwd });
const [resolvingRefCounts, setResolvingRefCounts] = useState<ReadonlyMap<string, number>>(
() => new Map(),
);
latestRef.current = params; latestRef.current = params;
useEffect(() => { useEffect(() => {
suppressRefsCarriedAcrossTargets({
params: latestRef.current,
previousTargetRef,
removedRefKeys: removedRefKeysRef.current,
});
notifyNewPullRequestRefs({
params: latestRef.current,
presentPullRequestKeysRef,
});
const refs = refsReadyForLookup({ const refs = refsReadyForLookup({
params: latestRef.current, params: latestRef.current,
removedRefKeys: removedRefKeysRef.current, removedRefKeys: removedRefKeysRef.current,
@@ -52,6 +70,15 @@ export function useComposerGithubAutoAttach(
return; 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(() => { const timerId = setTimeout(() => {
void attachRefs({ void attachRefs({
refs, refs,
@@ -59,11 +86,12 @@ export function useComposerGithubAutoAttach(
latestRef, latestRef,
removedRefKeys: removedRefKeysRef.current, removedRefKeys: removedRefKeysRef.current,
pendingRefKeys: pendingRefKeysRef.current, pendingRefKeys: pendingRefKeysRef.current,
}); }).finally(releaseResolving);
}, AUTO_ATTACH_DEBOUNCE_MS); }, AUTO_ATTACH_DEBOUNCE_MS);
return () => { return () => {
clearTimeout(timerId); clearTimeout(timerId);
releaseResolving();
}; };
}, [ }, [
params.text, params.text,
@@ -86,12 +114,84 @@ export function useComposerGithubAutoAttach(
return useMemo( return useMemo(
() => ({ () => ({
isResolving: resolvingRefCounts.size > 0,
markGithubAttachmentRemoved, markGithubAttachmentRemoved,
}), }),
[markGithubAttachmentRemoved], [markGithubAttachmentRemoved, resolvingRefCounts.size],
); );
} }
function suppressRefsCarriedAcrossTargets({
params,
previousTargetRef,
removedRefKeys,
}: {
params: ComposerGithubAutoAttachInput;
previousTargetRef: RefObject<{ serverId: string; cwd: string }>;
removedRefKeys: Set<string>;
}): 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<Set<string>>;
}): 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<string, number>,
keys: readonly string[],
): ReadonlyMap<string, number> {
const nextCounts = new Map(current);
for (const key of keys) nextCounts.set(key, (nextCounts.get(key) ?? 0) + 1);
return nextCounts;
}
function removeKeys(
current: ReadonlyMap<string, number>,
keys: readonly string[],
): ReadonlyMap<string, number> {
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<SetStateAction<ReadonlyMap<string, number>>>,
keys: readonly string[],
): void {
setResolvingRefCounts((current) => removeKeys(current, keys));
}
async function attachRefs({ async function attachRefs({
refs, refs,
queryClient, queryClient,
@@ -142,16 +242,28 @@ async function attachRef({
return; return;
} }
const item = search.items.find((candidate) => githubItemMatchesRef(candidate, ref)); 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; return;
} }
latestRef.current.setAttachments((current) => { if (isAttachmentSelectedForGithubItem(current.attachments, item)) {
if (removedRefKeys.has(key) || isAttachmentSelectedForGithubItem(current, item)) { return;
return current; }
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({ 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 { function hasGithubAttachment(attachments: UserComposerAttachment[], ref: GithubRef): boolean {
return attachments.some((attachment) => attachmentKey(attachment) === githubRefKey(ref)); return attachments.some((attachment) => attachmentKey(attachment) === githubRefKey(ref));
} }

View File

@@ -768,6 +768,8 @@ interface ComposerProps {
submitIcon?: "arrow" | "return"; submitIcon?: "arrow" | "return";
/** Externally controlled loading state. When true, disables the submit button. */ /** Externally controlled loading state. When true, disables the submit button. */
isSubmitLoading?: boolean; isSubmitLoading?: boolean;
/** When true, waits for pasted GitHub links to resolve before enabling submit. */
waitForGithubAutoAttachOnSubmit?: boolean;
submitBehavior?: "clear" | "preserve-and-lock"; submitBehavior?: "clear" | "preserve-and-lock";
/** When true, blurs the input immediately when submitting. */ /** When true, blurs the input immediately when submitting. */
blurOnSubmit?: boolean; blurOnSubmit?: boolean;
@@ -777,6 +779,8 @@ interface ComposerProps {
attachmentScopeKeys?: readonly string[]; attachmentScopeKeys?: readonly string[];
onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void; onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void;
onChangeAttachments: (updater: AttachmentListUpdater) => void; onChangeAttachments: (updater: AttachmentListUpdater) => void;
onGithubPrDetected?: () => void;
onGithubPrAutoAttach?: (item: ForgeSearchItem) => void;
cwd: string; cwd: string;
clearDraft: (lifecycle: "sent" | "abandoned") => void; clearDraft: (lifecycle: "sent" | "abandoned") => void;
/** When true, auto-focuses the text input on web. */ /** When true, auto-focuses the text input on web. */
@@ -981,6 +985,7 @@ export function Composer({
submitButtonTestID, submitButtonTestID,
submitIcon = "arrow", submitIcon = "arrow",
isSubmitLoading = false, isSubmitLoading = false,
waitForGithubAutoAttachOnSubmit = false,
submitBehavior = "clear", submitBehavior = "clear",
blurOnSubmit = false, blurOnSubmit = false,
value, value,
@@ -989,6 +994,8 @@ export function Composer({
attachmentScopeKeys = EMPTY_ATTACHMENT_SCOPE_KEYS, attachmentScopeKeys = EMPTY_ATTACHMENT_SCOPE_KEYS,
onOpenWorkspaceAttachment, onOpenWorkspaceAttachment,
onChangeAttachments, onChangeAttachments,
onGithubPrDetected,
onGithubPrAutoAttach,
cwd, cwd,
clearDraft, clearDraft,
autoFocus = false, autoFocus = false,
@@ -1070,6 +1077,8 @@ export function Composer({
cwd, cwd,
supportsForgeSearch, supportsForgeSearch,
setAttachments: setSelectedAttachments, setAttachments: setSelectedAttachments,
onPullRequestDetected: onGithubPrDetected,
onPullRequestAdded: onGithubPrAutoAttach,
}); });
const [cursorIndex, setCursorIndex] = useState(0); const [cursorIndex, setCursorIndex] = useState(0);
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
@@ -1949,7 +1958,11 @@ export function Composer({
const messageInputContainerRef = useRef<View>(null); const messageInputContainerRef = useRef<View>(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, // 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 // so a drop in that window would be lost or land on a locked draft. `disabled` hides the

View File

@@ -985,9 +985,6 @@ export const ar: TranslationResources = {
refPicker: { refPicker: {
startingRef: "بدء المرجع", startingRef: "بدء المرجع",
chooseStart: "اختر من أين تبدأ", chooseStart: "اختر من أين تبدأ",
checkoutHint: "تحقق من {{noun}} {{numberPrefix}}{{number}}؟",
checkoutPr: "تحقق من {{noun}} {{numberPrefix}}{{number}}",
dismissCheckoutHint: "تجاهل تلميح الخروج {{noun}} {{numberPrefix}}{{number}}",
intoBase: "إلى {{baseRef}}", intoBase: "إلى {{baseRef}}",
searching: "جارٍ البحث...", searching: "جارٍ البحث...",
noMatchingRefs: "لا توجد مراجع مطابقة.", noMatchingRefs: "لا توجد مراجع مطابقة.",

View File

@@ -995,9 +995,6 @@ export const en = {
refPicker: { refPicker: {
startingRef: "Starting ref", startingRef: "Starting ref",
chooseStart: "Choose where to start from", 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}}", intoBase: "into {{baseRef}}",
searching: "Searching...", searching: "Searching...",
noMatchingRefs: "No matching refs.", noMatchingRefs: "No matching refs.",

View File

@@ -1016,9 +1016,6 @@ export const es: TranslationResources = {
refPicker: { refPicker: {
startingRef: "Árbitro inicial", startingRef: "Árbitro inicial",
chooseStart: "Elige por dónde empezar", 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}}", intoBase: "en {{baseRef}}",
searching: "Búsqueda...", searching: "Búsqueda...",
noMatchingRefs: "No hay árbitros coincidentes.", noMatchingRefs: "No hay árbitros coincidentes.",

View File

@@ -1015,9 +1015,6 @@ export const fr: TranslationResources = {
refPicker: { refPicker: {
startingRef: "Réf de départ", startingRef: "Réf de départ",
chooseStart: "Choisissez par où commencer", 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}}", intoBase: "dans {{baseRef}}",
searching: "Recherche...", searching: "Recherche...",
noMatchingRefs: "Aucune référence correspondante.", noMatchingRefs: "Aucune référence correspondante.",

View File

@@ -996,9 +996,6 @@ export const ja: TranslationResources = {
refPicker: { refPicker: {
startingRef: "開始Ref", startingRef: "開始Ref",
chooseStart: "開始点を選択", chooseStart: "開始点を選択",
checkoutHint: "{{noun}} {{numberPrefix}}{{number}}をチェックアウトしますか?",
checkoutPr: "{{noun}} {{numberPrefix}}{{number}}をチェックアウト",
dismissCheckoutHint: "{{noun}} {{numberPrefix}}{{number}}のチェックアウトヒントを閉じる",
intoBase: "{{baseRef}}に", intoBase: "{{baseRef}}に",
searching: "検索中...", searching: "検索中...",
noMatchingRefs: "一致するRefがありません。", noMatchingRefs: "一致するRefがありません。",

View File

@@ -1007,9 +1007,6 @@ export const ptBR: TranslationResources = {
refPicker: { refPicker: {
startingRef: "Ref inicial", startingRef: "Ref inicial",
chooseStart: "Escolha de onde começar", 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}}", intoBase: "em {{baseRef}}",
searching: "Buscando...", searching: "Buscando...",
noMatchingRefs: "Nenhuma ref correspondente.", noMatchingRefs: "Nenhuma ref correspondente.",

View File

@@ -1007,10 +1007,6 @@ export const ru: TranslationResources = {
refPicker: { refPicker: {
startingRef: "Начальная ссылка", startingRef: "Начальная ссылка",
chooseStart: "Выберите, с чего начать", chooseStart: "Выберите, с чего начать",
checkoutHint: "Проверьте {{noun}} {{numberPrefix}}{{number}}?",
checkoutPr: "Проверьте {{noun}} {{numberPrefix}}{{number}}",
dismissCheckoutHint:
"Отклонить подсказку по оформлению заказа {{noun}} {{numberPrefix}}{{number}}",
intoBase: "в {{baseRef}}", intoBase: "в {{baseRef}}",
searching: "Идет поиск...", searching: "Идет поиск...",
noMatchingRefs: "Нет подходящих ссылок.", noMatchingRefs: "Нет подходящих ссылок.",

View File

@@ -974,9 +974,6 @@ export const zhCN: TranslationResources = {
refPicker: { refPicker: {
startingRef: "起始 ref", startingRef: "起始 ref",
chooseStart: "选择起始位置", chooseStart: "选择起始位置",
checkoutHint: "Checkout {{noun}} {{numberPrefix}}{{number}}",
checkoutPr: "Checkout {{noun}} {{numberPrefix}}{{number}}",
dismissCheckoutHint: "忽略 {{noun}} {{numberPrefix}}{{number}} checkout 提示",
intoBase: "进入 {{baseRef}}", intoBase: "进入 {{baseRef}}",
searching: "正在搜索...", searching: "正在搜索...",
noMatchingRefs: "没有匹配的 refs。", noMatchingRefs: "没有匹配的 refs。",

View File

@@ -2,7 +2,8 @@ import { describe, expect, it } from "vitest";
import type { UserComposerAttachment } from "@/attachments/types"; import type { UserComposerAttachment } from "@/attachments/types";
import { import {
clearPickerPrAttachmentForTargetChange, clearPickerPrAttachmentForTargetChange,
findCheckoutHintPrAttachment, initialPickerSelectionState,
reducePickerSelection,
syncPickerPrAttachment, syncPickerPrAttachment,
} from "./new-workspace-picker-state"; } from "./new-workspace-picker-state";
import type { ForgeSearchItem } from "@getpaseo/protocol/messages"; import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
@@ -28,21 +29,28 @@ function prAttachment(
return { kind: "github_pr", item, ...(owner ? { owner } : {}) }; return { kind: "github_pr", item, ...(owner ? { owner } : {}) };
} }
function issueAttachment(number: number): UserComposerAttachment { function forgePrAttachment(
item: ForgeSearchItem,
): Extract<UserComposerAttachment, { kind: "forge_change_request" }> {
return { kind: "forge_change_request", item };
}
function makeIssueItem(number: number): ForgeSearchItem {
return { return {
kind: "github_issue", kind: "issue",
item: { number,
kind: "issue", title: `Issue ${number}`,
number, url: `https://example.com/issues/${number}`,
title: `Issue ${number}`, state: "open",
url: `https://example.com/issues/${number}`, body: null,
state: "open", labels: [],
body: null,
labels: [],
},
}; };
} }
function issueAttachment(number: number): UserComposerAttachment {
return { kind: "github_issue", item: makeIssueItem(number) };
}
describe("syncPickerPrAttachment", () => { describe("syncPickerPrAttachment", () => {
it("selects a PR when no previous picker PR is set", () => { it("selects a PR when no previous picker PR is set", () => {
const pr = makePrItem(202, "Refactor picker"); const pr = makePrItem(202, "Refactor picker");
@@ -91,6 +99,15 @@ describe("syncPickerPrAttachment", () => {
expect(result).toEqual([prAttachment(pr)]); 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", () => { it("clears a persisted picker selection without removing user-added attachments", () => {
const pickerPr = prAttachment(makePrItem(202, "Picker PR"), "new-workspace-picker"); const pickerPr = prAttachment(makePrItem(202, "Picker PR"), "new-workspace-picker");
const manuallyAttachedPr = prAttachment(makePrItem(303, "Manual PR")); const manuallyAttachedPr = prAttachment(makePrItem(303, "Manual PR"));
@@ -119,67 +136,82 @@ describe("clearPickerPrAttachmentForTargetChange", () => {
).toBe(attachments); ).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 pickerPr = prAttachment(makePrItem(202, "Picker PR"), "new-workspace-picker");
const manualPr = prAttachment(makePrItem(303, "Manual PR")); const manualPr = prAttachment(makePrItem(303, "Manual PR"));
const forgePr = forgePrAttachment(makePrItem(404, "Forge PR"));
const issue = issueAttachment(44);
expect( expect(
clearPickerPrAttachmentForTargetChange({ clearPickerPrAttachmentForTargetChange({
attachments: [pickerPr, manualPr], attachments: [issue, pickerPr, manualPr, forgePr],
currentTargetId: "server-a", currentTargetId: "server-a",
nextTargetId: "server-b", nextTargetId: "server-b",
}), }),
).toEqual([manualPr]); ).toEqual([issue]);
}); });
}); });
describe("findCheckoutHintPrAttachment", () => { describe("reducePickerSelection", () => {
it("returns the first attached PR that is not selected or dismissed", () => { it("selects a PR that was newly detected and added", () => {
const first = prAttachment(makePrItem(101, "A")); const item = { kind: "github-pr" as const, item: makePrItem(101, "A") };
const second = prAttachment(makePrItem(202, "B")); const detected = reducePickerSelection(initialPickerSelectionState, { type: "pr-detected" });
expect( expect(reducePickerSelection(detected, { type: "pr-added", item })).toEqual({
findCheckoutHintPrAttachment({ selectedItem: item,
attachments: [issueAttachment(44), first, second], allowAutoPrSelection: false,
selectedItem: null, });
dismissedPrNumbers: new Set(),
}),
).toBe(first);
}); });
it("skips the selected PR and offers the next attached PR", () => { it("keeps the first PR selected when one edit adds multiple PRs", () => {
const selected = prAttachment(makePrItem(101, "A")); const detected = reducePickerSelection(initialPickerSelectionState, { type: "pr-detected" });
const next = prAttachment(makePrItem(202, "B")); const first = reducePickerSelection(detected, {
type: "pr-added",
item: { kind: "github-pr", item: makePrItem(101, "A") },
});
expect( expect(
findCheckoutHintPrAttachment({ reducePickerSelection(first, {
attachments: [selected, next], type: "pr-added",
selectedItem: { kind: "github-pr", item: selected.item }, item: { kind: "github-pr", item: makePrItem(202, "B") },
dismissedPrNumbers: new Set(),
}), }),
).toBe(next); ).toEqual(first);
}); });
it("skips dismissed PRs and ignores issues", () => { it("keeps a branch selected after a pending PR is added", () => {
const dismissed = prAttachment(makePrItem(101, "A")); const detected = reducePickerSelection(initialPickerSelectionState, { type: "pr-detected" });
const next = prAttachment(makePrItem(202, "B")); const branchSelected = reducePickerSelection(detected, {
type: "picker-selected",
item: { kind: "branch", name: "main" },
});
expect( expect(
findCheckoutHintPrAttachment({ reducePickerSelection(branchSelected, {
attachments: [issueAttachment(44), dismissed, next], type: "pr-added",
selectedItem: null, item: { kind: "github-pr", item: makePrItem(101, "A") },
dismissedPrNumbers: new Set([101]),
}), }),
).toBe(next); ).toEqual(branchSelected);
}); });
it("returns null when only issues qualify", () => { it("does not derive checkout selection from an existing attachment", () => {
expect( expect(
findCheckoutHintPrAttachment({ reducePickerSelection(initialPickerSelectionState, {
attachments: [issueAttachment(44)], type: "pr-added",
selectedItem: null, item: { kind: "github-pr", item: makePrItem(101, "A") },
dismissedPrNumbers: new Set(),
}), }),
).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,
);
}); });
}); });

View File

@@ -4,6 +4,46 @@ import {
} from "@/attachments/types"; } from "@/attachments/types";
import type { PickerItem } from "./new-workspace-picker-item"; 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<PickerItem, { kind: "github-pr" }> }
| { 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<UserComposerAttachment, { kind: "forge_change_request" | "github_pr" }> {
return attachment.kind === "forge_change_request" || attachment.kind === "github_pr";
}
function isPickerOwnedPrAttachment(attachment: UserComposerAttachment): attachment is Extract< function isPickerOwnedPrAttachment(attachment: UserComposerAttachment): attachment is Extract<
UserComposerAttachment, UserComposerAttachment,
{ kind: "github_pr" } { kind: "github_pr" }
@@ -28,8 +68,7 @@ export function syncPickerPrAttachment(input: {
if (input.item?.kind === "github-pr") { if (input.item?.kind === "github-pr") {
const selectedPr = input.item.item; const selectedPr = input.item.item;
const hasExistingPrAttachment = nextAttachments.some( const hasExistingPrAttachment = nextAttachments.some(
(attachment) => (attachment) => isPrAttachment(attachment) && attachment.item.number === selectedPr.number,
attachment.kind === "github_pr" && attachment.item.number === selectedPr.number,
); );
if (!hasExistingPrAttachment) { if (!hasExistingPrAttachment) {
return [ return [
@@ -54,24 +93,5 @@ export function clearPickerPrAttachmentForTargetChange(input: {
if (input.currentTargetId === input.nextTargetId) { if (input.currentTargetId === input.nextTargetId) {
return input.attachments; return input.attachments;
} }
return syncPickerPrAttachment({ attachments: input.attachments, item: null }); return input.attachments.filter((attachment) => !isPrAttachment(attachment));
}
export function findCheckoutHintPrAttachment(input: {
attachments: ReadonlyArray<UserComposerAttachment>;
selectedItem: PickerItem | null;
dismissedPrNumbers: ReadonlySet<number>;
}): Extract<UserComposerAttachment, { kind: "github_pr" }> | 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;
} }

View File

@@ -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 type { ReactElement, RefObject } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { TFunction } from "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 { useSafeAreaInsets } from "react-native-safe-area-context";
import { createNameId } from "mnemonic-id"; import { createNameId } from "mnemonic-id";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import { ChevronDown, Folder, FolderPlus, GitBranch, GitPullRequest } from "lucide-react-native";
Check,
ChevronDown,
Folder,
FolderPlus,
GitBranch,
GitPullRequest,
X,
} from "lucide-react-native";
import { Composer } from "@/composer"; import { Composer } from "@/composer";
import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control"; import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
@@ -80,7 +72,7 @@ import {
} from "@/projects/host-projects"; } from "@/projects/host-projects";
import { useProjectIconDataByProjectKey } from "@/projects/project-icons"; import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
import { ICON_SIZE, type Theme } from "@/styles/theme"; 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 { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
import type { MessagePayload } from "@/composer/types"; import type { MessagePayload } from "@/composer/types";
import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages"; import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages";
@@ -99,7 +91,8 @@ import {
} from "./new-workspace-picker-item"; } from "./new-workspace-picker-item";
import { import {
clearPickerPrAttachmentForTargetChange, clearPickerPrAttachmentForTargetChange,
findCheckoutHintPrAttachment, initialPickerSelectionState,
reducePickerSelection,
syncPickerPrAttachment, syncPickerPrAttachment,
} from "./new-workspace-picker-state"; } from "./new-workspace-picker-state";
import { import {
@@ -186,10 +179,6 @@ interface PickerOptionData {
itemById: Map<string, PickerItem>; itemById: Map<string, PickerItem>;
} }
interface PickerSelection {
item: PickerItem;
}
const BRANCH_OPTION_PREFIX = "branch:"; const BRANCH_OPTION_PREFIX = "branch:";
const PR_OPTION_PREFIX = "github-pr:"; const PR_OPTION_PREFIX = "github-pr:";
const PROJECT_ICON_FALLBACK_FONT_SIZE = 10; 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 (
<View style={styles.checkoutHintBadge}>
<Text style={styles.badgeText} numberOfLines={1}>
{label}
</Text>
<Pressable
testID="new-workspace-checkout-hint-accept"
onPress={onAccept}
style={styles.checkoutHintAction}
accessibilityRole="button"
accessibilityLabel={acceptLabel}
>
<Check size={iconSize} color={iconColor} />
</Pressable>
<Pressable
testID="new-workspace-checkout-hint-dismiss"
onPress={onDismiss}
style={styles.checkoutHintAction}
accessibilityRole="button"
accessibilityLabel={dismissLabel}
>
<X size={iconSize} color={iconColor} />
</Pressable>
</View>
);
}
function PickerOptionItem({ function PickerOptionItem({
testID, testID,
label, label,
@@ -646,14 +591,6 @@ function formatPrLabel(item: Pick<ForgeSearchItem, "forge" | "number" | "title">
return `${presentation.numberPrefix}${item.number} ${item.title}`; 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 { function pickerItemLabel(item: PickerItem): string {
return item.kind === "branch" ? item.name : formatPrLabel(item.item); 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]; return [styles.content, styles.contentCentered];
} }
function getSelectedPickerItem(selection: PickerSelection | null): PickerItem | null {
if (!selection) return null;
return selection.item;
}
function normalizeBranchDetails( function normalizeBranchDetails(
data: data:
| { branchDetails?: Array<{ name: string; committerDate: number }>; branches?: string[] } | { branchDetails?: Array<{ name: string; committerDate: number }>; branches?: string[] }
@@ -1060,47 +992,6 @@ function buildComposerConfig(input: {
}; };
} }
function collectAttachedPrNumbers(attachments: ReadonlyArray<UserComposerAttachment>): Set<number> {
const numbers = new Set<number>();
for (const attachment of attachments) {
if (attachment.kind === "github_pr") {
numbers.add(attachment.item.number);
}
}
return numbers;
}
function pruneDismissedCheckoutHintPrNumbers(
dismissed: ReadonlySet<number>,
attached: ReadonlySet<number>,
): ReadonlySet<number> {
let changed = false;
const next = new Set<number>();
for (const prNumber of dismissed) {
if (attached.has(prNumber)) {
next.add(prNumber);
} else {
changed = true;
}
}
return changed ? next : dismissed;
}
function useCheckoutHintDismissals(attachments: ReadonlyArray<UserComposerAttachment>) {
const [dismissedPrNumbers, setDismissedPrNumbers] = useState<ReadonlySet<number>>(
() => new Set(),
);
const attachedPrNumbers = useMemo(() => collectAttachedPrNumbers(attachments), [attachments]);
useEffect(() => {
setDismissedPrNumbers((current) =>
pruneDismissedCheckoutHintPrNumbers(current, attachedPrNumbers),
);
}, [attachedPrNumbers]);
return [dismissedPrNumbers, setDismissedPrNumbers] as const;
}
function usePendingWorkspaceDraftSetup( function usePendingWorkspaceDraftSetup(
draftId: string | undefined, draftId: string | undefined,
): PendingWorkspaceDraftSetup | null { ): PendingWorkspaceDraftSetup | null {
@@ -1647,7 +1538,6 @@ export function NewWorkspaceScreen({
typeof normalizeWorkspaceDescriptor typeof normalizeWorkspaceDescriptor
> | null>(null); > | null>(null);
const [pendingAction, setPendingAction] = useState<"chat" | "empty" | null>(null); const [pendingAction, setPendingAction] = useState<"chat" | "empty" | null>(null);
const [manualPickerSelection, setManualPickerSelection] = useState<PickerSelection | null>(null);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
const [projectPickerOpen, setProjectPickerOpen] = useState(false); const [projectPickerOpen, setProjectPickerOpen] = useState(false);
const openAddProjectPicker = useOpenAddProject(); const openAddProjectPicker = useOpenAddProject();
@@ -1718,10 +1608,22 @@ export function NewWorkspaceScreen({
}), }),
}); });
const composerState = chatDraft.composerState; const composerState = chatDraft.composerState;
const [dismissedCheckoutHintPrNumbers, setDismissedCheckoutHintPrNumbers] = const [pickerSelection, dispatchPickerSelection] = useReducer(
useCheckoutHintDismissals(chatDraft.attachments); 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(() => { const withConnectedClient = useCallback(() => {
if (!client || !isConnected) { if (!client || !isConnected) {
@@ -1822,7 +1724,7 @@ export function NewWorkspaceScreen({
item, item,
}); });
setManualPickerSelection({ item }); dispatchPickerSelection({ type: "picker-selected", item });
chatDraft.setAttachments(nextAttachments); chatDraft.setAttachments(nextAttachments);
setPickerOpen(false); setPickerOpen(false);
}, },
@@ -1838,7 +1740,7 @@ export function NewWorkspaceScreen({
[itemById, selectPickerItem], [itemById, selectPickerItem],
); );
const clearManualPickerSelectionForTargetChange = useCallback( const clearPickerSelectionForTargetChange = useCallback(
(currentTargetId: string, nextTargetId: string) => { (currentTargetId: string, nextTargetId: string) => {
const nextAttachments = clearPickerPrAttachmentForTargetChange({ const nextAttachments = clearPickerPrAttachmentForTargetChange({
attachments: chatDraft.attachments, attachments: chatDraft.attachments,
@@ -1847,7 +1749,7 @@ export function NewWorkspaceScreen({
}); });
if (nextAttachments === chatDraft.attachments) return; if (nextAttachments === chatDraft.attachments) return;
chatDraft.setAttachments(nextAttachments); chatDraft.setAttachments(nextAttachments);
setManualPickerSelection(null); dispatchPickerSelection({ type: "target-changed" });
}, },
[chatDraft], [chatDraft],
); );
@@ -1859,17 +1761,17 @@ export function NewWorkspaceScreen({
// canCreateWorktree or non-git projects become unselectable. // canCreateWorktree or non-git projects become unselectable.
selectProjectOption(id); selectProjectOption(id);
setProjectPickerOpen(false); setProjectPickerOpen(false);
clearManualPickerSelectionForTargetChange(selectedProjectOptionId, id); clearPickerSelectionForTargetChange(selectedProjectOptionId, id);
}, },
[clearManualPickerSelectionForTargetChange, selectProjectOption, selectedProjectOptionId], [clearPickerSelectionForTargetChange, selectProjectOption, selectedProjectOptionId],
); );
const handleSelectWorkspaceHost = useCallback( const handleSelectWorkspaceHost = useCallback(
(id: string) => { (id: string) => {
handleSelectHost(id); handleSelectHost(id);
clearManualPickerSelectionForTargetChange(selectedServerId, id); clearPickerSelectionForTargetChange(selectedServerId, id);
}, },
[clearManualPickerSelectionForTargetChange, handleSelectHost, selectedServerId], [clearPickerSelectionForTargetChange, handleSelectHost, selectedServerId],
); );
const handleAddProject = useCallback(() => { const handleAddProject = useCallback(() => {
@@ -1877,32 +1779,6 @@ export function NewWorkspaceScreen({
openAddProjectPicker(selectedServerId); openAddProjectPicker(selectedServerId);
}, [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(() => { const openPicker = useCallback(() => {
setPickerOpen(true); setPickerOpen(true);
}, []); }, []);
@@ -2234,42 +2110,11 @@ export function NewWorkspaceScreen({
}); });
const composerFooter = useMemo( const composerFooter = useMemo(
() => ( () =>
<> agentControlsWithDisabled ? (
{agentControlsWithDisabled ? ( <DraftAgentModeControl placement="footer" {...agentControlsWithDisabled} />
<DraftAgentModeControl placement="footer" {...agentControlsWithDisabled} /> ) : null,
) : null} [agentControlsWithDisabled],
{checkoutHintPrAttachment ? (
<CheckoutHintBadge
label={t("newWorkspace.refPicker.checkoutHint", {
number: checkoutHintPrAttachment.item.number,
...getCheckoutHintPresentation(checkoutHintPrAttachment.item),
})}
acceptLabel={t("newWorkspace.refPicker.checkoutPr", {
number: checkoutHintPrAttachment.item.number,
...getCheckoutHintPresentation(checkoutHintPrAttachment.item),
})}
dismissLabel={t("newWorkspace.refPicker.dismissCheckoutHint", {
number: checkoutHintPrAttachment.item.number,
...getCheckoutHintPresentation(checkoutHintPrAttachment.item),
})}
onAccept={acceptCheckoutHint}
onDismiss={dismissCheckoutHint}
iconColor={theme.colors.foregroundMuted}
iconSize={theme.iconSize.sm}
/>
) : null}
</>
),
[
acceptCheckoutHint,
agentControlsWithDisabled,
checkoutHintPrAttachment,
dismissCheckoutHint,
t,
theme.colors.foregroundMuted,
theme.iconSize.sm,
],
); );
const screenHeaderLeft = useMemo(() => <SidebarMenuToggle />, []); const screenHeaderLeft = useMemo(() => <SidebarMenuToggle />, []);
@@ -2294,6 +2139,7 @@ export function NewWorkspaceScreen({
submitButtonTestID="workspace-create-submit" submitButtonTestID="workspace-create-submit"
submitIcon="return" submitIcon="return"
isSubmitLoading={isPending} isSubmitLoading={isPending}
waitForGithubAutoAttachOnSubmit
submitBehavior="preserve-and-lock" submitBehavior="preserve-and-lock"
blurOnSubmit={true} blurOnSubmit={true}
value={chatDraft.text} value={chatDraft.text}
@@ -2301,6 +2147,8 @@ export function NewWorkspaceScreen({
attachments={chatDraft.attachments} attachments={chatDraft.attachments}
attachmentScopeKeys={visibleDraftContextScopeKeys} attachmentScopeKeys={visibleDraftContextScopeKeys}
onChangeAttachments={chatDraft.setAttachments} onChangeAttachments={chatDraft.setAttachments}
onGithubPrDetected={handleGithubPrDetected}
onGithubPrAutoAttach={handleGithubPrAutoAttach}
cwd={selectedSourceDirectory ?? ""} cwd={selectedSourceDirectory ?? ""}
clearDraft={handleClearDraft} clearDraft={handleClearDraft}
autoFocus autoFocus
@@ -2387,23 +2235,6 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius["2xl"], borderRadius: theme.borderRadius["2xl"],
gap: theme.spacing[1], 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: { badgeHovered: {
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
}, },

View File

@@ -621,6 +621,52 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
expect(result.worktree.branchName).toBe("pr-123"); 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 () => { test("checks out a GitLab MR source branch through the resolved forge service", async () => {
const { tempDir, repoDir, paseoHome } = createGitRepoWithOriginFeatureBranch(); const { tempDir, repoDir, paseoHome } = createGitRepoWithOriginFeatureBranch();
cleanupPaths.push(tempDir); cleanupPaths.push(tempDir);

View File

@@ -1511,7 +1511,33 @@ async function tryFetchWorktreeTrackingRemote(options: {
acceptExitCodes: [0, 1, 128], 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<void> {
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( async function getWorktreeRemotePushUrl(