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

View File

@@ -876,6 +876,8 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
const hasPullRequest = Boolean(prStatus?.url);
const hasRemote = gitStatus?.hasRemote ?? false;
const isPaseoOwnedWorktree = gitStatus?.isPaseoOwnedWorktree ?? false;
const currentBranch = gitStatus?.currentBranch;
const isOnBaseBranch = currentBranch === baseRefLabel;
// ==========================================================================
// Git Actions (Data-Oriented)
@@ -950,7 +952,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
if (aheadCount > 0) {
allActions.set("merge-branch", {
id: "merge-branch",
label: "Merge branch",
label: `Merge into ${baseRefLabel}`,
pendingLabel: "Merging...",
successLabel: "Merged",
disabled: mergeDisabled,
@@ -960,17 +962,19 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
});
}
// Merge from base - always available
allActions.set("merge-from-base", {
id: "merge-from-base",
label: `Merge from ${baseRefLabel}`,
pendingLabel: "Merging...",
successLabel: "Merged",
disabled: mergeFromBaseDisabled,
status: mergeFromBaseAction.status,
description: hasUncommittedChanges ? "Requires clean working tree" : undefined,
handler: mergeFromBaseAction.trigger,
});
// Update from base - only when not on base branch
if (!isOnBaseBranch) {
allActions.set("merge-from-base", {
id: "merge-from-base",
label: `Update from ${baseRefLabel}`,
pendingLabel: "Updating...",
successLabel: "Updated",
disabled: mergeFromBaseDisabled,
status: mergeFromBaseAction.status,
description: hasUncommittedChanges ? "Requires clean working tree" : undefined,
handler: mergeFromBaseAction.trigger,
});
}
// Archive worktree - only for Paseo worktrees
if (isPaseoOwnedWorktree) {
@@ -1033,7 +1037,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
return { primary, secondary, menu };
}, [
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree,
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch,
hasUncommittedChanges, aheadOfOrigin, shipDefault, baseRefLabel,
commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled,
commitAction, pushAction, prCreateAction, mergeAction, mergeFromBaseAction, archiveAction,
@@ -1089,7 +1093,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
>
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260} testID="changes-primary-cta-menu">
<DropdownMenuContent align="end" testID="changes-primary-cta-menu">
{gitActions.secondary.map((action, index) => {
const needsSeparator = action.id === "merge-from-base" || action.id === "push";
return (
@@ -1149,7 +1153,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
) : null}
</View>
{isGit && hasChanges ? (
{isGit && (hasUncommittedChanges || aheadCount > 0) ? (
<View style={styles.diffStatusContainer}>
<Pressable
style={({ hovered }) => [

View File

@@ -28,6 +28,10 @@ class TestAgentClient implements AgentClient {
readonly provider = "codex" as const;
readonly capabilities = TEST_CAPABILITIES;
async isAvailable(): Promise<boolean> {
return true;
}
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new TestAgentSession(config);
}
@@ -368,4 +372,126 @@ describe("AgentManager", () => {
// Should NOT have triggered attention callback for internal agent
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");
});
});

View File

@@ -700,6 +700,15 @@ export class AgentManager {
const agent = this.requireAgent(agentId);
await agent.session.respondToPermission(requestId, response);
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);
}