From 14cc1cc83c14aa4a03208a0840edfd41065b972b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 18 Apr 2026 12:08:30 +0700 Subject: [PATCH] feat: support creating worktrees from GitHub pull requests --- packages/app/e2e/global-setup.ts | 50 +- packages/app/e2e/helpers/new-workspace.ts | 46 +- packages/app/e2e/helpers/workspace.ts | 57 +- packages/app/e2e/new-workspace.spec.ts | 100 ++- .../src/components/agent-input-submit.test.ts | 45 + .../app/src/components/agent-input-submit.ts | 20 +- .../app/src/components/attachment-pill.tsx | 4 + packages/app/src/components/composer.test.tsx | 41 +- packages/app/src/components/composer.tsx | 11 +- .../app/src/components/message-input.test.tsx | 2 +- packages/app/src/components/message-input.tsx | 4 +- .../app/src/components/ui/tooltip.test.tsx | 116 +++ packages/app/src/components/ui/tooltip.tsx | 1 + .../screens/new-workspace-picker-item.test.ts | 59 ++ .../src/screens/new-workspace-picker-item.ts | 30 + .../src/screens/new-workspace-screen.test.tsx | 781 ++++++++++++++++ .../app/src/screens/new-workspace-screen.tsx | 386 +++++--- .../server/src/client/daemon-client.test.ts | 255 ++++++ packages/server/src/client/daemon-client.ts | 25 +- .../agent/agent-metadata-generator.test.ts | 38 +- .../src/server/agent/mcp-server.test.ts | 227 ++++- .../server/src/server/agent/mcp-server.ts | 115 ++- packages/server/src/server/bootstrap.ts | 39 + .../daemon-e2e/checkout-ship.e2e.test.ts | 39 +- .../src/server/paseo-worktree-service.test.ts | 274 ++++++ .../src/server/paseo-worktree-service.ts | 132 +++ .../resolve-worktree-creation-intent.test.ts | 233 +++++ .../resolve-worktree-creation-intent.ts | 198 +++++ packages/server/src/server/session.ts | 184 ++-- .../src/server/session.workspaces.test.ts | 63 ++ .../server/src/server/websocket-server.ts | 5 + .../src/server/workspace-git-service.test.ts | 30 +- .../src/server/workspace-git-service.ts | 14 +- .../src/server/worktree-bootstrap.test.ts | 70 +- .../server/src/server/worktree-bootstrap.ts | 71 -- .../server/src/server/worktree-core.test.ts | 455 ++++++++++ packages/server/src/server/worktree-core.ts | 125 +++ packages/server/src/server/worktree-errors.ts | 50 ++ .../src/server/worktree-session.test.ts | 474 ++++++++-- .../server/src/server/worktree-session.ts | 406 +++------ .../src/services/github-service.test.ts | 231 +++++ .../server/src/services/github-service.ts | 835 ++++++++++++++++++ .../src/shared/messages.attachments.test.ts | 67 ++ packages/server/src/shared/messages.ts | 12 + .../server/src/utils/checkout-git.test.ts | 522 ++++++----- packages/server/src/utils/checkout-git.ts | 510 ++--------- packages/server/src/utils/worktree.test.ts | 193 +++- packages/server/src/utils/worktree.ts | 318 +++++-- 48 files changed, 6448 insertions(+), 1515 deletions(-) create mode 100644 packages/app/src/components/ui/tooltip.test.tsx create mode 100644 packages/app/src/screens/new-workspace-picker-item.test.ts create mode 100644 packages/app/src/screens/new-workspace-picker-item.ts create mode 100644 packages/app/src/screens/new-workspace-screen.test.tsx create mode 100644 packages/server/src/server/paseo-worktree-service.test.ts create mode 100644 packages/server/src/server/paseo-worktree-service.ts create mode 100644 packages/server/src/server/resolve-worktree-creation-intent.test.ts create mode 100644 packages/server/src/server/resolve-worktree-creation-intent.ts create mode 100644 packages/server/src/server/worktree-core.test.ts create mode 100644 packages/server/src/server/worktree-core.ts create mode 100644 packages/server/src/server/worktree-errors.ts create mode 100644 packages/server/src/services/github-service.test.ts create mode 100644 packages/server/src/services/github-service.ts diff --git a/packages/app/e2e/global-setup.ts b/packages/app/e2e/global-setup.ts index 9e319e851..2962d1336 100644 --- a/packages/app/e2e/global-setup.ts +++ b/packages/app/e2e/global-setup.ts @@ -1,6 +1,6 @@ import { spawn, type ChildProcess, execFileSync, execSync } from "node:child_process"; import { existsSync } from "node:fs"; -import { mkdtemp, rm } from "node:fs/promises"; +import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import net from "node:net"; @@ -182,6 +182,7 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise { + const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-gh-bin-")); + const ghPath = path.join(binDir, "gh"); + await writeFile( + ghPath, + `#!/usr/bin/env node +const args = process.argv.slice(2); + +if (args[0] === "auth" && args[1] === "status") { + process.exit(0); +} + +if (args[0] === "pr" && args[1] === "list") { + console.log(JSON.stringify([ + { + number: 515, + title: "Review selected start ref", + url: "https://github.com/getpaseo/paseo/pull/515", + state: "OPEN", + body: "Fixture pull request for app e2e.", + labels: [], + baseRefName: "main", + headRefName: "feature/start-from-pr" + } + ])); + process.exit(0); +} + +if (args[0] === "issue" && args[1] === "list") { + console.log("[]"); + process.exit(0); +} + +console.error("Unsupported fake gh invocation: " + args.join(" ")); +process.exit(1); +`, + ); + await chmod(ghPath, 0o755); + return binDir; +} + function stripAnsi(input: string): string { return input.replace(/\u001b\[[0-9;]*m/g, ""); } @@ -281,6 +323,7 @@ export default async function globalSetup() { let relayPort = 0; const metroPort = await getAvailablePort(); paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-")); + fakeGhBinDir = await createFakeGhBin(); let relayLineBuffer = createLineBuffer(); const metroLineBuffer = createLineBuffer(); const daemonLineBuffer = createLineBuffer(); @@ -298,6 +341,10 @@ export default async function globalSetup() { await rm(paseoHome, { recursive: true, force: true }); paseoHome = null; } + if (fakeGhBinDir) { + await rm(fakeGhBinDir, { recursive: true, force: true }); + fakeGhBinDir = null; + } }; const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY); @@ -482,6 +529,7 @@ export default async function globalSetup() { cwd: serverDir, env: { ...process.env, + PATH: `${fakeGhBinDir}${path.delimiter}${process.env.PATH ?? ""}`, PASEO_HOME: paseoHome, PASEO_SERVER_ID: "srv_e2e_test_daemon", PASEO_LISTEN: `0.0.0.0:${port}`, diff --git a/packages/app/e2e/helpers/new-workspace.ts b/packages/app/e2e/helpers/new-workspace.ts index eadcb636e..2f199f100 100644 --- a/packages/app/e2e/helpers/new-workspace.ts +++ b/packages/app/e2e/helpers/new-workspace.ts @@ -153,7 +153,7 @@ export async function createWorktreeViaDaemon( }; } -export async function clickNewWorkspaceButton( +export async function openNewWorkspaceComposer( page: Page, input: { projectKey: string; projectDisplayName: string }, ): Promise { @@ -168,7 +168,13 @@ export async function clickNewWorkspaceButton( await expect(page).toHaveURL(/\/h\/[^/]+\/new(?:\?.*)?$/, { timeout: 30_000, }); +} +export async function clickNewWorkspaceButton( + page: Page, + input: { projectKey: string; projectDisplayName: string }, +): Promise { + await openNewWorkspaceComposer(page, input); const createButton = page .getByTestId("message-input-root") .getByRole("button", { name: "Create" }); @@ -176,6 +182,44 @@ export async function clickNewWorkspaceButton( await createButton.click(); } +export async function openStartingRefPicker(page: Page): Promise { + const trigger = page.getByTestId("new-workspace-ref-picker-trigger"); + await expect(trigger).toBeVisible({ timeout: 30_000 }); + await trigger.click(); +} + +export async function selectBranchInPicker(page: Page, name: string): Promise { + const branchRow = page.getByTestId(`new-workspace-ref-picker-branch-${name}`); + await expect(branchRow).toBeVisible({ timeout: 30_000 }); + await branchRow.click(); +} + +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 }); + await prRow.click(); +} + +export async function expectStartingRefPickerTriggerPr( + page: Page, + input: { number: number; title: string; headRef: string }, +): Promise { + const trigger = page.getByTestId("new-workspace-ref-picker-trigger"); + await expect(trigger).toContainText(`#${input.number}`); + await expect(trigger).toContainText(input.title); + await expect(trigger).not.toContainText(input.headRef); +} + +export async function expectComposerGithubAttachmentPill( + page: Page, + input: { number: number; title: string }, +): Promise { + const pills = page.getByTestId("composer-github-attachment-pill"); + await expect(pills).toHaveCount(1); + await expect(pills.first()).toContainText(`#${input.number}`); + await expect(pills.first()).toContainText(input.title); +} + export async function assertNewWorkspaceSidebarAndHeader( page: Page, input: { serverId: string; previousWorkspaceId: string; projectDisplayName: string }, diff --git a/packages/app/e2e/helpers/workspace.ts b/packages/app/e2e/helpers/workspace.ts index 28f4d3365..4b6600367 100644 --- a/packages/app/e2e/helpers/workspace.ts +++ b/packages/app/e2e/helpers/workspace.ts @@ -5,6 +5,7 @@ import path from "node:path"; type TempRepo = { path: string; + branchHeads: Record; cleanup: () => Promise; }; @@ -14,6 +15,7 @@ export const createTempGitRepo = async ( withRemote?: boolean; paseoConfig?: Record; files?: Array<{ path: string; content: string }>; + branches?: string[]; }, ): Promise => { // Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping. @@ -47,19 +49,72 @@ export const createTempGitRepo = async ( } execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: "ignore" }); + const branchHeads: Record = {}; + const branches = Array.from(new Set(options?.branches ?? [])); + for (const branch of branches) { + if (branch !== "main") { + execSync(`git checkout -b ${JSON.stringify(branch)} main`, { + cwd: repoPath, + stdio: "ignore", + }); + } + const markerPath = `.paseo-e2e-${branch.replace(/[^a-zA-Z0-9._-]/g, "-")}.txt`; + await writeFile(path.join(repoPath, markerPath), `branch ${branch}\n`); + execSync(`git add ${JSON.stringify(markerPath)}`, { cwd: repoPath, stdio: "ignore" }); + execSync(`git commit -m ${JSON.stringify(`Add ${branch} marker`)}`, { + cwd: repoPath, + stdio: "ignore", + }); + branchHeads[branch] = execSync(`git rev-parse ${JSON.stringify(branch)}`, { + cwd: repoPath, + stdio: "pipe", + }) + .toString() + .trim(); + execSync("git checkout main", { cwd: repoPath, stdio: "ignore" }); + } + if (withRemote) { // Deterministic local remote to avoid relying on external auth/network in e2e. const remoteDir = path.join(repoPath, "remote.git"); await mkdir(remoteDir, { recursive: true }); execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" }); execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" }); - execSync("git push -u origin main", { cwd: repoPath, stdio: "ignore" }); + execSync("git push -u origin --all", { cwd: repoPath, stdio: "ignore" }); } return { path: repoPath, + branchHeads, cleanup: async () => { await rm(repoPath, { recursive: true, force: true }); }, }; }; + +export async function readWorktreeBranchInfo({ worktreePath }: { worktreePath: string }): Promise<{ + currentBranch: string; + hasAncestor: (ref: string) => boolean; +}> { + const currentBranch = execSync("git branch --show-current", { + cwd: worktreePath, + stdio: "pipe", + }) + .toString() + .trim(); + + return { + currentBranch, + hasAncestor: (ref: string) => { + try { + execSync(`git merge-base --is-ancestor ${JSON.stringify(ref)} HEAD`, { + cwd: worktreePath, + stdio: "ignore", + }); + return true; + } catch { + return false; + } + }, + }; +} diff --git a/packages/app/e2e/new-workspace.spec.ts b/packages/app/e2e/new-workspace.spec.ts index d760b07ec..cdf582dab 100644 --- a/packages/app/e2e/new-workspace.spec.ts +++ b/packages/app/e2e/new-workspace.spec.ts @@ -1,3 +1,5 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; import { buildHostWorkspaceRoute } from "@/utils/host-routes"; import { expect, test } from "./fixtures"; import { gotoAppShell } from "./helpers/app"; @@ -8,9 +10,15 @@ import { clickNewWorkspaceButton, connectNewWorkspaceDaemonClient, createWorktreeViaDaemon, + expectComposerGithubAttachmentPill, + expectStartingRefPickerTriggerPr, + openNewWorkspaceComposer, + openStartingRefPicker, openProjectViaDaemon, + selectBranchInPicker, + selectGitHubPrInPicker, } from "./helpers/new-workspace"; -import { createTempGitRepo } from "./helpers/workspace"; +import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace"; import { expectSidebarWorkspaceSelected, expectWorkspaceHeader, @@ -254,4 +262,94 @@ test.describe("New workspace flow", () => { await tempRepo.cleanup(); } }); + + test("selected branch becomes the base of a new workspace worktree", async ({ page }) => { + const serverId = process.env.E2E_SERVER_ID; + if (!serverId) { + throw new Error("E2E_SERVER_ID is not set."); + } + + const tempRepo = await createTempGitRepo("new-workspace-ref-", { + branches: ["main", "dev"], + }); + + try { + const openedProject = await openProjectViaDaemon(client, tempRepo.path); + localWorkspaceIds.add(openedProject.workspaceId); + + await gotoAppShell(page); + await waitForSidebarHydration(page); + + await switchWorkspaceViaSidebar({ + page, + serverId, + targetWorkspacePath: openedProject.workspaceId, + }); + await expectWorkspaceHeader(page, { + title: openedProject.workspaceName, + subtitle: openedProject.projectDisplayName, + }); + + await openNewWorkspaceComposer(page, { + projectKey: openedProject.projectKey, + projectDisplayName: openedProject.projectDisplayName, + }); + await openStartingRefPicker(page); + await selectBranchInPicker(page, "dev"); + + const createButton = page + .getByTestId("message-input-root") + .getByRole("button", { name: "Create" }); + await expect(createButton).toBeVisible({ timeout: 30_000 }); + await createButton.click(); + + const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, { + serverId, + previousWorkspaceId: openedProject.workspaceId, + projectDisplayName: openedProject.projectDisplayName, + }); + createdWorktreeIds.add(createdWorkspace.workspaceId); + + expect(existsSync(createdWorkspace.workspaceId)).toBe(true); + + const branchInfo = await readWorktreeBranchInfo({ + worktreePath: createdWorkspace.workspaceId, + }); + expect(branchInfo.currentBranch).toBe(path.basename(createdWorkspace.workspaceId)); + expect(branchInfo.hasAncestor(tempRepo.branchHeads.main)).toBe(true); + expect(branchInfo.hasAncestor(tempRepo.branchHeads.dev)).toBe(true); + } finally { + await tempRepo.cleanup(); + } + }); + + test("selected GitHub PR shows PR context in the trigger and composer", async ({ page }) => { + const tempRepo = await createTempGitRepo("new-workspace-pr-ref-"); + + try { + const openedProject = await openProjectViaDaemon(client, tempRepo.path); + localWorkspaceIds.add(openedProject.workspaceId); + + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openNewWorkspaceComposer(page, { + projectKey: openedProject.projectKey, + projectDisplayName: openedProject.projectDisplayName, + }); + await openStartingRefPicker(page); + await selectGitHubPrInPicker(page, 515); + + await expectStartingRefPickerTriggerPr(page, { + number: 515, + title: "Review selected start ref", + headRef: "feature/start-from-pr", + }); + await expectComposerGithubAttachmentPill(page, { + number: 515, + title: "Review selected start ref", + }); + } finally { + await tempRepo.cleanup(); + } + }); }); diff --git a/packages/app/src/components/agent-input-submit.test.ts b/packages/app/src/components/agent-input-submit.test.ts index c01390ce9..62926b419 100644 --- a/packages/app/src/components/agent-input-submit.test.ts +++ b/packages/app/src/components/agent-input-submit.test.ts @@ -59,6 +59,51 @@ describe("submitAgentInput", () => { expect(clearDraft).toHaveBeenCalledWith("sent"); }); + it("preserves the composer before an in-flight submit resolves when requested", async () => { + const deferred = createDeferredPromise(); + const attachments = [{ id: "img-1" }]; + const queueMessage = vi.fn(); + const submitMessage = vi.fn(async () => { + await deferred.promise; + }); + const clearDraft = vi.fn(); + const setUserInput = vi.fn(); + const setAttachments = vi.fn(); + const setSendError = vi.fn(); + const setIsProcessing = vi.fn(); + + const submitPromise = submitAgentInput({ + message: " keep me ", + attachments, + submitBehavior: "preserve-and-lock", + isAgentRunning: false, + canSubmit: true, + queueMessage, + submitMessage, + clearDraft, + setUserInput, + setAttachments, + setSendError, + setIsProcessing, + }); + + expect(queueMessage).not.toHaveBeenCalled(); + expect(submitMessage).toHaveBeenCalledWith({ + message: "keep me", + attachments, + }); + expect(setUserInput).not.toHaveBeenCalled(); + expect(setAttachments).not.toHaveBeenCalled(); + expect(setSendError).toHaveBeenCalledWith(null); + expect(setIsProcessing).toHaveBeenCalledWith(true); + expect(clearDraft).not.toHaveBeenCalled(); + + deferred.resolve(); + + await expect(submitPromise).resolves.toBe("submitted"); + expect(clearDraft).toHaveBeenCalledWith("sent"); + }); + it("queues while the agent is running and clears the composer immediately", async () => { const queueMessage = vi.fn(); const submitMessage = vi.fn(); diff --git a/packages/app/src/components/agent-input-submit.ts b/packages/app/src/components/agent-input-submit.ts index f8667107f..977799a5f 100644 --- a/packages/app/src/components/agent-input-submit.ts +++ b/packages/app/src/components/agent-input-submit.ts @@ -5,6 +5,7 @@ export interface AgentInputSubmitActionInput { attachments: TAttachment[]; hasExternalContent?: boolean; allowEmptySubmit?: boolean; + submitBehavior?: "clear" | "preserve-and-lock"; forceSend?: boolean; isAgentRunning: boolean; canSubmit: boolean; @@ -23,6 +24,7 @@ export async function submitAgentInput( ): Promise { const trimmedMessage = input.message.trim(); const attachments = input.attachments; + const shouldClearOnSubmit = input.submitBehavior !== "preserve-and-lock"; if ( !trimmedMessage && @@ -39,14 +41,18 @@ export async function submitAgentInput( if (input.isAgentRunning && !input.forceSend) { input.queueMessage({ message: trimmedMessage, attachments }); - input.setUserInput(""); - input.setAttachments([]); + if (shouldClearOnSubmit) { + input.setUserInput(""); + input.setAttachments([]); + } return "queued"; } // Clear immediately so optimistic stream updates and composer state stay in sync. - input.setUserInput(""); - input.setAttachments([]); + if (shouldClearOnSubmit) { + input.setUserInput(""); + input.setAttachments([]); + } input.setSendError(null); input.setIsProcessing(true); @@ -57,8 +63,10 @@ export async function submitAgentInput( return "submitted"; } catch (error) { input.onSubmitError?.(error); - input.setUserInput(trimmedMessage); - input.setAttachments(attachments); + if (shouldClearOnSubmit) { + input.setUserInput(trimmedMessage); + input.setAttachments(attachments); + } input.setSendError(error instanceof Error ? error.message : "Failed to send message"); input.setIsProcessing(false); return "failed"; diff --git a/packages/app/src/components/attachment-pill.tsx b/packages/app/src/components/attachment-pill.tsx index 4edf1ef04..069b26000 100644 --- a/packages/app/src/components/attachment-pill.tsx +++ b/packages/app/src/components/attachment-pill.tsx @@ -10,6 +10,7 @@ interface AttachmentPillProps { onRemove: () => void; openAccessibilityLabel: string; removeAccessibilityLabel: string; + disabled?: boolean; testID?: string; children: ReactNode; } @@ -19,6 +20,7 @@ export function AttachmentPill({ onRemove, openAccessibilityLabel, removeAccessibilityLabel, + disabled = false, testID, children, }: AttachmentPillProps) { @@ -33,6 +35,7 @@ export function AttachmentPill({ setIsBodyHovered(true)} onHoverOut={() => setIsBodyHovered(false)} accessibilityRole="button" @@ -43,6 +46,7 @@ export function AttachmentPill({ setIsCloseHovered(true)} onHoverOut={() => setIsCloseHovered(false)} hitSlop={8} diff --git a/packages/app/src/components/composer.test.tsx b/packages/app/src/components/composer.test.tsx index 1aa672b47..8316ebc17 100644 --- a/packages/app/src/components/composer.test.tsx +++ b/packages/app/src/components/composer.test.tsx @@ -176,7 +176,7 @@ vi.mock("lucide-react-native", () => { const createIcon = (name: string) => (props: Record) => React.createElement("span", { ...props, "data-icon": name }); return { - ArrowUp: createIcon("ArrowUp"), + CornerDownLeft: createIcon("CornerDownLeft"), Square: createIcon("Square"), Pencil: createIcon("Pencil"), AudioLines: createIcon("AudioLines"), @@ -579,9 +579,13 @@ function imageAttachment(id: string): AttachmentMetadata { function ComposerHarness({ initialText = "", initialAttachments = [], + isSubmitLoading = false, + submitBehavior, }: { initialText?: string; initialAttachments?: ComposerAttachment[]; + isSubmitLoading?: boolean; + submitBehavior?: "clear" | "preserve-and-lock"; }) { const [text, setText] = useState(initialText); const [attachments, setAttachments] = useState(initialAttachments); @@ -603,6 +607,8 @@ function ComposerHarness({ return next; }); }} + isSubmitLoading={isSubmitLoading} + submitBehavior={submitBehavior} cwd="/repo" clearDraft={vi.fn()} /> @@ -611,7 +617,12 @@ function ComposerHarness({ } function renderComposer( - input: { initialText?: string; initialAttachments?: ComposerAttachment[] } = {}, + input: { + initialText?: string; + initialAttachments?: ComposerAttachment[]; + isSubmitLoading?: boolean; + submitBehavior?: "clear" | "preserve-and-lock"; + } = {}, ) { act(() => { root?.render(); @@ -835,6 +846,32 @@ describe("Composer attachments", () => { expect(latestAttachments).toEqual([{ kind: "image", metadata: image }]); }); + it("locks the preserved draft while submit loading", () => { + renderComposer({ + initialText: "keep this prompt", + initialAttachments: [{ kind: "github_pr", item: prItem }], + isSubmitLoading: true, + submitBehavior: "preserve-and-lock", + }); + + const textInput = document.querySelector('[aria-label="Message agent..."]'); + const attachButton = queryByTestId("message-input-attach-button"); + const pill = queryByTestId("composer-github-attachment-pill"); + const removeButton = document.querySelector(`[aria-label="Remove PR #${prItem.number}"]`); + + expect(textInput).toHaveProperty("readOnly", true); + expect(textInput).toHaveProperty("value", "keep this prompt"); + expect(attachButton).toHaveProperty("disabled", true); + expect(pill).not.toBeNull(); + expect(removeButton).not.toBeNull(); + + click(pill!); + click(removeButton!); + + expect(openExternalUrlMock).not.toHaveBeenCalled(); + expect(latestAttachments).toEqual([{ kind: "github_pr", item: prItem }]); + }); + it("closes the image lightbox when its close button is pressed", () => { const image = imageAttachment("img-close"); renderComposer({ initialAttachments: [{ kind: "image", metadata: image }] }); diff --git a/packages/app/src/components/composer.tsx b/packages/app/src/components/composer.tsx index fc78ae875..f0b8f6269 100644 --- a/packages/app/src/components/composer.tsx +++ b/packages/app/src/components/composer.tsx @@ -102,6 +102,7 @@ interface ComposerProps { submitButtonAccessibilityLabel?: string; /** Externally controlled loading state. When true, disables the submit button. */ isSubmitLoading?: boolean; + submitBehavior?: "clear" | "preserve-and-lock"; /** When true, blurs the input immediately when submitting. */ blurOnSubmit?: boolean; value: string; @@ -142,6 +143,7 @@ export function Composer({ allowEmptySubmit = false, submitButtonAccessibilityLabel, isSubmitLoading = false, + submitBehavior = "clear", blurOnSubmit = false, value, onChangeText, @@ -218,6 +220,7 @@ export function Composer({ const [lightboxMetadata, setLightboxMetadata] = useState(null); const attachButtonRef = useRef(null); const messageInputRef = useRef(null); + const isComposerLocked = submitBehavior === "preserve-and-lock" && isSubmitLoading; const keyboardHandlerIdRef = useRef( `message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`, ); @@ -401,6 +404,7 @@ export function Composer({ hasExternalContent, allowEmptySubmit, forceSend, + submitBehavior, isAgentRunning: agentState.status === "running", // Parent-managed submits are still valid submit paths even when the // transport is disconnected, because the parent decides the failure mode. @@ -815,7 +819,7 @@ export function Composer({ > setLightboxMetadata(null)} /> {/* Input area */} - + {/* Queue list */} {queuedMessages.length > 0 && ( @@ -874,6 +878,7 @@ export function Composer({ onRemove={() => handleRemoveAttachment(index)} openAccessibilityLabel="Open image attachment" removeAccessibilityLabel="Remove image attachment" + disabled={isComposerLocked} > @@ -890,6 +895,7 @@ export function Composer({ onRemove={() => handleRemoveAttachment(index)} openAccessibilityLabel={`Open ${kindLabel} #${item.number}`} removeAccessibilityLabel={`Remove ${kindLabel} #${item.number}`} + disabled={isComposerLocked} > @@ -1043,6 +1049,9 @@ const styles = StyleSheet.create(((theme: Theme) => ({ overflow: "visible", padding: theme.spacing[4], }, + inputAreaLocked: { + opacity: 0.6, + }, inputAreaContent: { width: "100%", maxWidth: MAX_CONTENT_WIDTH, diff --git a/packages/app/src/components/message-input.test.tsx b/packages/app/src/components/message-input.test.tsx index ba7473b69..ebe016187 100644 --- a/packages/app/src/components/message-input.test.tsx +++ b/packages/app/src/components/message-input.test.tsx @@ -51,7 +51,7 @@ vi.mock("lucide-react-native", () => { return { Mic: createIcon("Mic"), MicOff: createIcon("MicOff"), - ArrowUp: createIcon("ArrowUp"), + CornerDownLeft: createIcon("CornerDownLeft"), Plus: createIcon("Plus"), Square: createIcon("Square"), }; diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 029e5e737..34074155a 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -20,7 +20,7 @@ import { forwardRef, } from "react"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { Mic, MicOff, ArrowUp, Plus, Square } from "lucide-react-native"; +import { Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native"; import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated"; import { useDictation } from "@/hooks/use-dictation"; import { DictationOverlay } from "./dictation-controls"; @@ -1181,7 +1181,7 @@ export const MessageInput = forwardRef(funct {isSubmitLoading ? ( ) : ( - + )} diff --git a/packages/app/src/components/ui/tooltip.test.tsx b/packages/app/src/components/ui/tooltip.test.tsx new file mode 100644 index 000000000..78f634574 --- /dev/null +++ b/packages/app/src/components/ui/tooltip.test.tsx @@ -0,0 +1,116 @@ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { JSDOM } from "jsdom"; +import { Pressable, Text } from "react-native"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Tooltip, TooltipTrigger } from "./tooltip"; + +vi.mock("@/constants/platform", () => ({ + isWeb: true, + isNative: false, +})); + +vi.mock("@/constants/layout", () => ({ + useIsCompactFormFactor: () => false, +})); + +vi.mock("@gorhom/portal", () => ({ + Portal: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +vi.mock("@gorhom/bottom-sheet", () => ({ + useBottomSheetModalInternal: () => null, +})); + +vi.mock("react-native-reanimated", () => ({ + default: { + View: "div", + }, + FadeIn: {}, + FadeOut: {}, +})); + +vi.mock("react-native-unistyles", () => ({ + StyleSheet: { + create: (styles: unknown) => styles, + }, +})); + +let root: Root | null = null; +let container: HTMLElement | null = null; + +beforeEach(() => { + const dom = new JSDOM(""); + vi.stubGlobal("React", React); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", dom.window); + vi.stubGlobal("document", dom.window.document); + vi.stubGlobal("HTMLElement", dom.window.HTMLElement); + vi.stubGlobal("Node", dom.window.Node); + vi.stubGlobal("navigator", dom.window.navigator); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + root = null; + container = null; + vi.unstubAllGlobals(); +}); + +function renderTrigger({ + childDisabled, + onPress, +}: { + childDisabled: boolean; + onPress: () => void; +}): void { + act(() => { + root?.render( + + + + Send + + + , + ); + }); +} + +function pressTrigger(): void { + const trigger = container?.querySelector('[data-testid="trigger"]'); + expect(trigger).not.toBeNull(); + + act(() => { + trigger?.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); + }); +} + +describe("TooltipTrigger", () => { + it("keeps an asChild trigger disabled when the child is disabled", () => { + const onPress = vi.fn(); + + renderTrigger({ childDisabled: true, onPress }); + pressTrigger(); + + expect(onPress).not.toHaveBeenCalled(); + }); + + it("keeps an asChild trigger interactive when the child is not disabled", () => { + const onPress = vi.fn(); + + renderTrigger({ childDisabled: false, onPress }); + pressTrigger(); + + expect(onPress).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/app/src/components/ui/tooltip.tsx b/packages/app/src/components/ui/tooltip.tsx index b1257264a..add551c15 100644 --- a/packages/app/src/components/ui/tooltip.tsx +++ b/packages/app/src/components/ui/tooltip.tsx @@ -386,6 +386,7 @@ export function TooltipTrigger({ const mergedProps = { ...childProps, ...triggerProps, + disabled: childProps.disabled || disabled, onHoverIn: composeEventHandlers(childProps.onHoverIn, handleHoverIn), onHoverOut: composeEventHandlers(childProps.onHoverOut, handleHoverOut), onFocus: composeEventHandlers(childProps.onFocus, handleFocus), diff --git a/packages/app/src/screens/new-workspace-picker-item.test.ts b/packages/app/src/screens/new-workspace-picker-item.test.ts new file mode 100644 index 000000000..6343ad061 --- /dev/null +++ b/packages/app/src/screens/new-workspace-picker-item.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import type { GitHubSearchItem } from "@server/shared/messages"; +import { pickerItemToCheckoutRequest, type PickerItem } from "./new-workspace-picker-item"; + +const prItem: GitHubSearchItem = { + kind: "pr", + number: 42, + title: "Add picker", + url: "https://example.com/pull/42", + state: "open", + body: null, + labels: [], + baseRefName: "main", + headRefName: "feature/picker", +}; + +describe("pickerItemToCheckoutRequest", () => { + it("returns undefined for no selection (null)", () => { + expect(pickerItemToCheckoutRequest(null)).toBeUndefined(); + }); + + it("maps a branch row to branch-off with the branch name", () => { + const item: PickerItem = { kind: "branch", name: "dev" }; + expect(pickerItemToCheckoutRequest(item)).toEqual({ + action: "branch-off", + refName: "dev", + }); + }); + + it("maps a github-pr row to checkout using the head ref and pr number", () => { + const item: PickerItem = { + kind: "github-pr", + item: prItem, + }; + expect(pickerItemToCheckoutRequest(item)).toEqual({ + action: "checkout", + refName: "feature/picker", + githubPrNumber: 42, + }); + }); + + it("handles a github-pr with a null baseRef", () => { + const item: PickerItem = { + kind: "github-pr", + item: { + ...prItem, + number: 7, + title: "Orphan branch", + baseRefName: null, + headRefName: "orphan", + }, + }; + expect(pickerItemToCheckoutRequest(item)).toEqual({ + action: "checkout", + refName: "orphan", + githubPrNumber: 7, + }); + }); +}); diff --git a/packages/app/src/screens/new-workspace-picker-item.ts b/packages/app/src/screens/new-workspace-picker-item.ts new file mode 100644 index 000000000..77808b8a4 --- /dev/null +++ b/packages/app/src/screens/new-workspace-picker-item.ts @@ -0,0 +1,30 @@ +import type { CreatePaseoWorktreeInput } from "@server/client/daemon-client"; +import type { GitHubSearchItem } from "@server/shared/messages"; + +export type PickerItem = + | { kind: "branch"; name: string } + | { + kind: "github-pr"; + item: GitHubSearchItem; + }; + +export type PickerCheckoutRequest = Pick< + CreatePaseoWorktreeInput, + "action" | "refName" | "githubPrNumber" +>; + +export function pickerItemToCheckoutRequest( + item: PickerItem | null, +): PickerCheckoutRequest | undefined { + if (!item) return undefined; + switch (item.kind) { + case "branch": + return { action: "branch-off", refName: item.name }; + case "github-pr": + return { + action: "checkout", + refName: item.item.headRefName ?? "", + githubPrNumber: item.item.number, + }; + } +} diff --git a/packages/app/src/screens/new-workspace-screen.test.tsx b/packages/app/src/screens/new-workspace-screen.test.tsx new file mode 100644 index 000000000..2e9191bcc --- /dev/null +++ b/packages/app/src/screens/new-workspace-screen.test.tsx @@ -0,0 +1,781 @@ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { JSDOM } from "jsdom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ComposerAttachment } from "@/attachments/types"; +import type { CreatePaseoWorktreeInput } from "@server/client/daemon-client"; +import type { GitHubSearchItem } from "@server/shared/messages"; +import { NewWorkspaceScreen } from "./new-workspace-screen"; + +const { + theme, + mockClient, + mergeWorkspacesMock, + setAgentsMock, + navigateMock, + createdAgent, + createdWorkspace, + prItem, + prItemB, + issueItem, + initialAttachments, + initialDraftState, +} = vi.hoisted(() => { + const theme = { + spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 }, + iconSize: { sm: 14, md: 18, lg: 22 }, + borderWidth: { 1: 1 }, + borderRadius: { md: 6, lg: 8, "2xl": 16 }, + fontSize: { xs: 11, sm: 13, base: 15, lg: 18 }, + fontWeight: { medium: "500" }, + shadow: { md: {} }, + colors: { + surface0: "#000", + surface1: "#111", + surface2: "#222", + foreground: "#fff", + foregroundMuted: "#aaa", + popoverForeground: "#fff", + border: "#555", + destructive: "#ff453a", + palette: { + zinc: { 600: "#52525b" }, + }, + }, + }; + + const prItem: GitHubSearchItem = { + kind: "pr", + number: 202, + title: "Refactor picker", + url: "https://example.com/pull/202", + state: "open", + body: null, + labels: [], + baseRefName: "main", + headRefName: "feature/picker", + }; + + const prItemB: GitHubSearchItem = { + kind: "pr", + number: 303, + title: "Polish composer chip", + url: "https://example.com/pull/303", + state: "open", + body: null, + labels: [], + baseRefName: "main", + headRefName: "feature/composer-chip", + }; + + const issueItem: GitHubSearchItem = { + kind: "issue", + number: 44, + title: "Keep manual attachment", + url: "https://example.com/issues/44", + state: "open", + body: null, + labels: [], + }; + + const initialAttachments: ComposerAttachment[] = []; + const initialDraftState = { text: "" }; + + const createdWorkspace = { + id: "workspace-1", + workspaceDirectory: "/repo/.paseo/worktrees/workspace-1", + }; + + const createdAgent = { + id: "agent-1", + cwd: createdWorkspace.workspaceDirectory, + }; + + const mockClient = { + isConnected: true, + getCheckoutStatus: vi.fn(async () => ({ currentBranch: "main" })), + getBranchSuggestions: vi.fn(async () => ({ branches: ["main", "dev", "feat/x"] })), + searchGitHub: vi.fn(async () => ({ + items: [prItem, prItemB], + githubFeaturesEnabled: true, + error: null, + })), + createPaseoWorktree: vi.fn(async (_input: CreatePaseoWorktreeInput) => ({ + workspace: createdWorkspace, + error: null, + })), + createAgent: vi.fn(async () => createdAgent), + }; + + return { + theme, + mockClient, + mergeWorkspacesMock: vi.fn(), + setAgentsMock: vi.fn(), + navigateMock: vi.fn(), + createdAgent, + createdWorkspace, + prItem, + prItemB, + issueItem, + initialAttachments, + initialDraftState, + }; +}); + +vi.mock("react-native-unistyles", () => ({ + StyleSheet: { + create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory), + }, + useUnistyles: () => ({ theme }), +})); + +vi.mock("@/constants/platform", () => ({ + isWeb: true, + isNative: false, +})); + +vi.mock("@/constants/layout", () => ({ + HEADER_INNER_HEIGHT: 48, + HEADER_INNER_HEIGHT_MOBILE: 56, + HEADER_TOP_PADDING_MOBILE: 8, + MAX_CONTENT_WIDTH: 900, + useIsCompactFormFactor: () => false, +})); + +vi.mock("lucide-react-native", () => { + const createIcon = (name: string) => (props: Record) => + React.createElement("span", { ...props, "data-icon": name }); + return { + ChevronDown: createIcon("ChevronDown"), + GitBranch: createIcon("GitBranch"), + GitPullRequest: createIcon("GitPullRequest"), + }; +}); + +vi.mock("@/runtime/host-runtime", () => ({ + useHostRuntimeClient: () => mockClient, + useHostRuntimeIsConnected: () => true, +})); + +vi.mock("@/stores/session-store", () => ({ + useSessionStore: (selector: (state: unknown) => unknown) => + selector({ + mergeWorkspaces: mergeWorkspacesMock, + setAgents: setAgentsMock, + }), + normalizeWorkspaceDescriptor: (workspace: unknown) => workspace, +})); + +vi.mock("@/utils/agent-snapshots", () => ({ + normalizeAgentSnapshot: (agent: unknown) => agent, +})); + +vi.mock("@/utils/encode-images", () => ({ + encodeImages: vi.fn(async () => []), +})); + +vi.mock("@/utils/error-messages", () => ({ + toErrorMessage: (error: unknown) => (error instanceof Error ? error.message : String(error)), +})); + +vi.mock("@/utils/workspace-execution", () => ({ + requireWorkspaceExecutionAuthority: (input: { workspace: { workspaceDirectory: string } }) => ({ + workspaceDirectory: input.workspace.workspaceDirectory, + }), +})); + +vi.mock("@/utils/workspace-navigation", () => ({ + navigateToPreparedWorkspaceTab: navigateMock, +})); + +vi.mock("@/contexts/toast-context", () => ({ + useToast: () => ({ error: vi.fn() }), +})); + +vi.mock("@/hooks/use-agent-input-draft", () => ({ + useAgentInputDraft: () => { + const [attachments, setAttachments] = React.useState(initialAttachments); + const [text, setText] = React.useState(initialDraftState.text); + return { + text, + setText, + attachments, + setAttachments, + cwd: "/repo", + composerState: { + selectedProvider: "claude-code", + selectedMode: "", + modeOptions: [], + effectiveModelId: null, + effectiveThinkingOptionId: null, + commandDraftConfig: undefined, + statusControls: {}, + }, + }; + }, +})); + +vi.mock("@/components/composer", () => ({ + Composer: ({ + onSubmitMessage, + submitBehavior, + isSubmitLoading, + value, + onChangeText, + attachments, + onChangeAttachments, + }: { + onSubmitMessage: (payload: { + text: string; + attachments: ComposerAttachment[]; + cwd: string; + }) => void; + submitBehavior?: "clear" | "preserve-and-lock"; + isSubmitLoading?: boolean; + value: string; + onChangeText: (text: string) => void; + attachments: ComposerAttachment[]; + onChangeAttachments: (attachments: ComposerAttachment[]) => void; + }) => ( +
+