feat(app): checkout diff pane styling and e2e test support

- Style diff file cards edge-to-edge with single divider between files
- Remove monospace font from filenames for better readability
- Add checkout-ship e2e test for app
- Export Page type from e2e fixtures
- Add checkout query hooks for diff, status, and PR status
This commit is contained in:
Mohamed Boudra
2026-01-23 08:37:15 +07:00
parent 746d413082
commit 53155ebcc9
15 changed files with 1450 additions and 135 deletions
@@ -204,12 +204,16 @@ describe("daemon checkout ship loop", () => {
expect(diffUncommitted.error).toBeNull();
expect(diffUncommitted.files.length).toBeGreaterThan(0);
const timelineBeforeCommit =
ctx.daemon.daemon.agentManager.getTimeline(agent.id).length;
const commitResult = await ctx.client.checkoutCommit(agent.id, {
message: "Ship loop update",
addAll: true,
});
expect(commitResult.error).toBeNull();
expect(commitResult.success).toBe(true);
const timelineAfterCommit =
ctx.daemon.daemon.agentManager.getTimeline(agent.id).length;
expect(timelineAfterCommit).toBe(timelineBeforeCommit);
const diffAfterCommit = await ctx.client.getCheckoutDiff(agent.id, {
mode: "uncommitted",
@@ -222,13 +226,16 @@ describe("daemon checkout ship loop", () => {
});
expect(baseDiff.files.length).toBeGreaterThan(0);
const timelineBeforePr =
ctx.daemon.daemon.agentManager.getTimeline(agent.id).length;
const prCreate = await ctx.client.checkoutPrCreate(agent.id, {
title: "Ship loop update",
body: "Testing checkout ship loop",
baseRef: "main",
});
expect(prCreate.error).toBeNull();
expect(prCreate.url).toContain(repoName);
const timelineAfterPr =
ctx.daemon.daemon.agentManager.getTimeline(agent.id).length;
expect(timelineAfterPr).toBe(timelineBeforePr);
const prStatus = await ctx.client.checkoutPrStatus(agent.id);
expect(prStatus.error).toBeNull();
+47 -22
View File
@@ -46,7 +46,10 @@ import { buildProviderRegistry } from "./agent/provider-registry.js";
import { AgentManager } from "./agent/agent-manager.js";
import type { ManagedAgent } from "./agent/agent-manager.js";
import { toAgentPayload } from "./agent/agent-projections.js";
import { getStructuredAgentResponse } from "./agent/agent-response-loop.js";
import {
StructuredAgentResponseError,
generateStructuredAgentResponse,
} from "./agent/agent-response-loop.js";
import type {
AgentPermissionResponse,
AgentPromptContentBlock,
@@ -1767,6 +1770,15 @@ export class Session {
return resolvedCandidate.startsWith(resolvedRoot + sep);
}
private buildEphemeralAgentConfig(agent: ManagedAgent, title: string): AgentSessionConfig {
return {
...agent.config,
cwd: agent.cwd,
title,
parentAgentId: agent.id,
};
}
private async generateCommitMessage(agent: ManagedAgent): Promise<string> {
const diff = await getCheckoutDiff(agent.cwd, { mode: "uncommitted" });
const schema = z.object({
@@ -1782,17 +1794,22 @@ export class Session {
"",
diff.diff.length > 0 ? diff.diff : "(No diff available)",
].join("\n");
const result = await getStructuredAgentResponse({
caller: async (nextPrompt) => {
const run = await this.agentManager.runAgent(agent.id, nextPrompt);
return run.finalText;
},
prompt,
schema,
schemaName: "CommitMessage",
maxRetries: 2,
});
return result.message;
try {
const result = await generateStructuredAgentResponse({
manager: this.agentManager,
agentConfig: this.buildEphemeralAgentConfig(agent, "Commit generator"),
prompt,
schema,
schemaName: "CommitMessage",
maxRetries: 2,
});
return result.message;
} catch (error) {
if (error instanceof StructuredAgentResponseError) {
return "Update files";
}
throw error;
}
}
private async generatePullRequestText(agent: ManagedAgent, baseRef?: string): Promise<{
@@ -1813,16 +1830,24 @@ export class Session {
"",
diff.diff.length > 0 ? diff.diff : "(No diff available)",
].join("\n");
return await getStructuredAgentResponse({
caller: async (nextPrompt) => {
const run = await this.agentManager.runAgent(agent.id, nextPrompt);
return run.finalText;
},
prompt,
schema,
schemaName: "PullRequest",
maxRetries: 2,
});
try {
return await generateStructuredAgentResponse({
manager: this.agentManager,
agentConfig: this.buildEphemeralAgentConfig(agent, "PR generator"),
prompt,
schema,
schemaName: "PullRequest",
maxRetries: 2,
});
} catch (error) {
if (error instanceof StructuredAgentResponseError) {
return {
title: "Update changes",
body: "Automated PR generated by Paseo.",
};
}
throw error;
}
}
private async ensureCleanWorkingTree(cwd: string): Promise<void> {
+18 -8
View File
@@ -113,16 +113,24 @@ describe("checkout git utilities", () => {
expect(message).toBe("worktree update");
});
it("merges the current branch into base", async () => {
writeFileSync(join(repoDir, "merge.txt"), "feature\n");
execSync("git checkout -b feature", { cwd: repoDir });
execSync("git add merge.txt", { cwd: repoDir });
execSync("git -c commit.gpgsign=false commit -m 'feature commit'", { cwd: repoDir });
const featureCommit = execSync("git rev-parse HEAD", { cwd: repoDir })
it("merges the current branch into base from a worktree checkout", async () => {
const worktree = await createWorktree({
branchName: "main",
cwd: repoDir,
worktreeSlug: "merge",
});
writeFileSync(join(worktree.worktreePath, "merge.txt"), "feature\n");
execSync("git checkout -b feature", { cwd: worktree.worktreePath });
execSync("git add merge.txt", { cwd: worktree.worktreePath });
execSync("git -c commit.gpgsign=false commit -m 'feature commit'", {
cwd: worktree.worktreePath,
});
const featureCommit = execSync("git rev-parse HEAD", { cwd: worktree.worktreePath })
.toString()
.trim();
await mergeToBase(repoDir, { baseRef: "main" });
await mergeToBase(worktree.worktreePath, { baseRef: "main" });
const baseContainsFeature = execSync(`git merge-base --is-ancestor ${featureCommit} main`, {
cwd: repoDir,
@@ -130,7 +138,9 @@ describe("checkout git utilities", () => {
});
expect(baseContainsFeature).toBeDefined();
const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", { cwd: repoDir })
const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", {
cwd: worktree.worktreePath,
})
.toString()
.trim();
expect(currentBranch).toBe("feature");
+76 -7
View File
@@ -98,6 +98,68 @@ async function getCurrentBranch(cwd: string): Promise<string | null> {
return branch.length > 0 ? branch : null;
}
async function getWorktreeRoot(cwd: string): Promise<string | null> {
try {
const { stdout } = await execAsync(
"git rev-parse --path-format=absolute --show-toplevel",
{ cwd, env: READ_ONLY_GIT_ENV }
);
const root = stdout.trim();
return root.length > 0 ? root : null;
} catch {
return null;
}
}
type GitWorktreeEntry = {
path: string;
branchRef?: string;
};
function parseWorktreeList(output: string): GitWorktreeEntry[] {
const entries: GitWorktreeEntry[] = [];
let current: GitWorktreeEntry | null = null;
for (const line of output.split("\n")) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
if (trimmed.startsWith("worktree ")) {
if (current) {
entries.push(current);
}
current = { path: trimmed.slice("worktree ".length).trim() };
continue;
}
if (current && trimmed.startsWith("branch ")) {
current.branchRef = trimmed.slice("branch ".length).trim();
}
}
if (current) {
entries.push(current);
}
return entries;
}
async function getWorktreePathForBranch(
cwd: string,
branchName: string
): Promise<string | null> {
try {
const { stdout } = await execAsync("git worktree list --porcelain", {
cwd,
env: READ_ONLY_GIT_ENV,
});
const entries = parseWorktreeList(stdout);
const ref = branchName.startsWith("refs/heads/")
? branchName
: `refs/heads/${branchName}`;
return entries.find((entry) => entry.branchRef === ref)?.path ?? null;
} catch {
return null;
}
}
export async function renameCurrentBranch(
cwd: string,
newName: string
@@ -295,19 +357,26 @@ export async function mergeToBase(cwd: string, options: MergeToBaseOptions = {})
if (!currentBranch) {
throw new Error("Unable to determine current branch for merge");
}
if (baseRef === currentBranch) {
let normalizedBaseRef = baseRef;
if (normalizedBaseRef.startsWith("origin/")) {
normalizedBaseRef = normalizedBaseRef.slice("origin/".length);
}
if (normalizedBaseRef === currentBranch) {
return;
}
const operationCwd = repoInfo.path;
const isSameCheckout = resolve(operationCwd) === resolve(cwd);
const currentWorktreeRoot = (await getWorktreeRoot(cwd)) ?? cwd;
const baseWorktree = await getWorktreePathForBranch(repoInfo.path, normalizedBaseRef);
const operationCwd = baseWorktree ?? currentWorktreeRoot;
const isSameCheckout = resolve(operationCwd) === resolve(currentWorktreeRoot);
const originalBranch = await getCurrentBranch(operationCwd);
const mode = options.mode ?? "merge";
try {
await execAsync(`git checkout ${baseRef}`, { cwd: operationCwd });
await execAsync(`git checkout ${normalizedBaseRef}`, { cwd: operationCwd });
if (mode === "squash") {
await execAsync(`git merge --squash ${currentBranch}`, { cwd: operationCwd });
const message = options.commitMessage ?? `Squash merge ${currentBranch} into ${baseRef}`;
const message =
options.commitMessage ?? `Squash merge ${currentBranch} into ${normalizedBaseRef}`;
await execFileAsync(
"git",
["-c", "commit.gpgsign=false", "commit", "-m", message],
@@ -354,7 +423,7 @@ export async function mergeToBase(cwd: string, options: MergeToBaseOptions = {})
// ignore
}
throw new MergeConflictError({
baseRef,
baseRef: normalizedBaseRef,
currentBranch,
conflictFiles: conflicts.length > 0 ? conflicts : [],
});
@@ -368,7 +437,7 @@ export async function mergeToBase(cwd: string, options: MergeToBaseOptions = {})
throw error;
} finally {
if (isSameCheckout && originalBranch && originalBranch !== baseRef) {
if (isSameCheckout && originalBranch && originalBranch !== normalizedBaseRef) {
try {
await execAsync(`git checkout ${originalBranch}`, { cwd: operationCwd });
} catch {