mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
327
packages/app/e2e/checkout-ship.spec.ts
Normal file
327
packages/app/e2e/checkout-ship.spec.ts
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -100,4 +100,4 @@ test.afterEach(async ({ page }, testInfo) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
export { test, expect };
|
export { test, expect, type Page };
|
||||||
|
|||||||
@@ -151,17 +151,76 @@ export const openSettings = async (page: Page) => {
|
|||||||
export const setWorkingDirectory = async (page: Page, directory: string) => {
|
export const setWorkingDirectory = async (page: Page, directory: string) => {
|
||||||
const workingDirectoryLabel = page.getByText('WORKING DIRECTORY', { exact: true }).first();
|
const workingDirectoryLabel = page.getByText('WORKING DIRECTORY', { exact: true }).first();
|
||||||
await expect(workingDirectoryLabel).toBeVisible();
|
await expect(workingDirectoryLabel).toBeVisible();
|
||||||
await workingDirectoryLabel.click();
|
|
||||||
|
|
||||||
const input = page.getByRole('textbox', { name: '/path/to/project' });
|
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.fill(directory);
|
||||||
await input.press('Enter');
|
await input.press('Enter');
|
||||||
|
|
||||||
const useOption = page.getByText(`Use "${directory}"`);
|
const useOption = page.getByText(`Use "${directory}"`);
|
||||||
await expect(useOption).toBeVisible();
|
await expect(useOption).toBeVisible();
|
||||||
await useOption.click();
|
await useOption.click({ force: true });
|
||||||
await expect(page.getByText(directory, { exact: true })).toBeVisible();
|
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) => {
|
export const ensureHostSelected = async (page: Page) => {
|
||||||
|
|||||||
@@ -5,20 +5,57 @@ import path from 'node:path';
|
|||||||
|
|
||||||
type TempRepo = {
|
type TempRepo = {
|
||||||
path: string;
|
path: string;
|
||||||
|
owner?: string;
|
||||||
|
name?: string;
|
||||||
cleanup: () => Promise<void>;
|
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 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');
|
await writeFile(path.join(repoPath, 'README.md'), '# Temp Repo\n');
|
||||||
execSync('git add README.md', { cwd: repoPath, stdio: 'ignore' });
|
execSync('git add README.md', { cwd: repoPath, stdio: 'ignore' });
|
||||||
execSync('git commit -m "Initial commit"', { 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 {
|
return {
|
||||||
path: repoPath,
|
path: repoPath,
|
||||||
|
owner,
|
||||||
|
name: repoName,
|
||||||
cleanup: async () => {
|
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 });
|
await rm(repoPath, { recursive: true, force: true });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -168,9 +168,10 @@ export default function HomeScreen() {
|
|||||||
const [errorMessage, setErrorMessage] = useState("");
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [promptText, setPromptText] = useState("");
|
const [promptText, setPromptText] = useState("");
|
||||||
const [useWorktree, setUseWorktree] = useState(false);
|
const [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">("none");
|
||||||
const [baseBranch, setBaseBranch] = useState("");
|
const [baseBranch, setBaseBranch] = useState("");
|
||||||
const [worktreeSlug, setWorktreeSlug] = useState("");
|
const [worktreeSlug, setWorktreeSlug] = useState("");
|
||||||
|
const [selectedWorktreePath, setSelectedWorktreePath] = useState("");
|
||||||
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
|
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
|
||||||
|
|
||||||
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
|
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
|
||||||
@@ -293,13 +294,73 @@ export default function HomeScreen() {
|
|||||||
const gitHelperText = isNonGitDirectory
|
const gitHelperText = isNonGitDirectory
|
||||||
? "No git repository detected. Git options are disabled for this directory."
|
? "No git repository detected. Git options are disabled for this directory."
|
||||||
: null;
|
: null;
|
||||||
|
const isCreateWorktree = worktreeMode === "create";
|
||||||
|
const isAttachWorktree = worktreeMode === "attach";
|
||||||
|
|
||||||
const handleUseWorktreeChange = useCallback((value: boolean) => {
|
const worktreeListRoot = repoInfo?.repoRoot ?? trimmedWorkingDir;
|
||||||
setUseWorktree(value);
|
const worktreeListQuery = useQuery({
|
||||||
if (value && !worktreeSlug) {
|
queryKey: ["paseoWorktreeList", selectedServerId, worktreeListRoot],
|
||||||
setWorktreeSlug(createNameId());
|
queryFn: async () => {
|
||||||
}
|
const client = sessionClient;
|
||||||
}, [worktreeSlug]);
|
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(
|
const validateWorktreeName = useCallback(
|
||||||
(name: string): { valid: boolean; error?: string } => {
|
(name: string): { valid: boolean; error?: string } => {
|
||||||
@@ -330,7 +391,7 @@ export default function HomeScreen() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const gitBlockingError = useMemo(() => {
|
const gitBlockingError = useMemo(() => {
|
||||||
if (!useWorktree || isNonGitDirectory) {
|
if (!isCreateWorktree || isNonGitDirectory) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!worktreeSlug) {
|
if (!worktreeSlug) {
|
||||||
@@ -344,14 +405,14 @@ export default function HomeScreen() {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}, [
|
}, [
|
||||||
useWorktree,
|
isCreateWorktree,
|
||||||
isNonGitDirectory,
|
isNonGitDirectory,
|
||||||
worktreeSlug,
|
worktreeSlug,
|
||||||
validateWorktreeName,
|
validateWorktreeName,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const baseBranchError = useMemo(() => {
|
const baseBranchError = useMemo(() => {
|
||||||
if (!useWorktree || isNonGitDirectory || !baseBranch) {
|
if (!isCreateWorktree || isNonGitDirectory || !baseBranch) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const branches = repoInfo?.branches ?? [];
|
const branches = repoInfo?.branches ?? [];
|
||||||
@@ -363,17 +424,26 @@ export default function HomeScreen() {
|
|||||||
return `Branch "${baseBranch}" not found in repository`;
|
return `Branch "${baseBranch}" not found in repository`;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}, [useWorktree, isNonGitDirectory, baseBranch, repoInfo?.branches]);
|
}, [isCreateWorktree, isNonGitDirectory, baseBranch, repoInfo?.branches]);
|
||||||
|
|
||||||
const handleBaseBranchChange = useCallback((value: string) => {
|
const handleBaseBranchChange = useCallback((value: string) => {
|
||||||
setBaseBranch(value);
|
setBaseBranch(value);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleSelectWorktreePath = useCallback(
|
||||||
|
(path: string) => {
|
||||||
|
setSelectedWorktreePath(path);
|
||||||
|
setWorkingDirFromUser(path);
|
||||||
|
},
|
||||||
|
[setWorkingDirFromUser]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isNonGitDirectory && useWorktree) {
|
if (isNonGitDirectory && worktreeMode !== "none") {
|
||||||
setUseWorktree(false);
|
setWorktreeMode("none");
|
||||||
|
setSelectedWorktreePath("");
|
||||||
}
|
}
|
||||||
}, [isNonGitDirectory, useWorktree]);
|
}, [isNonGitDirectory, worktreeMode]);
|
||||||
|
|
||||||
const sessionMethods = useSessionStore((state) =>
|
const sessionMethods = useSessionStore((state) =>
|
||||||
selectedServerId ? state.sessions[selectedServerId]?.methods : undefined
|
selectedServerId ? state.sessions[selectedServerId]?.methods : undefined
|
||||||
@@ -390,6 +460,10 @@ export default function HomeScreen() {
|
|||||||
setErrorMessage("");
|
setErrorMessage("");
|
||||||
const trimmedPath = workingDir.trim();
|
const trimmedPath = workingDir.trim();
|
||||||
const trimmedPrompt = text.trim();
|
const trimmedPrompt = text.trim();
|
||||||
|
const resolvedWorkingDir =
|
||||||
|
isAttachWorktree && selectedWorktreePath
|
||||||
|
? selectedWorktreePath
|
||||||
|
: trimmedPath;
|
||||||
if (!trimmedPath) {
|
if (!trimmedPath) {
|
||||||
setErrorMessage("Working directory is required");
|
setErrorMessage("Working directory is required");
|
||||||
throw new Error("Working directory is required");
|
throw new Error("Working directory is required");
|
||||||
@@ -410,6 +484,11 @@ export default function HomeScreen() {
|
|||||||
setErrorMessage(gitBlockingError);
|
setErrorMessage(gitBlockingError);
|
||||||
throw new Error(gitBlockingError);
|
throw new Error(gitBlockingError);
|
||||||
}
|
}
|
||||||
|
if (isAttachWorktree && !selectedWorktreePath) {
|
||||||
|
const message = "Select a worktree to attach";
|
||||||
|
setErrorMessage(message);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
if (baseBranchError) {
|
if (baseBranchError) {
|
||||||
setErrorMessage(baseBranchError);
|
setErrorMessage(baseBranchError);
|
||||||
throw new Error(baseBranchError);
|
throw new Error(baseBranchError);
|
||||||
@@ -427,18 +506,26 @@ export default function HomeScreen() {
|
|||||||
const trimmedModel = selectedModel.trim();
|
const trimmedModel = selectedModel.trim();
|
||||||
const config: AgentSessionConfig = {
|
const config: AgentSessionConfig = {
|
||||||
provider: selectedProvider,
|
provider: selectedProvider,
|
||||||
cwd: trimmedPath,
|
cwd: resolvedWorkingDir,
|
||||||
...(modeId ? { modeId } : {}),
|
...(modeId ? { modeId } : {}),
|
||||||
...(trimmedModel ? { model: trimmedModel } : {}),
|
...(trimmedModel ? { model: trimmedModel } : {}),
|
||||||
};
|
};
|
||||||
const effectiveBaseBranch = baseBranch.trim() || repoInfo?.currentBranch || undefined;
|
const effectiveBaseBranch = baseBranch.trim() || repoInfo?.currentBranch || undefined;
|
||||||
const gitOptions = useWorktree && !isNonGitDirectory && worktreeSlug
|
const effectiveWorktreeSlug =
|
||||||
? {
|
isCreateWorktree && !worktreeSlug ? createNameId() : worktreeSlug;
|
||||||
createWorktree: true,
|
if (isCreateWorktree && !worktreeSlug && effectiveWorktreeSlug) {
|
||||||
worktreeSlug,
|
setWorktreeSlug(effectiveWorktreeSlug);
|
||||||
baseBranch: effectiveBaseBranch,
|
}
|
||||||
}
|
const gitOptions =
|
||||||
: undefined;
|
isCreateWorktree && !isNonGitDirectory && effectiveWorktreeSlug
|
||||||
|
? {
|
||||||
|
createWorktree: true,
|
||||||
|
createNewBranch: true,
|
||||||
|
newBranchName: effectiveWorktreeSlug,
|
||||||
|
worktreeSlug: effectiveWorktreeSlug,
|
||||||
|
baseBranch: effectiveBaseBranch,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
void persistFormPreferences();
|
void persistFormPreferences();
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -469,9 +556,10 @@ export default function HomeScreen() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
useWorktree,
|
worktreeMode,
|
||||||
baseBranch,
|
baseBranch,
|
||||||
worktreeSlug,
|
worktreeSlug,
|
||||||
|
selectedWorktreePath,
|
||||||
repoInfo?.currentBranch,
|
repoInfo?.currentBranch,
|
||||||
gitBlockingError,
|
gitBlockingError,
|
||||||
baseBranchError,
|
baseBranchError,
|
||||||
@@ -544,8 +632,8 @@ export default function HomeScreen() {
|
|||||||
/>
|
/>
|
||||||
{trimmedWorkingDir.length > 0 && !isNonGitDirectory ? (
|
{trimmedWorkingDir.length > 0 && !isNonGitDirectory ? (
|
||||||
<GitOptionsSection
|
<GitOptionsSection
|
||||||
useWorktree={useWorktree}
|
worktreeMode={worktreeMode}
|
||||||
onUseWorktreeChange={handleUseWorktreeChange}
|
onWorktreeModeChange={handleWorktreeModeChange}
|
||||||
worktreeSlug={worktreeSlug}
|
worktreeSlug={worktreeSlug}
|
||||||
currentBranch={repoInfo?.currentBranch ?? null}
|
currentBranch={repoInfo?.currentBranch ?? null}
|
||||||
baseBranch={baseBranch}
|
baseBranch={baseBranch}
|
||||||
@@ -555,6 +643,12 @@ export default function HomeScreen() {
|
|||||||
repoError={repoInfoError}
|
repoError={repoInfoError}
|
||||||
gitValidationError={gitBlockingError}
|
gitValidationError={gitBlockingError}
|
||||||
baseBranchError={baseBranchError}
|
baseBranchError={baseBranchError}
|
||||||
|
worktreeOptions={worktreeOptions}
|
||||||
|
selectedWorktreePath={selectedWorktreePath}
|
||||||
|
worktreeOptionsStatus={worktreeOptionsStatus}
|
||||||
|
worktreeOptionsError={worktreeOptionsError}
|
||||||
|
attachWorktreeError={attachWorktreeError}
|
||||||
|
onSelectWorktreePath={handleSelectWorktreePath}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ interface DropdownFieldProps {
|
|||||||
warningMessage?: string | null;
|
warningMessage?: string | null;
|
||||||
helperText?: string | null;
|
helperText?: string | null;
|
||||||
renderTrigger?: DropdownTriggerRenderer;
|
renderTrigger?: DropdownTriggerRenderer;
|
||||||
|
testID?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DropdownField({
|
export function DropdownField({
|
||||||
@@ -64,6 +65,7 @@ export function DropdownField({
|
|||||||
warningMessage,
|
warningMessage,
|
||||||
helperText,
|
helperText,
|
||||||
renderTrigger,
|
renderTrigger,
|
||||||
|
testID,
|
||||||
}: DropdownFieldProps): ReactElement {
|
}: DropdownFieldProps): ReactElement {
|
||||||
if (renderTrigger) {
|
if (renderTrigger) {
|
||||||
return (
|
return (
|
||||||
@@ -88,6 +90,7 @@ export function DropdownField({
|
|||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
testID={testID}
|
||||||
style={[styles.dropdownControl, disabled && styles.dropdownControlDisabled]}
|
style={[styles.dropdownControl, disabled && styles.dropdownControlDisabled]}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
@@ -117,6 +120,7 @@ interface SelectFieldProps {
|
|||||||
warningMessage?: string | null;
|
warningMessage?: string | null;
|
||||||
helperText?: string | null;
|
helperText?: string | null;
|
||||||
controlRef?: React.RefObject<View | null>;
|
controlRef?: React.RefObject<View | null>;
|
||||||
|
testID?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SelectField({
|
export function SelectField({
|
||||||
@@ -129,6 +133,7 @@ export function SelectField({
|
|||||||
warningMessage,
|
warningMessage,
|
||||||
helperText,
|
helperText,
|
||||||
controlRef,
|
controlRef,
|
||||||
|
testID,
|
||||||
}: SelectFieldProps): ReactElement {
|
}: SelectFieldProps): ReactElement {
|
||||||
return (
|
return (
|
||||||
<View style={styles.selectFieldContainer}>
|
<View style={styles.selectFieldContainer}>
|
||||||
@@ -136,6 +141,7 @@ export function SelectField({
|
|||||||
ref={controlRef}
|
ref={controlRef}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
testID={testID}
|
||||||
style={[styles.selectFieldControl, disabled && styles.selectFieldControlDisabled]}
|
style={[styles.selectFieldControl, disabled && styles.selectFieldControlDisabled]}
|
||||||
>
|
>
|
||||||
<View style={styles.selectFieldContent}>
|
<View style={styles.selectFieldContent}>
|
||||||
@@ -1144,8 +1150,8 @@ export function ToggleRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface GitOptionsSectionProps {
|
export interface GitOptionsSectionProps {
|
||||||
useWorktree: boolean;
|
worktreeMode: "none" | "create" | "attach";
|
||||||
onUseWorktreeChange: (value: boolean) => void;
|
onWorktreeModeChange: (value: "none" | "create" | "attach") => void;
|
||||||
worktreeSlug: string;
|
worktreeSlug: string;
|
||||||
currentBranch: string | null;
|
currentBranch: string | null;
|
||||||
baseBranch: string;
|
baseBranch: string;
|
||||||
@@ -1155,11 +1161,17 @@ export interface GitOptionsSectionProps {
|
|||||||
repoError: string | null;
|
repoError: string | null;
|
||||||
gitValidationError: string | null;
|
gitValidationError: string | null;
|
||||||
baseBranchError: 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({
|
export function GitOptionsSection({
|
||||||
useWorktree,
|
worktreeMode,
|
||||||
onUseWorktreeChange,
|
onWorktreeModeChange,
|
||||||
worktreeSlug,
|
worktreeSlug,
|
||||||
currentBranch,
|
currentBranch,
|
||||||
baseBranch,
|
baseBranch,
|
||||||
@@ -1169,11 +1181,20 @@ export function GitOptionsSection({
|
|||||||
repoError,
|
repoError,
|
||||||
gitValidationError,
|
gitValidationError,
|
||||||
baseBranchError,
|
baseBranchError,
|
||||||
|
worktreeOptions,
|
||||||
|
selectedWorktreePath,
|
||||||
|
worktreeOptionsStatus,
|
||||||
|
worktreeOptionsError,
|
||||||
|
attachWorktreeError,
|
||||||
|
onSelectWorktreePath,
|
||||||
}: GitOptionsSectionProps): ReactElement {
|
}: GitOptionsSectionProps): ReactElement {
|
||||||
const isLoading = status === "loading";
|
const isLoading = status === "loading";
|
||||||
|
const isCreateMode = worktreeMode === "create";
|
||||||
|
const isAttachMode = worktreeMode === "attach";
|
||||||
const [isEditingBranch, setIsEditingBranch] = useState(false);
|
const [isEditingBranch, setIsEditingBranch] = useState(false);
|
||||||
const [editedBranch, setEditedBranch] = useState(baseBranch);
|
const [editedBranch, setEditedBranch] = useState(baseBranch);
|
||||||
const inputRef = useRef<TextInput>(null);
|
const inputRef = useRef<TextInput>(null);
|
||||||
|
const [isWorktreeSheetOpen, setIsWorktreeSheetOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEditedBranch(baseBranch);
|
setEditedBranch(baseBranch);
|
||||||
@@ -1204,24 +1225,35 @@ export function GitOptionsSection({
|
|||||||
}, [baseBranch]);
|
}, [baseBranch]);
|
||||||
|
|
||||||
const displayBranch = baseBranch || currentBranch || "HEAD";
|
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 (
|
return (
|
||||||
<View style={styles.gitOptionsContainer}>
|
<View style={styles.gitOptionsContainer}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => onUseWorktreeChange(!useWorktree)}
|
testID="worktree-create-toggle"
|
||||||
|
onPress={() =>
|
||||||
|
onWorktreeModeChange(isCreateMode ? "none" : "create")
|
||||||
|
}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
style={[styles.worktreeToggle, isLoading && styles.worktreeToggleDisabled]}
|
style={[styles.worktreeToggle, isLoading && styles.worktreeToggleDisabled]}
|
||||||
>
|
>
|
||||||
<View style={[styles.checkbox, useWorktree && styles.checkboxChecked]}>
|
<View style={[styles.checkbox, isCreateMode && styles.checkboxChecked]}>
|
||||||
{useWorktree ? <View style={styles.checkboxDot} /> : null}
|
{isCreateMode ? <View style={styles.checkboxDot} /> : null}
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.worktreeToggleContent}>
|
<View style={styles.worktreeToggleContent}>
|
||||||
<Text style={styles.worktreeToggleLabel}>Create worktree</Text>
|
<Text style={styles.worktreeToggleLabel}>Create worktree</Text>
|
||||||
<Text style={styles.worktreeToggleDescription}>
|
<Text style={styles.worktreeToggleDescription}>
|
||||||
{isLoading
|
{isLoading
|
||||||
? "Inspecting repository…"
|
? "Inspecting repository…"
|
||||||
: useWorktree && worktreeSlug
|
: isCreateMode
|
||||||
? `Will create: ${worktreeSlug}`
|
? `Will create: ${worktreeSlug || "preparing…"}`
|
||||||
: currentBranch
|
: currentBranch
|
||||||
? `Run isolated from ${currentBranch}`
|
? `Run isolated from ${currentBranch}`
|
||||||
: "Run in an isolated directory"}
|
: "Run in an isolated directory"}
|
||||||
@@ -1229,7 +1261,59 @@ export function GitOptionsSection({
|
|||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</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}>
|
<View style={styles.baseBranchRow}>
|
||||||
<Text style={styles.baseBranchLabel}>Base branch:</Text>
|
<Text style={styles.baseBranchLabel}>Base branch:</Text>
|
||||||
{isEditingBranch ? (
|
{isEditingBranch ? (
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@@ -11,14 +11,17 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
|
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
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 { useSessionStore } from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
useHighlightedDiffQuery,
|
useCheckoutDiffQuery,
|
||||||
type ParsedDiffFile,
|
type ParsedDiffFile,
|
||||||
type DiffLine,
|
type DiffLine,
|
||||||
type HighlightToken,
|
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 { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
|
||||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
@@ -260,6 +263,7 @@ const DiffFileSection = memo(function DiffFileSection({
|
|||||||
return (
|
return (
|
||||||
<View style={styles.fileSection} testID={testID}>
|
<View style={styles.fileSection} testID={testID}>
|
||||||
<Pressable
|
<Pressable
|
||||||
|
testID={testID ? `${testID}-toggle` : undefined}
|
||||||
style={({ pressed }) => [
|
style={({ pressed }) => [
|
||||||
styles.fileHeader,
|
styles.fileHeader,
|
||||||
pressed && styles.fileHeaderPressed,
|
pressed && styles.fileHeaderPressed,
|
||||||
@@ -341,9 +345,44 @@ interface GitDiffPaneProps {
|
|||||||
|
|
||||||
export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
||||||
const { theme } = useUnistyles();
|
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,
|
serverId,
|
||||||
agentId,
|
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
|
// Track user-initiated refresh to avoid iOS RefreshControl animation on background fetches
|
||||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||||
@@ -374,8 +413,10 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
|||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
setIsManualRefresh(true);
|
setIsManualRefresh(true);
|
||||||
refresh();
|
void refreshDiff();
|
||||||
}, [refresh]);
|
void refreshStatus();
|
||||||
|
void refreshPrStatus();
|
||||||
|
}, [refreshDiff, refreshStatus, refreshPrStatus]);
|
||||||
|
|
||||||
const handleToggleExpanded = useCallback((path: string) => {
|
const handleToggleExpanded = useCallback((path: string) => {
|
||||||
setExpandedByPath((prev) => ({
|
setExpandedByPath((prev) => ({
|
||||||
@@ -386,10 +427,10 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
|||||||
|
|
||||||
// Reset manual refresh flag when fetch completes
|
// Reset manual refresh flag when fetch completes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isFetching && isManualRefresh) {
|
if (!(isDiffFetching || isStatusFetching) && isManualRefresh) {
|
||||||
setIsManualRefresh(false);
|
setIsManualRefresh(false);
|
||||||
}
|
}
|
||||||
}, [isFetching, isManualRefresh]);
|
}, [isDiffFetching, isStatusFetching, isManualRefresh]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isPerfLoggingEnabled()) {
|
if (!isPerfLoggingEnabled()) {
|
||||||
@@ -408,15 +449,120 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
|||||||
hunkCount: diffMetrics.hunkCount,
|
hunkCount: diffMetrics.hunkCount,
|
||||||
lineCount: diffMetrics.lineCount,
|
lineCount: diffMetrics.lineCount,
|
||||||
tokenCount: diffMetrics.tokenCount,
|
tokenCount: diffMetrics.tokenCount,
|
||||||
isLoading,
|
isLoading: isDiffLoading,
|
||||||
isFetching,
|
isFetching: isDiffFetching,
|
||||||
});
|
});
|
||||||
}, [agentId, diffMetrics, isFetching, isLoading, serverId]);
|
}, [agentId, diffMetrics, isDiffFetching, isDiffLoading, serverId]);
|
||||||
|
|
||||||
const agentExists = useSessionStore((state) =>
|
const agentExists = useSessionStore((state) =>
|
||||||
state.sessions[serverId]?.agents?.has(agentId) ?? false
|
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(
|
const renderFileSection: ListRenderItem<ParsedDiffFile> = useCallback(
|
||||||
({ item, index }) => (
|
({ item, index }) => (
|
||||||
<DiffFileSection
|
<DiffFileSection
|
||||||
@@ -440,58 +586,321 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasChanges = files.length > 0;
|
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) {
|
let bodyContent: ReactElement;
|
||||||
return (
|
|
||||||
|
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}>
|
<View style={styles.loadingContainer}>
|
||||||
<ActivityIndicator size="large" />
|
<ActivityIndicator size="large" />
|
||||||
<Text style={styles.loadingText}>Loading changes...</Text>
|
<Text style={styles.loadingText}>Loading changes...</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
} else if (diffErrorMessage) {
|
||||||
|
bodyContent = (
|
||||||
if (isError) {
|
|
||||||
return (
|
|
||||||
<View style={styles.errorContainer}>
|
<View style={styles.errorContainer}>
|
||||||
<Text style={styles.errorText}>{errorMessage ?? "Failed to load changes"}</Text>
|
<Text style={styles.errorText}>{diffErrorMessage}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
} else if (!hasChanges) {
|
||||||
|
bodyContent = (
|
||||||
if (!hasChanges) {
|
|
||||||
return (
|
|
||||||
<View style={styles.emptyContainer}>
|
<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>
|
</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 (
|
return (
|
||||||
<FlatList
|
<View style={styles.container}>
|
||||||
data={files}
|
<View style={styles.header} testID="changes-header">
|
||||||
renderItem={renderFileSection}
|
<View style={styles.headerLeft}>
|
||||||
keyExtractor={keyExtractor}
|
<GitBranch size={16} color={theme.colors.foregroundMuted} />
|
||||||
extraData={expandedByPath}
|
<Text style={styles.branchLabel} testID="changes-branch" numberOfLines={1}>
|
||||||
style={styles.scrollView}
|
{branchLabel}
|
||||||
contentContainerStyle={styles.contentContainer}
|
</Text>
|
||||||
testID="git-diff-scroll"
|
{isStatusFetching && (
|
||||||
onRefresh={handleRefresh}
|
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||||
refreshing={isManualRefresh && isFetching}
|
)}
|
||||||
initialNumToRender={3}
|
</View>
|
||||||
maxToRenderPerBatch={3}
|
<View style={styles.modeToggle}>
|
||||||
windowSize={5}
|
<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) => ({
|
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: {
|
scrollView: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
contentContainer: {
|
contentContainer: {
|
||||||
paddingHorizontal: theme.spacing[2],
|
|
||||||
paddingTop: theme.spacing[3],
|
|
||||||
paddingBottom: theme.spacing[8],
|
paddingBottom: theme.spacing[8],
|
||||||
},
|
},
|
||||||
loadingContainer: {
|
loadingContainer: {
|
||||||
@@ -528,20 +937,18 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
fileSection: {
|
fileSection: {
|
||||||
borderRadius: theme.borderRadius.lg,
|
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
borderWidth: theme.borderWidth[1],
|
borderBottomWidth: 1,
|
||||||
borderColor: theme.colors.borderAccent,
|
borderBottomColor: theme.colors.borderAccent,
|
||||||
marginBottom: theme.spacing[2],
|
|
||||||
},
|
},
|
||||||
fileHeader: {
|
fileHeader: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
padding: theme.spacing[2],
|
paddingHorizontal: theme.spacing[2],
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[1],
|
||||||
},
|
},
|
||||||
fileHeaderPressed: {
|
fileHeaderPressed: {
|
||||||
opacity: 0.7,
|
opacity: 0.7,
|
||||||
@@ -549,14 +956,14 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fileHeaderLeft: {
|
fileHeaderLeft: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[1],
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
fileHeaderRight: {
|
fileHeaderRight: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[1],
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
chevronContainer: {
|
chevronContainer: {
|
||||||
@@ -569,7 +976,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontFamily: Fonts.mono,
|
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
newBadge: {
|
newBadge: {
|
||||||
|
|||||||
92
packages/app/src/hooks/use-checkout-diff-query.ts
Normal file
92
packages/app/src/hooks/use-checkout-diff-query.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
53
packages/app/src/hooks/use-checkout-pr-status-query.ts
Normal file
53
packages/app/src/hooks/use-checkout-pr-status-query.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
47
packages/app/src/hooks/use-checkout-status-query.ts
Normal file
47
packages/app/src/hooks/use-checkout-status-query.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,7 +3,12 @@ import type { Agent } from "@/stores/session-store";
|
|||||||
export function extractAgentModel(agent?: Agent | null): string | null {
|
export function extractAgentModel(agent?: Agent | null): string | null {
|
||||||
if (!agent) return null;
|
if (!agent) return null;
|
||||||
const runtimeModel = agent.runtimeInfo?.model;
|
const runtimeModel = agent.runtimeInfo?.model;
|
||||||
return typeof runtimeModel === "string" && runtimeModel.trim().length > 0
|
const fallbackModel = agent.model;
|
||||||
? runtimeModel.trim()
|
if (typeof runtimeModel === "string" && runtimeModel.trim().length > 0) {
|
||||||
: null;
|
return runtimeModel.trim();
|
||||||
|
}
|
||||||
|
if (typeof fallbackModel === "string" && fallbackModel.trim().length > 0) {
|
||||||
|
return fallbackModel.trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,12 +204,16 @@ describe("daemon checkout ship loop", () => {
|
|||||||
expect(diffUncommitted.error).toBeNull();
|
expect(diffUncommitted.error).toBeNull();
|
||||||
expect(diffUncommitted.files.length).toBeGreaterThan(0);
|
expect(diffUncommitted.files.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const timelineBeforeCommit =
|
||||||
|
ctx.daemon.daemon.agentManager.getTimeline(agent.id).length;
|
||||||
const commitResult = await ctx.client.checkoutCommit(agent.id, {
|
const commitResult = await ctx.client.checkoutCommit(agent.id, {
|
||||||
message: "Ship loop update",
|
|
||||||
addAll: true,
|
addAll: true,
|
||||||
});
|
});
|
||||||
expect(commitResult.error).toBeNull();
|
expect(commitResult.error).toBeNull();
|
||||||
expect(commitResult.success).toBe(true);
|
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, {
|
const diffAfterCommit = await ctx.client.getCheckoutDiff(agent.id, {
|
||||||
mode: "uncommitted",
|
mode: "uncommitted",
|
||||||
@@ -222,13 +226,16 @@ describe("daemon checkout ship loop", () => {
|
|||||||
});
|
});
|
||||||
expect(baseDiff.files.length).toBeGreaterThan(0);
|
expect(baseDiff.files.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const timelineBeforePr =
|
||||||
|
ctx.daemon.daemon.agentManager.getTimeline(agent.id).length;
|
||||||
const prCreate = await ctx.client.checkoutPrCreate(agent.id, {
|
const prCreate = await ctx.client.checkoutPrCreate(agent.id, {
|
||||||
title: "Ship loop update",
|
|
||||||
body: "Testing checkout ship loop",
|
|
||||||
baseRef: "main",
|
baseRef: "main",
|
||||||
});
|
});
|
||||||
expect(prCreate.error).toBeNull();
|
expect(prCreate.error).toBeNull();
|
||||||
expect(prCreate.url).toContain(repoName);
|
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);
|
const prStatus = await ctx.client.checkoutPrStatus(agent.id);
|
||||||
expect(prStatus.error).toBeNull();
|
expect(prStatus.error).toBeNull();
|
||||||
|
|||||||
@@ -46,7 +46,10 @@ import { buildProviderRegistry } from "./agent/provider-registry.js";
|
|||||||
import { AgentManager } from "./agent/agent-manager.js";
|
import { AgentManager } from "./agent/agent-manager.js";
|
||||||
import type { ManagedAgent } from "./agent/agent-manager.js";
|
import type { ManagedAgent } from "./agent/agent-manager.js";
|
||||||
import { toAgentPayload } from "./agent/agent-projections.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 {
|
import type {
|
||||||
AgentPermissionResponse,
|
AgentPermissionResponse,
|
||||||
AgentPromptContentBlock,
|
AgentPromptContentBlock,
|
||||||
@@ -1767,6 +1770,15 @@ export class Session {
|
|||||||
return resolvedCandidate.startsWith(resolvedRoot + sep);
|
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> {
|
private async generateCommitMessage(agent: ManagedAgent): Promise<string> {
|
||||||
const diff = await getCheckoutDiff(agent.cwd, { mode: "uncommitted" });
|
const diff = await getCheckoutDiff(agent.cwd, { mode: "uncommitted" });
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
@@ -1782,17 +1794,22 @@ export class Session {
|
|||||||
"",
|
"",
|
||||||
diff.diff.length > 0 ? diff.diff : "(No diff available)",
|
diff.diff.length > 0 ? diff.diff : "(No diff available)",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
const result = await getStructuredAgentResponse({
|
try {
|
||||||
caller: async (nextPrompt) => {
|
const result = await generateStructuredAgentResponse({
|
||||||
const run = await this.agentManager.runAgent(agent.id, nextPrompt);
|
manager: this.agentManager,
|
||||||
return run.finalText;
|
agentConfig: this.buildEphemeralAgentConfig(agent, "Commit generator"),
|
||||||
},
|
prompt,
|
||||||
prompt,
|
schema,
|
||||||
schema,
|
schemaName: "CommitMessage",
|
||||||
schemaName: "CommitMessage",
|
maxRetries: 2,
|
||||||
maxRetries: 2,
|
});
|
||||||
});
|
return result.message;
|
||||||
return result.message;
|
} catch (error) {
|
||||||
|
if (error instanceof StructuredAgentResponseError) {
|
||||||
|
return "Update files";
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async generatePullRequestText(agent: ManagedAgent, baseRef?: string): Promise<{
|
private async generatePullRequestText(agent: ManagedAgent, baseRef?: string): Promise<{
|
||||||
@@ -1813,16 +1830,24 @@ export class Session {
|
|||||||
"",
|
"",
|
||||||
diff.diff.length > 0 ? diff.diff : "(No diff available)",
|
diff.diff.length > 0 ? diff.diff : "(No diff available)",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
return await getStructuredAgentResponse({
|
try {
|
||||||
caller: async (nextPrompt) => {
|
return await generateStructuredAgentResponse({
|
||||||
const run = await this.agentManager.runAgent(agent.id, nextPrompt);
|
manager: this.agentManager,
|
||||||
return run.finalText;
|
agentConfig: this.buildEphemeralAgentConfig(agent, "PR generator"),
|
||||||
},
|
prompt,
|
||||||
prompt,
|
schema,
|
||||||
schema,
|
schemaName: "PullRequest",
|
||||||
schemaName: "PullRequest",
|
maxRetries: 2,
|
||||||
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> {
|
private async ensureCleanWorkingTree(cwd: string): Promise<void> {
|
||||||
|
|||||||
@@ -113,16 +113,24 @@ describe("checkout git utilities", () => {
|
|||||||
expect(message).toBe("worktree update");
|
expect(message).toBe("worktree update");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("merges the current branch into base", async () => {
|
it("merges the current branch into base from a worktree checkout", async () => {
|
||||||
writeFileSync(join(repoDir, "merge.txt"), "feature\n");
|
const worktree = await createWorktree({
|
||||||
execSync("git checkout -b feature", { cwd: repoDir });
|
branchName: "main",
|
||||||
execSync("git add merge.txt", { cwd: repoDir });
|
cwd: repoDir,
|
||||||
execSync("git -c commit.gpgsign=false commit -m 'feature commit'", { cwd: repoDir });
|
worktreeSlug: "merge",
|
||||||
const featureCommit = execSync("git rev-parse HEAD", { cwd: repoDir })
|
});
|
||||||
|
|
||||||
|
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()
|
.toString()
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
await mergeToBase(repoDir, { baseRef: "main" });
|
await mergeToBase(worktree.worktreePath, { baseRef: "main" });
|
||||||
|
|
||||||
const baseContainsFeature = execSync(`git merge-base --is-ancestor ${featureCommit} main`, {
|
const baseContainsFeature = execSync(`git merge-base --is-ancestor ${featureCommit} main`, {
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
@@ -130,7 +138,9 @@ describe("checkout git utilities", () => {
|
|||||||
});
|
});
|
||||||
expect(baseContainsFeature).toBeDefined();
|
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()
|
.toString()
|
||||||
.trim();
|
.trim();
|
||||||
expect(currentBranch).toBe("feature");
|
expect(currentBranch).toBe("feature");
|
||||||
|
|||||||
@@ -98,6 +98,68 @@ async function getCurrentBranch(cwd: string): Promise<string | null> {
|
|||||||
return branch.length > 0 ? branch : 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(
|
export async function renameCurrentBranch(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
newName: string
|
newName: string
|
||||||
@@ -295,19 +357,26 @@ export async function mergeToBase(cwd: string, options: MergeToBaseOptions = {})
|
|||||||
if (!currentBranch) {
|
if (!currentBranch) {
|
||||||
throw new Error("Unable to determine current branch for merge");
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const operationCwd = repoInfo.path;
|
const currentWorktreeRoot = (await getWorktreeRoot(cwd)) ?? cwd;
|
||||||
const isSameCheckout = resolve(operationCwd) === resolve(cwd);
|
const baseWorktree = await getWorktreePathForBranch(repoInfo.path, normalizedBaseRef);
|
||||||
|
const operationCwd = baseWorktree ?? currentWorktreeRoot;
|
||||||
|
const isSameCheckout = resolve(operationCwd) === resolve(currentWorktreeRoot);
|
||||||
const originalBranch = await getCurrentBranch(operationCwd);
|
const originalBranch = await getCurrentBranch(operationCwd);
|
||||||
const mode = options.mode ?? "merge";
|
const mode = options.mode ?? "merge";
|
||||||
try {
|
try {
|
||||||
await execAsync(`git checkout ${baseRef}`, { cwd: operationCwd });
|
await execAsync(`git checkout ${normalizedBaseRef}`, { cwd: operationCwd });
|
||||||
if (mode === "squash") {
|
if (mode === "squash") {
|
||||||
await execAsync(`git merge --squash ${currentBranch}`, { cwd: operationCwd });
|
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(
|
await execFileAsync(
|
||||||
"git",
|
"git",
|
||||||
["-c", "commit.gpgsign=false", "commit", "-m", message],
|
["-c", "commit.gpgsign=false", "commit", "-m", message],
|
||||||
@@ -354,7 +423,7 @@ export async function mergeToBase(cwd: string, options: MergeToBaseOptions = {})
|
|||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
throw new MergeConflictError({
|
throw new MergeConflictError({
|
||||||
baseRef,
|
baseRef: normalizedBaseRef,
|
||||||
currentBranch,
|
currentBranch,
|
||||||
conflictFiles: conflicts.length > 0 ? conflicts : [],
|
conflictFiles: conflicts.length > 0 ? conflicts : [],
|
||||||
});
|
});
|
||||||
@@ -368,7 +437,7 @@ export async function mergeToBase(cwd: string, options: MergeToBaseOptions = {})
|
|||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
if (isSameCheckout && originalBranch && originalBranch !== baseRef) {
|
if (isSameCheckout && originalBranch && originalBranch !== normalizedBaseRef) {
|
||||||
try {
|
try {
|
||||||
await execAsync(`git checkout ${originalBranch}`, { cwd: operationCwd });
|
await execAsync(`git checkout ${originalBranch}`, { cwd: operationCwd });
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
Reference in New Issue
Block a user