feat: improve git actions UI and mode tracking after plan approval
This commit is contained in:
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user