diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 6e938e6a0..03d0574d9 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -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) { > - + {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} - {isGit && hasChanges ? ( + {isGit && (hasUncommittedChanges || aheadCount > 0) ? ( [ diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index dc4721be7..dc65cbd1f 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -28,6 +28,10 @@ class TestAgentClient implements AgentClient { readonly provider = "codex" as const; readonly capabilities = TEST_CAPABILITIES; + async isAvailable(): Promise { + return true; + } + async createSession(config: AgentSessionConfig): Promise { 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 { + return { sessionId: this.id, finalText: "", timeline: [] }; + } + + async *stream(): AsyncGenerator { + yield { type: "turn_started", provider: this.provider }; + yield { type: "turn_completed", provider: this.provider }; + } + + async *streamHistory(): AsyncGenerator {} + + 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 { + sessionMode = modeId; + } + + getPendingPermissions() { + return []; + } + + async respondToPermission( + _requestId: string, + response: { behavior: string } + ): Promise { + // 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 {} + async close(): Promise {} + } + + class PlanModeTestClient implements AgentClient { + readonly provider = "codex" as const; + readonly capabilities = TEST_CAPABILITIES; + + async isAvailable(): Promise { + return true; + } + + async createSession(): Promise { + return new PlanModeTestSession(); + } + + async resumeSession(): Promise { + 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"); + }); }); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index b09a08a9b..4c5f217ef 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -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); }