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
This commit is contained in:
Mohamed Boudra
2026-01-23 08:37:15 +07:00
parent 746d413082
commit 53155ebcc9
15 changed files with 1450 additions and 135 deletions

View File

@@ -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: <path>');
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: <path>", 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();
}
});

View File

@@ -100,4 +100,4 @@ test.afterEach(async ({ page }, testInfo) => {
});
});
export { test, expect };
export { test, expect, type Page };

View File

@@ -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) => {

View File

@@ -5,20 +5,57 @@ import path from 'node:path';
type TempRepo = {
path: string;
owner?: string;
name?: string;
cleanup: () => Promise<void>;
};
export const createTempGitRepo = async (prefix = 'paseo-e2e-'): Promise<TempRepo> => {
export const createTempGitRepo = async (
prefix = 'paseo-e2e-',
options?: { withRemote?: boolean }
): Promise<TempRepo> => {
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 });
},
};

View File

@@ -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 ? (
<GitOptionsSection
useWorktree={useWorktree}
onUseWorktreeChange={handleUseWorktreeChange}
worktreeMode={worktreeMode}
onWorktreeModeChange={handleWorktreeModeChange}
worktreeSlug={worktreeSlug}
currentBranch={repoInfo?.currentBranch ?? null}
baseBranch={baseBranch}
@@ -555,6 +643,12 @@ export default function HomeScreen() {
repoError={repoInfoError}
gitValidationError={gitBlockingError}
baseBranchError={baseBranchError}
worktreeOptions={worktreeOptions}
selectedWorktreePath={selectedWorktreePath}
worktreeOptionsStatus={worktreeOptionsStatus}
worktreeOptionsError={worktreeOptionsError}
attachWorktreeError={attachWorktreeError}
onSelectWorktreePath={handleSelectWorktreePath}
/>
) : null}
</View>

View File

@@ -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({
<Pressable
onPress={onPress}
disabled={disabled}
testID={testID}
style={[styles.dropdownControl, disabled && styles.dropdownControlDisabled]}
>
<Text
@@ -117,6 +120,7 @@ interface SelectFieldProps {
warningMessage?: string | null;
helperText?: string | null;
controlRef?: React.RefObject<View | null>;
testID?: string;
}
export function SelectField({
@@ -129,6 +133,7 @@ export function SelectField({
warningMessage,
helperText,
controlRef,
testID,
}: SelectFieldProps): ReactElement {
return (
<View style={styles.selectFieldContainer}>
@@ -136,6 +141,7 @@ export function SelectField({
ref={controlRef}
onPress={onPress}
disabled={disabled}
testID={testID}
style={[styles.selectFieldControl, disabled && styles.selectFieldControlDisabled]}
>
<View style={styles.selectFieldContent}>
@@ -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<TextInput>(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 (
<View style={styles.gitOptionsContainer}>
<Pressable
onPress={() => onUseWorktreeChange(!useWorktree)}
testID="worktree-create-toggle"
onPress={() =>
onWorktreeModeChange(isCreateMode ? "none" : "create")
}
disabled={isLoading}
style={[styles.worktreeToggle, isLoading && styles.worktreeToggleDisabled]}
>
<View style={[styles.checkbox, useWorktree && styles.checkboxChecked]}>
{useWorktree ? <View style={styles.checkboxDot} /> : null}
<View style={[styles.checkbox, isCreateMode && styles.checkboxChecked]}>
{isCreateMode ? <View style={styles.checkboxDot} /> : null}
</View>
<View style={styles.worktreeToggleContent}>
<Text style={styles.worktreeToggleLabel}>Create worktree</Text>
<Text style={styles.worktreeToggleDescription}>
{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({
</View>
</Pressable>
{useWorktree ? (
<Pressable
testID="worktree-attach-toggle"
onPress={() =>
onWorktreeModeChange(isAttachMode ? "none" : "attach")
}
disabled={isLoading}
style={[styles.worktreeToggle, isLoading && styles.worktreeToggleDisabled]}
>
<View style={[styles.checkbox, isAttachMode && styles.checkboxChecked]}>
{isAttachMode ? <View style={styles.checkboxDot} /> : null}
</View>
<View style={styles.worktreeToggleContent}>
<Text style={styles.worktreeToggleLabel}>Attach to existing worktree</Text>
<Text style={styles.worktreeToggleDescription}>
{isLoading ? "Inspecting repository…" : "Pick a Paseo worktree by branch"}
</Text>
</View>
</Pressable>
{isAttachMode ? (
<>
<SelectField
label="Worktree"
value={selectedWorktreeLabel}
placeholder="Select a worktree"
onPress={() => setIsWorktreeSheetOpen(true)}
disabled={isLoading || worktreeOptionsStatus === "loading"}
helperText={worktreeHelperText}
errorMessage={attachWorktreeError || worktreeOptionsError}
testID="worktree-attach-picker"
/>
<DropdownSheet
title="Select worktree"
visible={isWorktreeSheetOpen}
onClose={() => setIsWorktreeSheetOpen(false)}
>
{worktreeOptions.map((option) => (
<Pressable
key={option.path}
style={styles.dropdownSheetOption}
onPress={() => {
onSelectWorktreePath(option.path);
setIsWorktreeSheetOpen(false);
}}
>
<Text style={styles.dropdownSheetOptionLabel}>{option.label}</Text>
</Pressable>
))}
</DropdownSheet>
</>
) : null}
{isCreateMode ? (
<View style={styles.baseBranchRow}>
<Text style={styles.baseBranchLabel}>Base branch:</Text>
{isEditingBranch ? (

View File

@@ -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 (
<View style={styles.fileSection} testID={testID}>
<Pressable
testID={testID ? `${testID}-toggle` : undefined}
style={({ pressed }) => [
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<string | null>(null);
const [actionStatus, setActionStatus] = useState<string | null>(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<ParsedDiffFile> = useCallback(
({ item, index }) => (
<DiffFileSection
@@ -440,58 +586,321 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
}
const hasChanges = files.length > 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 = (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" />
<Text style={styles.loadingText}>Checking repository...</Text>
</View>
);
} else if (statusErrorMessage) {
bodyContent = (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{statusErrorMessage}</Text>
</View>
);
} else if (notGit) {
bodyContent = (
<View style={styles.emptyContainer} testID="changes-not-git">
<Text style={styles.emptyText}>Not a git repository</Text>
</View>
);
} else if (isDiffLoading) {
bodyContent = (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" />
<Text style={styles.loadingText}>Loading changes...</Text>
</View>
);
}
if (isError) {
return (
} else if (diffErrorMessage) {
bodyContent = (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{errorMessage ?? "Failed to load changes"}</Text>
<Text style={styles.errorText}>{diffErrorMessage}</Text>
</View>
);
}
if (!hasChanges) {
return (
} else if (!hasChanges) {
bodyContent = (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No changes</Text>
<Text style={styles.emptyText}>
{diffMode === "uncommitted" ? "No uncommitted changes" : "No base changes"}
</Text>
</View>
);
} else {
bodyContent = (
<FlatList
data={files}
renderItem={renderFileSection}
keyExtractor={keyExtractor}
extraData={expandedByPath}
style={styles.scrollView}
contentContainerStyle={styles.contentContainer}
testID="git-diff-scroll"
onRefresh={handleRefresh}
refreshing={isManualRefresh && isDiffFetching}
initialNumToRender={3}
maxToRenderPerBatch={3}
windowSize={5}
/>
);
}
return (
<FlatList
data={files}
renderItem={renderFileSection}
keyExtractor={keyExtractor}
extraData={expandedByPath}
style={styles.scrollView}
contentContainerStyle={styles.contentContainer}
testID="git-diff-scroll"
onRefresh={handleRefresh}
refreshing={isManualRefresh && isFetching}
initialNumToRender={3}
maxToRenderPerBatch={3}
windowSize={5}
/>
<View style={styles.container}>
<View style={styles.header} testID="changes-header">
<View style={styles.headerLeft}>
<GitBranch size={16} color={theme.colors.foregroundMuted} />
<Text style={styles.branchLabel} testID="changes-branch" numberOfLines={1}>
{branchLabel}
</Text>
{isStatusFetching && (
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
)}
</View>
<View style={styles.modeToggle}>
<Pressable
testID="changes-mode-uncommitted"
onPress={() => setDiffMode("uncommitted")}
style={[
styles.modeToggleButton,
diffMode === "uncommitted" && styles.modeToggleButtonActive,
]}
>
<Text
style={[
styles.modeToggleText,
diffMode === "uncommitted" && styles.modeToggleTextActive,
]}
>
Uncommitted
</Text>
</Pressable>
<Pressable
testID="changes-mode-base"
onPress={() => setDiffMode("base")}
style={[
styles.modeToggleButton,
diffMode === "base" && styles.modeToggleButtonActive,
]}
>
<Text
style={[
styles.modeToggleText,
diffMode === "base" && styles.modeToggleTextActive,
]}
>
Base
</Text>
</Pressable>
</View>
</View>
<View style={styles.actionsRow}>
{showCommitAction ? (
<Pressable
testID="changes-action-commit"
style={[styles.actionButton, commitDisabled && styles.actionButtonDisabled]}
onPress={() => commitMutation.mutate()}
disabled={commitDisabled}
>
{commitMutation.isPending ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
) : (
<Text style={styles.actionButtonText}>Commit</Text>
)}
</Pressable>
) : null}
{showCreatePrAction ? (
<Pressable
testID="changes-action-create-pr"
style={[styles.actionButton, prDisabled && styles.actionButtonDisabled]}
onPress={() => prMutation.mutate()}
disabled={prDisabled}
>
{prMutation.isPending ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
) : (
<Text style={styles.actionButtonText}>Create PR</Text>
)}
</Pressable>
) : null}
<Pressable
testID="changes-action-merge"
style={[styles.actionButton, mergeDisabled && styles.actionButtonDisabled]}
onPress={() => mergeMutation.mutate()}
disabled={mergeDisabled}
>
{mergeMutation.isPending ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
) : (
<Text style={styles.actionButtonText}>Merge to base</Text>
)}
</Pressable>
<Pressable
testID="changes-action-archive"
style={[styles.actionButton, archiveDisabled && styles.actionButtonDisabled]}
onPress={() => archiveMutation.mutate()}
disabled={archiveDisabled}
>
{archiveMutation.isPending ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
) : (
<Text style={styles.actionButtonText}>Archive</Text>
)}
</Pressable>
</View>
{actionStatus ? <Text style={styles.actionStatusText}>{actionStatus}</Text> : null}
{actionError ? <Text style={styles.actionErrorText}>{actionError}</Text> : null}
{prStatus ? (
<View style={styles.prStatusRow} testID="changes-pr-status">
<Text style={styles.prStatusLabel}>PR</Text>
<Text style={styles.prStatusValue}>
{prStatus.state} {prStatus.url ? `· ${prStatus.url}` : ""}
</Text>
</View>
) : null}
{prErrorMessage ? (
<Text style={styles.actionErrorText}>{prErrorMessage}</Text>
) : null}
<View style={styles.diffContainer}>{bodyContent}</View>
</View>
);
}
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: {

View File

@@ -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<DiffLine["tokens"]>[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,
};
}

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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;
}

View File

@@ -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();

View File

@@ -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<string> {
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<void> {

View File

@@ -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");

View File

@@ -98,6 +98,68 @@ async function getCurrentBranch(cwd: string): Promise<string | null> {
return branch.length > 0 ? branch : null;
}
async function getWorktreeRoot(cwd: string): Promise<string | null> {
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<string | null> {
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 {