feat: improve git actions UI and mode tracking after plan approval

This commit is contained in:
Mohamed Boudra
2026-02-03 10:38:08 +07:00
parent 9cc953625f
commit ad632cb3bd
3 changed files with 154 additions and 15 deletions
+19 -15
View File
@@ -876,6 +876,8 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
const hasPullRequest = Boolean(prStatus?.url); const hasPullRequest = Boolean(prStatus?.url);
const hasRemote = gitStatus?.hasRemote ?? false; const hasRemote = gitStatus?.hasRemote ?? false;
const isPaseoOwnedWorktree = gitStatus?.isPaseoOwnedWorktree ?? false; const isPaseoOwnedWorktree = gitStatus?.isPaseoOwnedWorktree ?? false;
const currentBranch = gitStatus?.currentBranch;
const isOnBaseBranch = currentBranch === baseRefLabel;
// ========================================================================== // ==========================================================================
// Git Actions (Data-Oriented) // Git Actions (Data-Oriented)
@@ -950,7 +952,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
if (aheadCount > 0) { if (aheadCount > 0) {
allActions.set("merge-branch", { allActions.set("merge-branch", {
id: "merge-branch", id: "merge-branch",
label: "Merge branch", label: `Merge into ${baseRefLabel}`,
pendingLabel: "Merging...", pendingLabel: "Merging...",
successLabel: "Merged", successLabel: "Merged",
disabled: mergeDisabled, disabled: mergeDisabled,
@@ -960,17 +962,19 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
}); });
} }
// Merge from base - always available // Update from base - only when not on base branch
allActions.set("merge-from-base", { if (!isOnBaseBranch) {
id: "merge-from-base", allActions.set("merge-from-base", {
label: `Merge from ${baseRefLabel}`, id: "merge-from-base",
pendingLabel: "Merging...", label: `Update from ${baseRefLabel}`,
successLabel: "Merged", pendingLabel: "Updating...",
disabled: mergeFromBaseDisabled, successLabel: "Updated",
status: mergeFromBaseAction.status, disabled: mergeFromBaseDisabled,
description: hasUncommittedChanges ? "Requires clean working tree" : undefined, status: mergeFromBaseAction.status,
handler: mergeFromBaseAction.trigger, description: hasUncommittedChanges ? "Requires clean working tree" : undefined,
}); handler: mergeFromBaseAction.trigger,
});
}
// Archive worktree - only for Paseo worktrees // Archive worktree - only for Paseo worktrees
if (isPaseoOwnedWorktree) { if (isPaseoOwnedWorktree) {
@@ -1033,7 +1037,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
return { primary, secondary, menu }; return { primary, secondary, menu };
}, [ }, [
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch,
hasUncommittedChanges, aheadOfOrigin, shipDefault, baseRefLabel, hasUncommittedChanges, aheadOfOrigin, shipDefault, baseRefLabel,
commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled, commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled,
commitAction, pushAction, prCreateAction, mergeAction, mergeFromBaseAction, archiveAction, commitAction, pushAction, prCreateAction, mergeAction, mergeFromBaseAction, archiveAction,
@@ -1089,7 +1093,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
> >
<ChevronDown size={16} color={theme.colors.foregroundMuted} /> <ChevronDown size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260} testID="changes-primary-cta-menu"> <DropdownMenuContent align="end" testID="changes-primary-cta-menu">
{gitActions.secondary.map((action, index) => { {gitActions.secondary.map((action, index) => {
const needsSeparator = action.id === "merge-from-base" || action.id === "push"; const needsSeparator = action.id === "merge-from-base" || action.id === "push";
return ( return (
@@ -1149,7 +1153,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
) : null} ) : null}
</View> </View>
{isGit && hasChanges ? ( {isGit && (hasUncommittedChanges || aheadCount > 0) ? (
<View style={styles.diffStatusContainer}> <View style={styles.diffStatusContainer}>
<Pressable <Pressable
style={({ hovered }) => [ style={({ hovered }) => [
@@ -28,6 +28,10 @@ class TestAgentClient implements AgentClient {
readonly provider = "codex" as const; readonly provider = "codex" as const;
readonly capabilities = TEST_CAPABILITIES; readonly capabilities = TEST_CAPABILITIES;
async isAvailable(): Promise<boolean> {
return true;
}
async createSession(config: AgentSessionConfig): Promise<AgentSession> { async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new TestAgentSession(config); return new TestAgentSession(config);
} }
@@ -368,4 +372,126 @@ describe("AgentManager", () => {
// Should NOT have triggered attention callback for internal agent // Should NOT have triggered attention callback for internal agent
expect(attentionCalls).toHaveLength(0); expect(attentionCalls).toHaveLength(0);
}); });
test("respondToPermission updates currentModeId after plan approval", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
// Create a session that simulates plan approval mode change
let sessionMode = "plan";
class PlanModeTestSession implements AgentSession {
readonly provider = "codex" as const;
readonly capabilities = TEST_CAPABILITIES;
readonly id = randomUUID();
async run(): Promise<AgentRunResult> {
return { sessionId: this.id, finalText: "", timeline: [] };
}
async *stream(): AsyncGenerator<AgentStreamEvent> {
yield { type: "turn_started", provider: this.provider };
yield { type: "turn_completed", provider: this.provider };
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
async getRuntimeInfo() {
return { provider: this.provider, sessionId: this.id, model: null, modeId: sessionMode };
}
async getAvailableModes() {
return [
{ id: "plan", label: "Plan" },
{ id: "acceptEdits", label: "Accept Edits" },
];
}
async getCurrentMode() {
return sessionMode;
}
async setMode(modeId: string): Promise<void> {
sessionMode = modeId;
}
getPendingPermissions() {
return [];
}
async respondToPermission(
_requestId: string,
response: { behavior: string }
): Promise<void> {
// Simulate what claude-agent.ts does: when plan permission is approved,
// it calls setMode("acceptEdits") internally
if (response.behavior === "allow") {
sessionMode = "acceptEdits";
}
}
describePersistence() {
return { provider: this.provider, sessionId: this.id };
}
async interrupt(): Promise<void> {}
async close(): Promise<void> {}
}
class PlanModeTestClient implements AgentClient {
readonly provider = "codex" as const;
readonly capabilities = TEST_CAPABILITIES;
async isAvailable(): Promise<boolean> {
return true;
}
async createSession(): Promise<AgentSession> {
return new PlanModeTestSession();
}
async resumeSession(): Promise<AgentSession> {
return new PlanModeTestSession();
}
}
const manager = new AgentManager({
clients: {
codex: new PlanModeTestClient(),
},
registry: storage,
logger,
idFactory: () => "plan-mode-agent",
});
// Create agent in plan mode
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
modeId: "plan",
});
expect(snapshot.currentModeId).toBe("plan");
// Simulate a pending plan permission request
const agent = manager.getAgent(snapshot.id)!;
const permissionRequest = {
id: "perm-123",
provider: "codex" as const,
name: "ExitPlanMode",
kind: "plan" as const,
input: { plan: "Test plan" },
};
agent.pendingPermissions.set(permissionRequest.id, permissionRequest);
// Approve the plan permission
await manager.respondToPermission(snapshot.id, "perm-123", {
behavior: "allow",
});
// The session's mode has changed to "acceptEdits" internally
// The manager should have updated currentModeId to reflect this
const updatedAgent = manager.getAgent(snapshot.id);
expect(updatedAgent?.currentModeId).toBe("acceptEdits");
});
}); });
@@ -700,6 +700,15 @@ export class AgentManager {
const agent = this.requireAgent(agentId); const agent = this.requireAgent(agentId);
await agent.session.respondToPermission(requestId, response); await agent.session.respondToPermission(requestId, response);
agent.pendingPermissions.delete(requestId); agent.pendingPermissions.delete(requestId);
// Update currentModeId - the session may have changed mode internally
// (e.g., plan approval changes mode from "plan" to "acceptEdits")
try {
agent.currentModeId = await agent.session.getCurrentMode();
} catch {
// Ignore errors from getCurrentMode - mode tracking is best effort
}
this.emitState(agent); this.emitState(agent);
} }