From 53155ebcc995a19e3a53c27bbb1433b4b3c3d9ff Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 23 Jan 2026 08:37:15 +0700 Subject: [PATCH] feat(app): checkout diff pane styling and e2e test support - Style diff file cards edge-to-edge with single divider between files - Remove monospace font from filenames for better readability - Add checkout-ship e2e test for app - Export Page type from e2e fixtures - Add checkout query hooks for diff, status, and PR status --- packages/app/e2e/checkout-ship.spec.ts | 327 +++++++++++ packages/app/e2e/fixtures.ts | 2 +- packages/app/e2e/helpers/app.ts | 67 ++- packages/app/e2e/helpers/workspace.ts | 41 +- packages/app/src/app/index.tsx | 144 ++++- .../agent-form/agent-form-dropdowns.tsx | 104 +++- packages/app/src/components/git-diff-pane.tsx | 506 ++++++++++++++++-- .../app/src/hooks/use-checkout-diff-query.ts | 92 ++++ .../src/hooks/use-checkout-pr-status-query.ts | 53 ++ .../src/hooks/use-checkout-status-query.ts | 47 ++ packages/app/src/utils/extract-agent-model.ts | 11 +- .../daemon-e2e/checkout-ship.e2e.test.ts | 13 +- packages/server/src/server/session.ts | 69 ++- .../server/src/utils/checkout-git.test.ts | 26 +- packages/server/src/utils/checkout-git.ts | 83 ++- 15 files changed, 1450 insertions(+), 135 deletions(-) create mode 100644 packages/app/e2e/checkout-ship.spec.ts create mode 100644 packages/app/src/hooks/use-checkout-diff-query.ts create mode 100644 packages/app/src/hooks/use-checkout-pr-status-query.ts create mode 100644 packages/app/src/hooks/use-checkout-status-query.ts diff --git a/packages/app/e2e/checkout-ship.spec.ts b/packages/app/e2e/checkout-ship.spec.ts new file mode 100644 index 000000000..06c74e6fe --- /dev/null +++ b/packages/app/e2e/checkout-ship.spec.ts @@ -0,0 +1,327 @@ +import path from 'node:path'; +import { appendFile, mkdtemp, rm, writeFile, realpath } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { test, expect, type Page } from './fixtures'; +import { + allowPermission, + ensureHostSelected, + gotoHome, + setWorkingDirectory, + waitForPermissionPrompt, +} from './helpers/app'; +import { createTempGitRepo } from './helpers/workspace'; + +test.describe.configure({ mode: 'serial', timeout: 120000 }); + +function getChangesScope(page: Page) { + return page.locator('[data-testid="explorer-content-area"]:visible').first(); +} + +function getChangesHeader(page: Page) { + return getChangesScope(page).getByTestId('changes-header'); +} + +function getChangesActionLabel(page: Page, label: string) { + return getChangesScope(page).getByText(label, { exact: true }); +} + +function getChangesActionButton(page: Page, label: string) { + return getChangesActionLabel(page, label).locator('..'); +} + +async function openChangesPanel(page: Page, options?: { expectGit?: boolean }) { + const changesHeader = getChangesHeader(page); + if (!(await changesHeader.isVisible())) { + const explorerHeader = page.getByTestId('explorer-header'); + if (await explorerHeader.isVisible()) { + await page.getByText('Changes', { exact: true }).click(); + } else { + const overflowMenu = page.getByTestId('agent-overflow-menu').first(); + await expect(overflowMenu).toBeVisible({ timeout: 10000 }); + await overflowMenu.click(); + await page.getByText('View Changes', { exact: true }).click(); + } + } + await expect(changesHeader).toBeVisible(); + if (options?.expectGit === false) { + return; + } + const changesScope = getChangesScope(page); + await expect(changesScope.getByTestId('changes-not-git')).toHaveCount(0, { + timeout: 30000, + }); + await expect(changesScope.getByTestId('changes-branch')).not.toHaveText('Not a git repository', { + timeout: 30000, + }); +} + +async function sendPrompt(page: Page, prompt: string) { + const input = page.getByRole('textbox', { name: 'Message agent...' }); + await expect(input).toBeEditable(); + await input.fill(prompt); + await input.press('Enter'); +} + +async function waitForAssistantText(page: Page, text: string) { + const assistantMessage = page.getByTestId('assistant-message').filter({ hasText: text }).last(); + await expect(assistantMessage).toBeVisible({ timeout: 60000 }); + return assistantMessage; +} + +async function waitForAssistantTextWithPermissions( + page: Page, + text: string, + timeoutMs = 60000 +) { + const start = Date.now(); + const assistantMessage = page + .getByTestId('assistant-message') + .filter({ hasText: text }) + .last(); + while (Date.now() - start < timeoutMs) { + if (await assistantMessage.isVisible()) { + return assistantMessage; + } + const allowButton = page.getByText('Allow', { exact: true }).first(); + if (await allowButton.isVisible()) { + try { + await allowButton.click({ force: true, timeout: 1000 }); + } catch { + // Button can detach during animation; retry on next loop. + } + continue; + } + await page.waitForTimeout(500); + } + throw new Error(`Timed out waiting for assistant text: ${text}`); +} + +async function createAgentAndWait(page: Page, message: string) { + const input = page.getByRole('textbox', { name: 'Message agent...' }); + await expect(input).toBeEditable(); + await input.fill(message); + await input.press('Enter'); + await expect(page).toHaveURL(/\/agent\//, { timeout: 120000 }); + await expect(page.getByText(message, { exact: true })).toBeVisible(); +} + +async function requestCwd(page: Page) { + await sendPrompt(page, 'Run `pwd` and respond with exactly: CWD: '); + const message = await waitForAssistantText(page, 'CWD:'); + const content = await message.innerText(); + const match = content.match(/CWD:\s*(\S+)/); + if (!match) { + throw new Error(`Expected agent to respond with "CWD: ", got: ${content}`); + } + return match[1].trim(); +} + +async function selectAttachWorktree(page: Page, branchName: string) { + await page.getByTestId('worktree-attach-toggle').click(); + const picker = page.getByTestId('worktree-attach-picker'); + await expect(picker).toBeVisible(); + await picker.click(); + const sheet = page.getByLabel('Bottom Sheet', { exact: true }); + const backdrop = page.getByRole('button', { name: 'Bottom sheet backdrop' }).first(); + await expect.poll(async () => { + const sheetVisible = await sheet.isVisible().catch(() => false); + const backdropVisible = await backdrop.isVisible().catch(() => false); + return sheetVisible || backdropVisible; + }).toBeTruthy(); + const sheetVisible = await sheet.isVisible().catch(() => false); + const scope = sheetVisible ? sheet : page; + const option = scope.getByText(branchName, { exact: true }).first(); + await expect(option).toBeVisible(); + await option.click(); + await expect(picker).toContainText(branchName); +} + +async function enableCreateWorktree(page: Page) { + const createToggle = page.getByTestId('worktree-create-toggle'); + const willCreateLabel = page.getByText(/Will create:/); + if (await willCreateLabel.isVisible()) { + return; + } + const readyLabel = page.getByText( + /Run isolated from|Run in an isolated directory/ + ); + await expect(readyLabel).toBeVisible({ timeout: 30000 }); + await createToggle.click({ force: true }); + await expect(willCreateLabel).toBeVisible({ timeout: 30000 }); +} + +async function refreshUncommittedMode(page: Page) { + const changesScope = getChangesScope(page); + await changesScope.getByTestId('changes-mode-base').click(); + await changesScope.getByTestId('changes-mode-uncommitted').click(); +} + +async function refreshChangesTab(page: Page) { + const header = page.locator('[data-testid="explorer-header"]:visible').first(); + await header.getByText('Files', { exact: true }).first().click(); + await header.getByText('Changes', { exact: true }).first().click(); +} + +function normalizeTmpPath(value: string) { + if (value.startsWith('/var/')) { + return `/private${value}`; + } + return value; +} + +test('checkout-first Changes panel ship loop', async ({ page }) => { + const repo = await createTempGitRepo('paseo-e2e-', { withRemote: true }); + const nonGitDir = await mkdtemp(path.join(tmpdir(), 'paseo-e2e-non-git-')); + + try { + await gotoHome(page); + await setWorkingDirectory(page, repo.path); + await ensureHostSelected(page); + + await enableCreateWorktree(page); + await createAgentAndWait(page, 'Respond with exactly: READY'); + await waitForAssistantText(page, 'READY'); + + await openChangesPanel(page); + const branchName = (await getChangesScope(page).getByTestId('changes-branch').innerText()).trim(); + expect(branchName.length).toBeGreaterThan(0); + + const firstCwd = await requestCwd(page); + const [resolvedCwd, resolvedRepo] = await Promise.all([ + realpath(firstCwd).catch(() => firstCwd), + realpath(repo.path).catch(() => repo.path), + ]); + const normalizedRepo = normalizeTmpPath(resolvedRepo); + const normalizedCwd = normalizeTmpPath(resolvedCwd); + const expectedRoot = path.join(normalizedRepo, '.paseo', 'worktrees'); + const expectedRootRaw = normalizeTmpPath( + path.join(repo.path, '.paseo', 'worktrees') + ); + expect( + normalizedCwd.startsWith(expectedRoot) || + normalizedCwd.startsWith(expectedRootRaw) + ).toBeTruthy(); + + await page.getByTestId('sidebar-new-agent').click(); + await expect(page).toHaveURL(/\/$/); + + await setWorkingDirectory(page, repo.path); + await ensureHostSelected(page); + await selectAttachWorktree(page, branchName); + await createAgentAndWait(page, 'Respond with exactly: READY2'); + await waitForAssistantText(page, 'READY2'); + + const secondCwd = await requestCwd(page); + expect(secondCwd).toBe(firstCwd); + + await sendPrompt( + page, + 'Only call MCP tools set_title("E2E Ship Loop") and set_branch("feat/e2e-ship-loop"). Do not run bash or other tools. Then respond with exactly: OK' + ); + await waitForAssistantTextWithPermissions(page, 'OK', 60000); + await expect(page.getByText('E2E Ship Loop', { exact: true }).first()).toBeVisible(); + + await openChangesPanel(page); + await expect.poll( + async () => (await getChangesScope(page).getByTestId('changes-branch').innerText()).trim(), + { timeout: 60000 } + ).toBe('feat/e2e-ship-loop'); + + const readmePath = path.join(firstCwd, 'README.md'); + await appendFile(readmePath, '\nFirst change\n'); + + await refreshUncommittedMode(page); + await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({ + timeout: 30000, + }); + await getChangesScope(page).getByTestId('diff-file-0-toggle').first().click(); + await expect(page.getByText('First change')).toBeVisible(); + await expect(getChangesActionLabel(page, 'Commit')).toBeVisible(); + + await getChangesActionButton(page, 'Commit').click(); + await getChangesScope(page).getByTestId('changes-mode-uncommitted').click(); + await refreshChangesTab(page); + await expect(getChangesScope(page).getByText('No uncommitted changes')).toBeVisible({ + timeout: 30000, + }); + await expect(getChangesActionLabel(page, 'Commit')).toHaveCount(0); + + await getChangesScope(page).getByTestId('changes-mode-base').click(); + await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({ + timeout: 30000, + }); + + const notesPath = path.join(firstCwd, 'notes.txt'); + await writeFile(notesPath, 'Second change\n'); + + await refreshUncommittedMode(page); + await refreshChangesTab(page); + await expect(getChangesScope(page).getByText('notes.txt', { exact: true })).toBeVisible({ + timeout: 30000, + }); + await expect(getChangesScope(page).getByText('README.md', { exact: true })).toHaveCount(0); + await expect(getChangesActionLabel(page, 'Commit')).toBeVisible(); + + await getChangesScope(page).getByTestId('changes-mode-base').click(); + await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({ + timeout: 30000, + }); + + await getChangesActionButton(page, 'Commit').click(); + await getChangesScope(page).getByTestId('changes-mode-uncommitted').click(); + await expect(page.getByText('No uncommitted changes')).toBeVisible({ timeout: 30000 }); + await expect(getChangesActionLabel(page, 'Commit')).toHaveCount(0); + + await getChangesScope(page).getByTestId('changes-mode-base').click(); + await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({ + timeout: 30000, + }); + await expect(getChangesScope(page).getByText('notes.txt', { exact: true })).toBeVisible({ + timeout: 30000, + }); + + await expect(getChangesActionLabel(page, 'Create PR')).toBeVisible(); + await getChangesActionButton(page, 'Create PR').click(); + await expect(getChangesScope(page).getByTestId('changes-pr-status')).toContainText(/open/i, { + timeout: 60000, + }); + await expect(getChangesActionLabel(page, 'Create PR')).toHaveCount(0); + + await getChangesActionButton(page, 'Merge to base').click(); + await getChangesScope(page).getByTestId('changes-mode-base').click(); + await expect(getChangesScope(page).getByText('No base changes')).toBeVisible({ + timeout: 60000, + }); + + await getChangesActionButton(page, 'Archive').click(); + await page.getByTestId('sidebar-new-agent').click(); + await setWorkingDirectory(page, repo.path); + await ensureHostSelected(page); + await page.getByTestId('worktree-attach-toggle').click(); + await page.getByTestId('worktree-attach-picker').click(); + await expect(page.getByText(branchName, { exact: true })).toHaveCount(0); + const attachSheetBackdrop = page.getByRole('button', { name: 'Bottom sheet backdrop' }); + if (await attachSheetBackdrop.isVisible()) { + await attachSheetBackdrop.click({ force: true }); + } + await page.getByTestId('worktree-attach-toggle').click(); + await expect(page.getByTestId('worktree-attach-picker')).toHaveCount(0); + + await setWorkingDirectory(page, nonGitDir); + const attachPicker = page.getByTestId('worktree-attach-picker'); + if (await attachPicker.isVisible()) { + await page.getByTestId('worktree-attach-toggle').click(); + await expect(attachPicker).toHaveCount(0); + } + await createAgentAndWait(page, 'Respond with exactly: NON-GIT'); + await waitForAssistantText(page, 'NON-GIT'); + await openChangesPanel(page, { expectGit: false }); + await expect(getChangesScope(page).getByTestId('changes-not-git')).toBeVisible(); + await expect(getChangesActionButton(page, 'Commit')).toHaveAttribute('aria-disabled', 'true'); + await expect(getChangesActionButton(page, 'Create PR')).toHaveAttribute('aria-disabled', 'true'); + await expect(getChangesActionButton(page, 'Merge to base')).toHaveAttribute('aria-disabled', 'true'); + } finally { + await rm(nonGitDir, { recursive: true, force: true }); + await repo.cleanup(); + } +}); diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index 895349b3a..067818d2b 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -100,4 +100,4 @@ test.afterEach(async ({ page }, testInfo) => { }); }); -export { test, expect }; +export { test, expect, type Page }; diff --git a/packages/app/e2e/helpers/app.ts b/packages/app/e2e/helpers/app.ts index d54ad5a2a..be7d43c13 100644 --- a/packages/app/e2e/helpers/app.ts +++ b/packages/app/e2e/helpers/app.ts @@ -151,17 +151,76 @@ export const openSettings = async (page: Page) => { export const setWorkingDirectory = async (page: Page, directory: string) => { const workingDirectoryLabel = page.getByText('WORKING DIRECTORY', { exact: true }).first(); await expect(workingDirectoryLabel).toBeVisible(); - await workingDirectoryLabel.click(); const input = page.getByRole('textbox', { name: '/path/to/project' }); - await expect(input).toBeVisible(); + const worktreePicker = page.getByTestId('worktree-attach-picker'); + const worktreeSheetTitle = page.getByText('Select worktree', { exact: true }); + const closeBottomSheet = async () => { + const bottomSheetBackdrop = page + .getByRole('button', { name: 'Bottom sheet backdrop' }) + .first(); + const bottomSheetHandle = page + .getByRole('slider', { name: 'Bottom sheet handle' }) + .first(); + for (let attempt = 0; attempt < 3; attempt += 1) { + if (!(await bottomSheetBackdrop.isVisible())) { + return; + } + await bottomSheetBackdrop.click({ force: true }); + await page.keyboard.press('Escape').catch(() => undefined); + await page.waitForTimeout(200); + } + if (await bottomSheetBackdrop.isVisible()) { + const box = await bottomSheetHandle.boundingBox(); + if (box) { + const startX = box.x + box.width / 2; + const startY = box.y + box.height / 2; + await page.mouse.move(startX, startY); + await page.mouse.down(); + await page.mouse.move(startX, startY + 400); + await page.mouse.up(); + await page.waitForTimeout(200); + } + } + }; + const closeWorktreeSheetIfOpen = async () => { + if (!(await worktreeSheetTitle.isVisible()) && !(await worktreePicker.isVisible())) { + return; + } + const attachToggle = page.getByTestId('worktree-attach-toggle'); + if (await attachToggle.isVisible()) { + await attachToggle.click({ force: true }); + await page.waitForTimeout(200); + } + await closeBottomSheet(); + }; + + await closeWorktreeSheetIfOpen(); + + if (!(await input.isVisible())) { + await closeBottomSheet(); + await workingDirectoryLabel.click({ force: true }); + if (!(await input.isVisible())) { + await closeBottomSheet(); + await workingDirectoryLabel.click({ force: true }); + } + await expect(input).toBeVisible(); + } + await input.fill(directory); await input.press('Enter'); const useOption = page.getByText(`Use "${directory}"`); await expect(useOption).toBeVisible(); - await useOption.click(); - await expect(page.getByText(directory, { exact: true })).toBeVisible(); + await useOption.click({ force: true }); + const normalizedDirectory = directory.startsWith('/var/') + ? `/private${directory}` + : directory; + const workingDirectoryContainer = workingDirectoryLabel.locator('..'); + await expect.poll(async () => { + const text = await workingDirectoryContainer.innerText(); + return text.includes(directory) || text.includes(normalizedDirectory); + }).toBe(true); }; export const ensureHostSelected = async (page: Page) => { diff --git a/packages/app/e2e/helpers/workspace.ts b/packages/app/e2e/helpers/workspace.ts index 7d974f806..f2cecfe18 100644 --- a/packages/app/e2e/helpers/workspace.ts +++ b/packages/app/e2e/helpers/workspace.ts @@ -5,20 +5,57 @@ import path from 'node:path'; type TempRepo = { path: string; + owner?: string; + name?: string; cleanup: () => Promise; }; -export const createTempGitRepo = async (prefix = 'paseo-e2e-'): Promise => { +export const createTempGitRepo = async ( + prefix = 'paseo-e2e-', + options?: { withRemote?: boolean } +): Promise => { const repoPath = await mkdtemp(path.join(tmpdir(), prefix)); + const repoName = `paseo-e2e-${Date.now().toString(36)}-${Math.random() + .toString(36) + .slice(2, 8)}`; + let owner: string | undefined; + const withRemote = options?.withRemote ?? false; - execSync('git init', { cwd: repoPath, stdio: 'ignore' }); + execSync('git init -b main', { cwd: repoPath, stdio: 'ignore' }); + execSync('git config user.email "e2e@paseo.test"', { cwd: repoPath, stdio: 'ignore' }); + execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: 'ignore' }); await writeFile(path.join(repoPath, 'README.md'), '# Temp Repo\n'); execSync('git add README.md', { cwd: repoPath, stdio: 'ignore' }); execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: 'ignore' }); + if (withRemote) { + try { + owner = execSync('gh api user -q .login', { encoding: 'utf8' }).trim(); + execSync( + `gh repo create ${repoName} --private --confirm --source=. --remote=origin --push`, + { + cwd: repoPath, + stdio: 'ignore', + } + ); + } catch (error) { + await rm(repoPath, { recursive: true, force: true }); + throw error; + } + } + return { path: repoPath, + owner, + name: repoName, cleanup: async () => { + if (owner && withRemote) { + try { + execSync(`gh repo delete ${owner}/${repoName} --yes`, { stdio: 'ignore' }); + } catch { + // Best-effort cleanup + } + } await rm(repoPath, { recursive: true, force: true }); }, }; diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index 62789a02f..972fccd19 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -168,9 +168,10 @@ export default function HomeScreen() { const [errorMessage, setErrorMessage] = useState(""); const [isLoading, setIsLoading] = useState(false); const [promptText, setPromptText] = useState(""); - const [useWorktree, setUseWorktree] = useState(false); + const [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">("none"); const [baseBranch, setBaseBranch] = useState(""); const [worktreeSlug, setWorktreeSlug] = useState(""); + const [selectedWorktreePath, setSelectedWorktreePath] = useState(""); const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null); const handleFilesDropped = useCallback((files: ImageAttachment[]) => { @@ -293,13 +294,73 @@ export default function HomeScreen() { const gitHelperText = isNonGitDirectory ? "No git repository detected. Git options are disabled for this directory." : null; + const isCreateWorktree = worktreeMode === "create"; + const isAttachWorktree = worktreeMode === "attach"; - const handleUseWorktreeChange = useCallback((value: boolean) => { - setUseWorktree(value); - if (value && !worktreeSlug) { - setWorktreeSlug(createNameId()); - } - }, [worktreeSlug]); + const worktreeListRoot = repoInfo?.repoRoot ?? trimmedWorkingDir; + const worktreeListQuery = useQuery({ + queryKey: ["paseoWorktreeList", selectedServerId, worktreeListRoot], + queryFn: async () => { + const client = sessionClient; + if (!client) { + throw new Error("Daemon client unavailable"); + } + const payload = await client.getPaseoWorktreeList({ + repoRoot: worktreeListRoot || undefined, + cwd: worktreeListRoot ? undefined : trimmedWorkingDir || undefined, + }); + if (payload.error) { + throw new Error(payload.error.message); + } + return payload.worktrees ?? []; + }, + enabled: + isAttachWorktree && + Boolean(worktreeListRoot || trimmedWorkingDir) && + !repoAvailabilityError && + Boolean(sessionClient) && + isConnected && + !isNonGitDirectory, + retry: false, + staleTime: 0, + refetchOnMount: "always", + }); + const worktreeOptions = useMemo(() => { + return (worktreeListQuery.data ?? []).map((worktree) => ({ + path: worktree.worktreePath, + label: worktree.branchName ?? worktree.head ?? "Unknown branch", + })); + }, [worktreeListQuery.data]); + const worktreeOptionsError = + worktreeListQuery.error instanceof Error ? worktreeListQuery.error.message : null; + const worktreeOptionsStatus: "idle" | "loading" | "ready" | "error" = + !isAttachWorktree + ? "idle" + : worktreeListQuery.isPending || worktreeListQuery.isFetching + ? "loading" + : worktreeListQuery.isError + ? "error" + : "ready"; + const attachWorktreeError = + isAttachWorktree && + worktreeOptionsStatus === "ready" && + worktreeOptions.length > 0 && + !selectedWorktreePath + ? "Select a worktree to attach" + : null; + + const handleWorktreeModeChange = useCallback( + (mode: "none" | "create" | "attach") => { + setWorktreeMode(mode); + if (mode === "create" && !worktreeSlug) { + setWorktreeSlug(createNameId()); + } + if (mode !== "attach") { + setSelectedWorktreePath(""); + } + }, + [worktreeSlug] + ); const validateWorktreeName = useCallback( (name: string): { valid: boolean; error?: string } => { @@ -330,7 +391,7 @@ export default function HomeScreen() { ); const gitBlockingError = useMemo(() => { - if (!useWorktree || isNonGitDirectory) { + if (!isCreateWorktree || isNonGitDirectory) { return null; } if (!worktreeSlug) { @@ -344,14 +405,14 @@ export default function HomeScreen() { } return null; }, [ - useWorktree, + isCreateWorktree, isNonGitDirectory, worktreeSlug, validateWorktreeName, ]); const baseBranchError = useMemo(() => { - if (!useWorktree || isNonGitDirectory || !baseBranch) { + if (!isCreateWorktree || isNonGitDirectory || !baseBranch) { return null; } const branches = repoInfo?.branches ?? []; @@ -363,17 +424,26 @@ export default function HomeScreen() { return `Branch "${baseBranch}" not found in repository`; } return null; - }, [useWorktree, isNonGitDirectory, baseBranch, repoInfo?.branches]); + }, [isCreateWorktree, isNonGitDirectory, baseBranch, repoInfo?.branches]); const handleBaseBranchChange = useCallback((value: string) => { setBaseBranch(value); }, []); + const handleSelectWorktreePath = useCallback( + (path: string) => { + setSelectedWorktreePath(path); + setWorkingDirFromUser(path); + }, + [setWorkingDirFromUser] + ); + useEffect(() => { - if (isNonGitDirectory && useWorktree) { - setUseWorktree(false); + if (isNonGitDirectory && worktreeMode !== "none") { + setWorktreeMode("none"); + setSelectedWorktreePath(""); } - }, [isNonGitDirectory, useWorktree]); + }, [isNonGitDirectory, worktreeMode]); const sessionMethods = useSessionStore((state) => selectedServerId ? state.sessions[selectedServerId]?.methods : undefined @@ -390,6 +460,10 @@ export default function HomeScreen() { setErrorMessage(""); const trimmedPath = workingDir.trim(); const trimmedPrompt = text.trim(); + const resolvedWorkingDir = + isAttachWorktree && selectedWorktreePath + ? selectedWorktreePath + : trimmedPath; if (!trimmedPath) { setErrorMessage("Working directory is required"); throw new Error("Working directory is required"); @@ -410,6 +484,11 @@ export default function HomeScreen() { setErrorMessage(gitBlockingError); throw new Error(gitBlockingError); } + if (isAttachWorktree && !selectedWorktreePath) { + const message = "Select a worktree to attach"; + setErrorMessage(message); + throw new Error(message); + } if (baseBranchError) { setErrorMessage(baseBranchError); throw new Error(baseBranchError); @@ -427,18 +506,26 @@ export default function HomeScreen() { const trimmedModel = selectedModel.trim(); const config: AgentSessionConfig = { provider: selectedProvider, - cwd: trimmedPath, + cwd: resolvedWorkingDir, ...(modeId ? { modeId } : {}), ...(trimmedModel ? { model: trimmedModel } : {}), }; const effectiveBaseBranch = baseBranch.trim() || repoInfo?.currentBranch || undefined; - const gitOptions = useWorktree && !isNonGitDirectory && worktreeSlug - ? { - createWorktree: true, - worktreeSlug, - baseBranch: effectiveBaseBranch, - } - : undefined; + const effectiveWorktreeSlug = + isCreateWorktree && !worktreeSlug ? createNameId() : worktreeSlug; + if (isCreateWorktree && !worktreeSlug && effectiveWorktreeSlug) { + setWorktreeSlug(effectiveWorktreeSlug); + } + const gitOptions = + isCreateWorktree && !isNonGitDirectory && effectiveWorktreeSlug + ? { + createWorktree: true, + createNewBranch: true, + newBranchName: effectiveWorktreeSlug, + worktreeSlug: effectiveWorktreeSlug, + baseBranch: effectiveBaseBranch, + } + : undefined; void persistFormPreferences(); setIsLoading(true); @@ -469,9 +556,10 @@ export default function HomeScreen() { } }, [ - useWorktree, + worktreeMode, baseBranch, worktreeSlug, + selectedWorktreePath, repoInfo?.currentBranch, gitBlockingError, baseBranchError, @@ -544,8 +632,8 @@ export default function HomeScreen() { /> {trimmedWorkingDir.length > 0 && !isNonGitDirectory ? ( ) : null} diff --git a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx index a44fe1d57..72db4af2e 100644 --- a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx +++ b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx @@ -52,6 +52,7 @@ interface DropdownFieldProps { warningMessage?: string | null; helperText?: string | null; renderTrigger?: DropdownTriggerRenderer; + testID?: string; } export function DropdownField({ @@ -64,6 +65,7 @@ export function DropdownField({ warningMessage, helperText, renderTrigger, + testID, }: DropdownFieldProps): ReactElement { if (renderTrigger) { return ( @@ -88,6 +90,7 @@ export function DropdownField({ ; + testID?: string; } export function SelectField({ @@ -129,6 +133,7 @@ export function SelectField({ warningMessage, helperText, controlRef, + testID, }: SelectFieldProps): ReactElement { return ( @@ -136,6 +141,7 @@ export function SelectField({ ref={controlRef} onPress={onPress} disabled={disabled} + testID={testID} style={[styles.selectFieldControl, disabled && styles.selectFieldControlDisabled]} > @@ -1144,8 +1150,8 @@ export function ToggleRow({ } export interface GitOptionsSectionProps { - useWorktree: boolean; - onUseWorktreeChange: (value: boolean) => void; + worktreeMode: "none" | "create" | "attach"; + onWorktreeModeChange: (value: "none" | "create" | "attach") => void; worktreeSlug: string; currentBranch: string | null; baseBranch: string; @@ -1155,11 +1161,17 @@ export interface GitOptionsSectionProps { repoError: string | null; gitValidationError: string | null; baseBranchError: string | null; + worktreeOptions: Array<{ path: string; label: string }>; + selectedWorktreePath: string; + worktreeOptionsStatus: "idle" | "loading" | "ready" | "error"; + worktreeOptionsError: string | null; + attachWorktreeError: string | null; + onSelectWorktreePath: (path: string) => void; } export function GitOptionsSection({ - useWorktree, - onUseWorktreeChange, + worktreeMode, + onWorktreeModeChange, worktreeSlug, currentBranch, baseBranch, @@ -1169,11 +1181,20 @@ export function GitOptionsSection({ repoError, gitValidationError, baseBranchError, + worktreeOptions, + selectedWorktreePath, + worktreeOptionsStatus, + worktreeOptionsError, + attachWorktreeError, + onSelectWorktreePath, }: GitOptionsSectionProps): ReactElement { const isLoading = status === "loading"; + const isCreateMode = worktreeMode === "create"; + const isAttachMode = worktreeMode === "attach"; const [isEditingBranch, setIsEditingBranch] = useState(false); const [editedBranch, setEditedBranch] = useState(baseBranch); const inputRef = useRef(null); + const [isWorktreeSheetOpen, setIsWorktreeSheetOpen] = useState(false); useEffect(() => { setEditedBranch(baseBranch); @@ -1204,24 +1225,35 @@ export function GitOptionsSection({ }, [baseBranch]); const displayBranch = baseBranch || currentBranch || "HEAD"; + const selectedWorktreeLabel = + worktreeOptions.find((option) => option.path === selectedWorktreePath)?.label ?? ""; + const worktreeHelperText = + worktreeOptionsStatus === "loading" + ? "Loading worktrees..." + : worktreeOptions.length === 0 + ? "No worktrees found" + : null; return ( onUseWorktreeChange(!useWorktree)} + testID="worktree-create-toggle" + onPress={() => + onWorktreeModeChange(isCreateMode ? "none" : "create") + } disabled={isLoading} style={[styles.worktreeToggle, isLoading && styles.worktreeToggleDisabled]} > - - {useWorktree ? : null} + + {isCreateMode ? : null} Create worktree {isLoading ? "Inspecting repository…" - : useWorktree && worktreeSlug - ? `Will create: ${worktreeSlug}` + : isCreateMode + ? `Will create: ${worktreeSlug || "preparing…"}` : currentBranch ? `Run isolated from ${currentBranch}` : "Run in an isolated directory"} @@ -1229,7 +1261,59 @@ export function GitOptionsSection({ - {useWorktree ? ( + + onWorktreeModeChange(isAttachMode ? "none" : "attach") + } + disabled={isLoading} + style={[styles.worktreeToggle, isLoading && styles.worktreeToggleDisabled]} + > + + {isAttachMode ? : null} + + + Attach to existing worktree + + {isLoading ? "Inspecting repository…" : "Pick a Paseo worktree by branch"} + + + + + {isAttachMode ? ( + <> + setIsWorktreeSheetOpen(true)} + disabled={isLoading || worktreeOptionsStatus === "loading"} + helperText={worktreeHelperText} + errorMessage={attachWorktreeError || worktreeOptionsError} + testID="worktree-attach-picker" + /> + setIsWorktreeSheetOpen(false)} + > + {worktreeOptions.map((option) => ( + { + onSelectWorktreePath(option.path); + setIsWorktreeSheetOpen(false); + }} + > + {option.label} + + ))} + + + ) : null} + + {isCreateMode ? ( Base branch: {isEditingBranch ? ( diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 7f0c47cc2..72f121c67 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect, useId, useMemo, useRef, memo } from "react"; +import { useState, useCallback, useEffect, useId, useMemo, useRef, memo, type ReactElement } from "react"; import { View, Text, @@ -11,14 +11,17 @@ import { } from "react-native"; import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { ChevronRight } from "lucide-react-native"; +import { ChevronRight, GitBranch } from "lucide-react-native"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useSessionStore } from "@/stores/session-store"; import { - useHighlightedDiffQuery, + useCheckoutDiffQuery, type ParsedDiffFile, type DiffLine, type HighlightToken, -} from "@/hooks/use-highlighted-diff-query"; +} from "@/hooks/use-checkout-diff-query"; +import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query"; +import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query"; import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context"; import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context"; import { Fonts } from "@/constants/theme"; @@ -260,6 +263,7 @@ const DiffFileSection = memo(function DiffFileSection({ return ( [ styles.fileHeader, pressed && styles.fileHeaderPressed, @@ -341,9 +345,44 @@ interface GitDiffPaneProps { export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) { const { theme } = useUnistyles(); - const { files, isLoading, isFetching, isError, error, refresh } = useHighlightedDiffQuery({ + const queryClient = useQueryClient(); + const client = useSessionStore( + (state) => state.sessions[serverId]?.client ?? null + ); + const [diffMode, setDiffMode] = useState<"uncommitted" | "base">("uncommitted"); + const [actionError, setActionError] = useState(null); + const [actionStatus, setActionStatus] = useState(null); + const { status, isLoading: isStatusLoading, isFetching: isStatusFetching, isError: isStatusError, error: statusError, refresh: refreshStatus } = + useCheckoutStatusQuery({ serverId, agentId }); + const isGit = status?.isGit ?? false; + const notGit = Boolean(status) && !status?.isGit && !status?.error; + const statusErrorMessage = + status?.error?.message ?? + (isStatusError && statusError instanceof Error ? statusError.message : null); + const baseRef = status?.baseRef ?? undefined; + const { + files, + payloadError: diffPayloadError, + isLoading: isDiffLoading, + isFetching: isDiffFetching, + isError: isDiffError, + error: diffError, + refresh: refreshDiff, + } = useCheckoutDiffQuery({ serverId, agentId, + mode: diffMode, + baseRef, + enabled: isGit, + }); + const { + status: prStatus, + payloadError: prPayloadError, + refresh: refreshPrStatus, + } = useCheckoutPrStatusQuery({ + serverId, + agentId, + enabled: isGit, }); // Track user-initiated refresh to avoid iOS RefreshControl animation on background fetches const [isManualRefresh, setIsManualRefresh] = useState(false); @@ -374,8 +413,10 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) { const handleRefresh = useCallback(() => { setIsManualRefresh(true); - refresh(); - }, [refresh]); + void refreshDiff(); + void refreshStatus(); + void refreshPrStatus(); + }, [refreshDiff, refreshStatus, refreshPrStatus]); const handleToggleExpanded = useCallback((path: string) => { setExpandedByPath((prev) => ({ @@ -386,10 +427,10 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) { // Reset manual refresh flag when fetch completes useEffect(() => { - if (!isFetching && isManualRefresh) { + if (!(isDiffFetching || isStatusFetching) && isManualRefresh) { setIsManualRefresh(false); } - }, [isFetching, isManualRefresh]); + }, [isDiffFetching, isStatusFetching, isManualRefresh]); useEffect(() => { if (!isPerfLoggingEnabled()) { @@ -408,15 +449,120 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) { hunkCount: diffMetrics.hunkCount, lineCount: diffMetrics.lineCount, tokenCount: diffMetrics.tokenCount, - isLoading, - isFetching, + isLoading: isDiffLoading, + isFetching: isDiffFetching, }); - }, [agentId, diffMetrics, isFetching, isLoading, serverId]); + }, [agentId, diffMetrics, isDiffFetching, isDiffLoading, serverId]); const agentExists = useSessionStore((state) => state.sessions[serverId]?.agents?.has(agentId) ?? false ); + const commitMutation = useMutation({ + mutationFn: async () => { + if (!client) { + throw new Error("Daemon client unavailable"); + } + const payload = await client.checkoutCommit(agentId, { addAll: true }); + if (payload.error) { + throw new Error(payload.error.message); + } + return payload; + }, + onSuccess: () => { + setActionError(null); + setActionStatus("Commit created."); + void refreshDiff(); + void refreshStatus(); + }, + onError: (err) => { + const message = err instanceof Error ? err.message : "Failed to commit"; + setActionStatus(null); + setActionError(message); + }, + }); + + const prMutation = useMutation({ + mutationFn: async () => { + if (!client) { + throw new Error("Daemon client unavailable"); + } + const payload = await client.checkoutPrCreate(agentId, {}); + if (payload.error) { + throw new Error(payload.error.message); + } + return payload; + }, + onSuccess: () => { + setActionError(null); + setActionStatus("PR created."); + void refreshPrStatus(); + }, + onError: (err) => { + const message = err instanceof Error ? err.message : "Failed to create PR"; + setActionStatus(null); + setActionError(message); + }, + }); + + const mergeMutation = useMutation({ + mutationFn: async () => { + if (!client) { + throw new Error("Daemon client unavailable"); + } + const payload = await client.checkoutMerge(agentId, { + baseRef, + strategy: "merge", + requireCleanTarget: true, + }); + if (payload.error) { + throw new Error(payload.error.message); + } + return payload; + }, + onSuccess: () => { + setActionError(null); + setActionStatus("Merged to base."); + void refreshDiff(); + void refreshStatus(); + }, + onError: (err) => { + const message = err instanceof Error ? err.message : "Failed to merge"; + setActionStatus(null); + setActionError(message); + }, + }); + + const archiveMutation = useMutation({ + mutationFn: async () => { + if (!client) { + throw new Error("Daemon client unavailable"); + } + const worktreePath = status?.cwd; + if (!worktreePath) { + throw new Error("Worktree path unavailable"); + } + const payload = await client.archivePaseoWorktree({ worktreePath }); + if (payload.error) { + throw new Error(payload.error.message); + } + return payload; + }, + onSuccess: () => { + setActionError(null); + setActionStatus("Worktree archived."); + queryClient.invalidateQueries({ + predicate: (query) => + Array.isArray(query.queryKey) && query.queryKey[0] === "paseoWorktreeList", + }); + }, + onError: (err) => { + const message = err instanceof Error ? err.message : "Failed to archive worktree"; + setActionStatus(null); + setActionError(message); + }, + }); + const renderFileSection: ListRenderItem = useCallback( ({ item, index }) => ( 0; - const errorMessage = isError && error instanceof Error ? error.message : null; + const diffErrorMessage = + diffPayloadError?.message ?? + (isDiffError && diffError instanceof Error ? diffError.message : null); + const prErrorMessage = prPayloadError?.message ?? null; + const branchLabel = status?.currentBranch ?? (notGit ? "Not a git repository" : "Unknown"); + const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading; + const hasUncommittedChanges = Boolean(status?.isDirty); + const showCommitAction = + !isGit || hasUncommittedChanges || (diffMode === "uncommitted" && hasChanges); + const showCreatePrAction = !isGit || !prStatus; + const commitDisabled = actionsDisabled || commitMutation.isPending; + const prDisabled = actionsDisabled || prMutation.isPending; + const mergeDisabled = actionsDisabled || mergeMutation.isPending; + const archiveDisabled = + actionsDisabled || archiveMutation.isPending || !status?.isPaseoOwnedWorktree; - if (isLoading) { - return ( + let bodyContent: ReactElement; + + if (isStatusLoading) { + bodyContent = ( + + + Checking repository... + + ); + } else if (statusErrorMessage) { + bodyContent = ( + + {statusErrorMessage} + + ); + } else if (notGit) { + bodyContent = ( + + Not a git repository + + ); + } else if (isDiffLoading) { + bodyContent = ( Loading changes... ); - } - - if (isError) { - return ( + } else if (diffErrorMessage) { + bodyContent = ( - {errorMessage ?? "Failed to load changes"} + {diffErrorMessage} ); - } - - if (!hasChanges) { - return ( + } else if (!hasChanges) { + bodyContent = ( - No changes + + {diffMode === "uncommitted" ? "No uncommitted changes" : "No base changes"} + ); + } else { + bodyContent = ( + + ); } return ( - + + + + + + {branchLabel} + + {isStatusFetching && ( + + )} + + + setDiffMode("uncommitted")} + style={[ + styles.modeToggleButton, + diffMode === "uncommitted" && styles.modeToggleButtonActive, + ]} + > + + Uncommitted + + + setDiffMode("base")} + style={[ + styles.modeToggleButton, + diffMode === "base" && styles.modeToggleButtonActive, + ]} + > + + Base + + + + + + + {showCommitAction ? ( + commitMutation.mutate()} + disabled={commitDisabled} + > + {commitMutation.isPending ? ( + + ) : ( + Commit + )} + + ) : null} + {showCreatePrAction ? ( + prMutation.mutate()} + disabled={prDisabled} + > + {prMutation.isPending ? ( + + ) : ( + Create PR + )} + + ) : null} + mergeMutation.mutate()} + disabled={mergeDisabled} + > + {mergeMutation.isPending ? ( + + ) : ( + Merge to base + )} + + archiveMutation.mutate()} + disabled={archiveDisabled} + > + {archiveMutation.isPending ? ( + + ) : ( + Archive + )} + + + + {actionStatus ? {actionStatus} : null} + {actionError ? {actionError} : null} + {prStatus ? ( + + PR + + {prStatus.state} {prStatus.url ? `· ${prStatus.url}` : ""} + + + ) : null} + {prErrorMessage ? ( + {prErrorMessage} + ) : null} + + {bodyContent} + ); } const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + minHeight: 0, + }, + header: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + }, + headerLeft: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + flex: 1, + minWidth: 0, + }, + branchLabel: { + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, + fontWeight: theme.fontWeight.medium, + flexShrink: 1, + }, + modeToggle: { + flexDirection: "row", + alignItems: "center", + backgroundColor: theme.colors.surface2, + borderRadius: theme.borderRadius.lg, + padding: theme.spacing[1], + gap: theme.spacing[1], + }, + modeToggleButton: { + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[1], + borderRadius: theme.borderRadius.md, + }, + modeToggleButtonActive: { + backgroundColor: theme.colors.surface0, + }, + modeToggleText: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + fontWeight: theme.fontWeight.medium, + }, + modeToggleTextActive: { + color: theme.colors.foreground, + }, + actionsRow: { + flexDirection: "row", + flexWrap: "wrap", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingTop: theme.spacing[2], + paddingBottom: theme.spacing[1], + }, + actionButton: { + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + borderRadius: theme.borderRadius.md, + backgroundColor: theme.colors.surface2, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.borderAccent, + }, + actionButtonDisabled: { + opacity: 0.5, + }, + actionButtonText: { + fontSize: theme.fontSize.xs, + color: theme.colors.foreground, + fontWeight: theme.fontWeight.medium, + }, + actionStatusText: { + paddingHorizontal: theme.spacing[3], + paddingBottom: theme.spacing[1], + fontSize: theme.fontSize.xs, + color: theme.colors.success, + }, + actionErrorText: { + paddingHorizontal: theme.spacing[3], + paddingBottom: theme.spacing[1], + fontSize: theme.fontSize.xs, + color: theme.colors.destructive, + }, + prStatusRow: { + paddingHorizontal: theme.spacing[3], + paddingBottom: theme.spacing[2], + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + prStatusLabel: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + textTransform: "uppercase", + letterSpacing: 0.6, + }, + prStatusValue: { + fontSize: theme.fontSize.xs, + color: theme.colors.foreground, + flexShrink: 1, + }, + diffContainer: { + flex: 1, + minHeight: 0, + }, scrollView: { flex: 1, }, contentContainer: { - paddingHorizontal: theme.spacing[2], - paddingTop: theme.spacing[3], paddingBottom: theme.spacing[8], }, loadingContainer: { @@ -528,20 +937,18 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, }, fileSection: { - borderRadius: theme.borderRadius.lg, overflow: "hidden", backgroundColor: theme.colors.surface2, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.borderAccent, - marginBottom: theme.spacing[2], + borderBottomWidth: 1, + borderBottomColor: theme.colors.borderAccent, }, fileHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", - padding: theme.spacing[2], + paddingHorizontal: theme.spacing[2], paddingVertical: theme.spacing[2], - gap: theme.spacing[2], + gap: theme.spacing[1], }, fileHeaderPressed: { opacity: 0.7, @@ -549,14 +956,14 @@ const styles = StyleSheet.create((theme) => ({ fileHeaderLeft: { flexDirection: "row", alignItems: "center", - gap: theme.spacing[2], + gap: theme.spacing[1], flex: 1, minWidth: 0, }, fileHeaderRight: { flexDirection: "row", alignItems: "center", - gap: theme.spacing[2], + gap: theme.spacing[1], flexShrink: 0, }, chevronContainer: { @@ -569,7 +976,6 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, color: theme.colors.foreground, - fontFamily: Fonts.mono, flex: 1, }, newBadge: { diff --git a/packages/app/src/hooks/use-checkout-diff-query.ts b/packages/app/src/hooks/use-checkout-diff-query.ts new file mode 100644 index 000000000..704dc38fc --- /dev/null +++ b/packages/app/src/hooks/use-checkout-diff-query.ts @@ -0,0 +1,92 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect } from "react"; +import { UnistylesRuntime } from "react-native-unistyles"; +import { useSessionStore } from "@/stores/session-store"; +import { usePanelStore } from "@/stores/panel-store"; +import type { CheckoutDiffResponse } from "@server/shared/messages"; + +const CHECKOUT_DIFF_STALE_TIME = 30_000; + +function checkoutDiffQueryKey( + serverId: string, + agentId: string, + mode: "uncommitted" | "base", + baseRef?: string +) { + return ["checkoutDiff", serverId, agentId, mode, baseRef ?? ""] as const; +} + +interface UseCheckoutDiffQueryOptions { + serverId: string; + agentId: string; + mode: "uncommitted" | "base"; + baseRef?: string; + enabled?: boolean; +} + +export type ParsedDiffFile = CheckoutDiffResponse["payload"]["files"][number]; +export type DiffHunk = ParsedDiffFile["hunks"][number]; +export type DiffLine = DiffHunk["lines"][number]; +export type HighlightToken = NonNullable[number]; + +export function useCheckoutDiffQuery({ + serverId, + agentId, + mode, + baseRef, + enabled = true, +}: UseCheckoutDiffQueryOptions) { + const queryClient = useQueryClient(); + const client = useSessionStore( + (state) => state.sessions[serverId]?.client ?? null + ); + const isConnected = useSessionStore( + (state) => state.sessions[serverId]?.connection.isConnected ?? false + ); + const isMobile = + UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const mobileView = usePanelStore((state) => state.mobileView); + const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); + const explorerTab = usePanelStore((state) => state.explorerTab); + const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen; + + const query = useQuery({ + queryKey: checkoutDiffQueryKey(serverId, agentId, mode, baseRef), + queryFn: async () => { + if (!client) { + throw new Error("Daemon client not available"); + } + return await client.getCheckoutDiff(agentId, { mode, baseRef }); + }, + enabled: !!client && isConnected && !!agentId && enabled, + staleTime: CHECKOUT_DIFF_STALE_TIME, + refetchInterval: 10_000, + }); + + // Revalidate when sidebar opens with "changes" tab active + useEffect(() => { + if (!isOpen || explorerTab !== "changes" || !agentId) { + return; + } + queryClient.invalidateQueries({ + queryKey: checkoutDiffQueryKey(serverId, agentId, mode, baseRef), + }); + }, [isOpen, explorerTab, serverId, agentId, mode, baseRef, queryClient]); + + const refresh = useCallback(() => { + return query.refetch(); + }, [query]); + + const payload = query.data ?? null; + const payloadError = payload?.error ?? null; + + return { + files: payload?.files ?? [], + payloadError, + isLoading: query.isLoading, + isFetching: query.isFetching, + isError: query.isError || Boolean(payloadError), + error: query.error, + refresh, + }; +} diff --git a/packages/app/src/hooks/use-checkout-pr-status-query.ts b/packages/app/src/hooks/use-checkout-pr-status-query.ts new file mode 100644 index 000000000..a2d27a5a5 --- /dev/null +++ b/packages/app/src/hooks/use-checkout-pr-status-query.ts @@ -0,0 +1,53 @@ +import { useQuery } from "@tanstack/react-query"; +import { useSessionStore } from "@/stores/session-store"; +import type { CheckoutPrStatusResponse } from "@server/shared/messages"; + +const CHECKOUT_PR_STATUS_STALE_TIME = 20_000; + +function checkoutPrStatusQueryKey(serverId: string, agentId: string) { + return ["checkoutPrStatus", serverId, agentId] as const; +} + +interface UseCheckoutPrStatusQueryOptions { + serverId: string; + agentId: string; + enabled?: boolean; +} + +export type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"]; + +export function useCheckoutPrStatusQuery({ + serverId, + agentId, + enabled = true, +}: UseCheckoutPrStatusQueryOptions) { + const client = useSessionStore( + (state) => state.sessions[serverId]?.client ?? null + ); + const isConnected = useSessionStore( + (state) => state.sessions[serverId]?.connection.isConnected ?? false + ); + + const query = useQuery({ + queryKey: checkoutPrStatusQueryKey(serverId, agentId), + queryFn: async () => { + if (!client) { + throw new Error("Daemon client not available"); + } + return await client.checkoutPrStatus(agentId); + }, + enabled: !!client && isConnected && !!agentId && enabled, + staleTime: CHECKOUT_PR_STATUS_STALE_TIME, + refetchInterval: 15_000, + }); + + return { + status: query.data?.status ?? null, + payloadError: query.data?.error ?? null, + isLoading: query.isLoading, + isFetching: query.isFetching, + isError: query.isError, + error: query.error, + refresh: query.refetch, + }; +} diff --git a/packages/app/src/hooks/use-checkout-status-query.ts b/packages/app/src/hooks/use-checkout-status-query.ts new file mode 100644 index 000000000..2aec4c476 --- /dev/null +++ b/packages/app/src/hooks/use-checkout-status-query.ts @@ -0,0 +1,47 @@ +import { useQuery } from "@tanstack/react-query"; +import { useSessionStore } from "@/stores/session-store"; +import type { CheckoutStatusResponse } from "@server/shared/messages"; + +const CHECKOUT_STATUS_STALE_TIME = 15_000; + +function checkoutStatusQueryKey(serverId: string, agentId: string) { + return ["checkoutStatus", serverId, agentId] as const; +} + +interface UseCheckoutStatusQueryOptions { + serverId: string; + agentId: string; +} + +export type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; + +export function useCheckoutStatusQuery({ serverId, agentId }: UseCheckoutStatusQueryOptions) { + const client = useSessionStore( + (state) => state.sessions[serverId]?.client ?? null + ); + const isConnected = useSessionStore( + (state) => state.sessions[serverId]?.connection.isConnected ?? false + ); + + const query = useQuery({ + queryKey: checkoutStatusQueryKey(serverId, agentId), + queryFn: async () => { + if (!client) { + throw new Error("Daemon client not available"); + } + return await client.getCheckoutStatus(agentId); + }, + enabled: !!client && isConnected && !!agentId, + staleTime: CHECKOUT_STATUS_STALE_TIME, + refetchInterval: 10_000, + }); + + return { + status: query.data ?? null, + isLoading: query.isLoading, + isFetching: query.isFetching, + isError: query.isError, + error: query.error, + refresh: query.refetch, + }; +} diff --git a/packages/app/src/utils/extract-agent-model.ts b/packages/app/src/utils/extract-agent-model.ts index d277ebe90..e96214881 100644 --- a/packages/app/src/utils/extract-agent-model.ts +++ b/packages/app/src/utils/extract-agent-model.ts @@ -3,7 +3,12 @@ import type { Agent } from "@/stores/session-store"; export function extractAgentModel(agent?: Agent | null): string | null { if (!agent) return null; const runtimeModel = agent.runtimeInfo?.model; - return typeof runtimeModel === "string" && runtimeModel.trim().length > 0 - ? runtimeModel.trim() - : null; + const fallbackModel = agent.model; + if (typeof runtimeModel === "string" && runtimeModel.trim().length > 0) { + return runtimeModel.trim(); + } + if (typeof fallbackModel === "string" && fallbackModel.trim().length > 0) { + return fallbackModel.trim(); + } + return null; } diff --git a/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts b/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts index 35998f3e1..6974e3b92 100644 --- a/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts @@ -204,12 +204,16 @@ describe("daemon checkout ship loop", () => { expect(diffUncommitted.error).toBeNull(); expect(diffUncommitted.files.length).toBeGreaterThan(0); + const timelineBeforeCommit = + ctx.daemon.daemon.agentManager.getTimeline(agent.id).length; const commitResult = await ctx.client.checkoutCommit(agent.id, { - message: "Ship loop update", addAll: true, }); expect(commitResult.error).toBeNull(); expect(commitResult.success).toBe(true); + const timelineAfterCommit = + ctx.daemon.daemon.agentManager.getTimeline(agent.id).length; + expect(timelineAfterCommit).toBe(timelineBeforeCommit); const diffAfterCommit = await ctx.client.getCheckoutDiff(agent.id, { mode: "uncommitted", @@ -222,13 +226,16 @@ describe("daemon checkout ship loop", () => { }); expect(baseDiff.files.length).toBeGreaterThan(0); + const timelineBeforePr = + ctx.daemon.daemon.agentManager.getTimeline(agent.id).length; const prCreate = await ctx.client.checkoutPrCreate(agent.id, { - title: "Ship loop update", - body: "Testing checkout ship loop", baseRef: "main", }); expect(prCreate.error).toBeNull(); expect(prCreate.url).toContain(repoName); + const timelineAfterPr = + ctx.daemon.daemon.agentManager.getTimeline(agent.id).length; + expect(timelineAfterPr).toBe(timelineBeforePr); const prStatus = await ctx.client.checkoutPrStatus(agent.id); expect(prStatus.error).toBeNull(); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 1c83ca51a..0795bb6cd 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -46,7 +46,10 @@ import { buildProviderRegistry } from "./agent/provider-registry.js"; import { AgentManager } from "./agent/agent-manager.js"; import type { ManagedAgent } from "./agent/agent-manager.js"; import { toAgentPayload } from "./agent/agent-projections.js"; -import { getStructuredAgentResponse } from "./agent/agent-response-loop.js"; +import { + StructuredAgentResponseError, + generateStructuredAgentResponse, +} from "./agent/agent-response-loop.js"; import type { AgentPermissionResponse, AgentPromptContentBlock, @@ -1767,6 +1770,15 @@ export class Session { return resolvedCandidate.startsWith(resolvedRoot + sep); } + private buildEphemeralAgentConfig(agent: ManagedAgent, title: string): AgentSessionConfig { + return { + ...agent.config, + cwd: agent.cwd, + title, + parentAgentId: agent.id, + }; + } + private async generateCommitMessage(agent: ManagedAgent): Promise { const diff = await getCheckoutDiff(agent.cwd, { mode: "uncommitted" }); const schema = z.object({ @@ -1782,17 +1794,22 @@ export class Session { "", diff.diff.length > 0 ? diff.diff : "(No diff available)", ].join("\n"); - const result = await getStructuredAgentResponse({ - caller: async (nextPrompt) => { - const run = await this.agentManager.runAgent(agent.id, nextPrompt); - return run.finalText; - }, - prompt, - schema, - schemaName: "CommitMessage", - maxRetries: 2, - }); - return result.message; + try { + const result = await generateStructuredAgentResponse({ + manager: this.agentManager, + agentConfig: this.buildEphemeralAgentConfig(agent, "Commit generator"), + prompt, + schema, + schemaName: "CommitMessage", + maxRetries: 2, + }); + return result.message; + } catch (error) { + if (error instanceof StructuredAgentResponseError) { + return "Update files"; + } + throw error; + } } private async generatePullRequestText(agent: ManagedAgent, baseRef?: string): Promise<{ @@ -1813,16 +1830,24 @@ export class Session { "", diff.diff.length > 0 ? diff.diff : "(No diff available)", ].join("\n"); - return await getStructuredAgentResponse({ - caller: async (nextPrompt) => { - const run = await this.agentManager.runAgent(agent.id, nextPrompt); - return run.finalText; - }, - prompt, - schema, - schemaName: "PullRequest", - maxRetries: 2, - }); + try { + return await generateStructuredAgentResponse({ + manager: this.agentManager, + agentConfig: this.buildEphemeralAgentConfig(agent, "PR generator"), + prompt, + schema, + schemaName: "PullRequest", + maxRetries: 2, + }); + } catch (error) { + if (error instanceof StructuredAgentResponseError) { + return { + title: "Update changes", + body: "Automated PR generated by Paseo.", + }; + } + throw error; + } } private async ensureCleanWorkingTree(cwd: string): Promise { diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 0989c6369..84f8fa58d 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -113,16 +113,24 @@ describe("checkout git utilities", () => { expect(message).toBe("worktree update"); }); - it("merges the current branch into base", async () => { - writeFileSync(join(repoDir, "merge.txt"), "feature\n"); - execSync("git checkout -b feature", { cwd: repoDir }); - execSync("git add merge.txt", { cwd: repoDir }); - execSync("git -c commit.gpgsign=false commit -m 'feature commit'", { cwd: repoDir }); - const featureCommit = execSync("git rev-parse HEAD", { cwd: repoDir }) + it("merges the current branch into base from a worktree checkout", async () => { + const worktree = await createWorktree({ + branchName: "main", + cwd: repoDir, + worktreeSlug: "merge", + }); + + writeFileSync(join(worktree.worktreePath, "merge.txt"), "feature\n"); + execSync("git checkout -b feature", { cwd: worktree.worktreePath }); + execSync("git add merge.txt", { cwd: worktree.worktreePath }); + execSync("git -c commit.gpgsign=false commit -m 'feature commit'", { + cwd: worktree.worktreePath, + }); + const featureCommit = execSync("git rev-parse HEAD", { cwd: worktree.worktreePath }) .toString() .trim(); - await mergeToBase(repoDir, { baseRef: "main" }); + await mergeToBase(worktree.worktreePath, { baseRef: "main" }); const baseContainsFeature = execSync(`git merge-base --is-ancestor ${featureCommit} main`, { cwd: repoDir, @@ -130,7 +138,9 @@ describe("checkout git utilities", () => { }); expect(baseContainsFeature).toBeDefined(); - const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", { cwd: repoDir }) + const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", { + cwd: worktree.worktreePath, + }) .toString() .trim(); expect(currentBranch).toBe("feature"); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 30d298726..ed8661c78 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -98,6 +98,68 @@ async function getCurrentBranch(cwd: string): Promise { return branch.length > 0 ? branch : null; } +async function getWorktreeRoot(cwd: string): Promise { + try { + const { stdout } = await execAsync( + "git rev-parse --path-format=absolute --show-toplevel", + { cwd, env: READ_ONLY_GIT_ENV } + ); + const root = stdout.trim(); + return root.length > 0 ? root : null; + } catch { + return null; + } +} + +type GitWorktreeEntry = { + path: string; + branchRef?: string; +}; + +function parseWorktreeList(output: string): GitWorktreeEntry[] { + const entries: GitWorktreeEntry[] = []; + let current: GitWorktreeEntry | null = null; + for (const line of output.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + if (trimmed.startsWith("worktree ")) { + if (current) { + entries.push(current); + } + current = { path: trimmed.slice("worktree ".length).trim() }; + continue; + } + if (current && trimmed.startsWith("branch ")) { + current.branchRef = trimmed.slice("branch ".length).trim(); + } + } + if (current) { + entries.push(current); + } + return entries; +} + +async function getWorktreePathForBranch( + cwd: string, + branchName: string +): Promise { + try { + const { stdout } = await execAsync("git worktree list --porcelain", { + cwd, + env: READ_ONLY_GIT_ENV, + }); + const entries = parseWorktreeList(stdout); + const ref = branchName.startsWith("refs/heads/") + ? branchName + : `refs/heads/${branchName}`; + return entries.find((entry) => entry.branchRef === ref)?.path ?? null; + } catch { + return null; + } +} + export async function renameCurrentBranch( cwd: string, newName: string @@ -295,19 +357,26 @@ export async function mergeToBase(cwd: string, options: MergeToBaseOptions = {}) if (!currentBranch) { throw new Error("Unable to determine current branch for merge"); } - if (baseRef === currentBranch) { + let normalizedBaseRef = baseRef; + if (normalizedBaseRef.startsWith("origin/")) { + normalizedBaseRef = normalizedBaseRef.slice("origin/".length); + } + if (normalizedBaseRef === currentBranch) { return; } - const operationCwd = repoInfo.path; - const isSameCheckout = resolve(operationCwd) === resolve(cwd); + const currentWorktreeRoot = (await getWorktreeRoot(cwd)) ?? cwd; + const baseWorktree = await getWorktreePathForBranch(repoInfo.path, normalizedBaseRef); + const operationCwd = baseWorktree ?? currentWorktreeRoot; + const isSameCheckout = resolve(operationCwd) === resolve(currentWorktreeRoot); const originalBranch = await getCurrentBranch(operationCwd); const mode = options.mode ?? "merge"; try { - await execAsync(`git checkout ${baseRef}`, { cwd: operationCwd }); + await execAsync(`git checkout ${normalizedBaseRef}`, { cwd: operationCwd }); if (mode === "squash") { await execAsync(`git merge --squash ${currentBranch}`, { cwd: operationCwd }); - const message = options.commitMessage ?? `Squash merge ${currentBranch} into ${baseRef}`; + const message = + options.commitMessage ?? `Squash merge ${currentBranch} into ${normalizedBaseRef}`; await execFileAsync( "git", ["-c", "commit.gpgsign=false", "commit", "-m", message], @@ -354,7 +423,7 @@ export async function mergeToBase(cwd: string, options: MergeToBaseOptions = {}) // ignore } throw new MergeConflictError({ - baseRef, + baseRef: normalizedBaseRef, currentBranch, conflictFiles: conflicts.length > 0 ? conflicts : [], }); @@ -368,7 +437,7 @@ export async function mergeToBase(cwd: string, options: MergeToBaseOptions = {}) throw error; } finally { - if (isSameCheckout && originalBranch && originalBranch !== baseRef) { + if (isSameCheckout && originalBranch && originalBranch !== normalizedBaseRef) { try { await execAsync(`git checkout ${originalBranch}`, { cwd: operationCwd }); } catch {