feat: add plan mode frontend support with mode toggle and plan review UI

- Add InteractionMode type and ExitPlanModeRequestedEvent to sidecar contracts
- Add PendingPlanReviewRecord domain type and interactionMode to SessionRecord
- Wire onExitPlanMode callback through SidecarClient and RunTurnPendingCommand
- Pass session interaction mode to RunTurnCommand for sidecar consumption
- Handle exit-plan-mode-requested events in AryxAppService
- Add setSessionInteractionMode and dismissSessionPlanReview IPC methods
- Create PlanReviewBanner component with summary, markdown content, and actions
- Add plan mode toggle pill in ChatPane composer area
- Wire implement action as follow-up message (graceful degradation)
- Clear pending plan review on new turn, turn completion, and cancellation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-26 23:39:48 +01:00
co-authored by Copilot
parent 380e402512
commit 231be36e6c
14 changed files with 279 additions and 8 deletions
+55
View File
@@ -7,6 +7,7 @@ import electron from 'electron';
import type {
AgentActivityEvent,
ApprovalRequestedEvent,
ExitPlanModeRequestedEvent,
RunTurnToolingConfig,
SidecarCapabilities,
TurnDeltaEvent,
@@ -584,6 +585,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
session.title = resolveSessionTitle(session, effectivePattern, session.messages);
session.status = 'running';
session.lastError = undefined;
session.pendingPlanReview = undefined;
session.updatedAt = occurredAt;
session.runs = [
createSessionRunRecord({
@@ -613,6 +615,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
sessionId: session.id,
projectPath: project.path,
workspaceKind,
mode: session.interactionMode ?? 'interactive',
pattern: effectivePattern,
messages: session.messages,
tooling: this.buildRunTurnToolingConfig(workspace, session),
@@ -631,6 +634,9 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
await this.handleUserInputRequested(workspace, session.id, requestId, event, (answer, wasFreeform) =>
this.sidecar.resolveUserInput(event.userInputId, answer, wasFreeform));
},
async (event) => {
await this.handleExitPlanModeRequested(workspace, session.id, event);
},
);
await this.awaitFinalResponseApproval(workspace, session.id, requestId, effectivePattern, responseMessages);
@@ -845,6 +851,29 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
return this.persistAndBroadcast(workspace);
}
async setSessionInteractionMode(
sessionId: string,
mode: 'interactive' | 'plan',
): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const session = this.requireSession(workspace, sessionId);
session.interactionMode = mode === 'interactive' ? undefined : mode;
session.updatedAt = nowIso();
return this.persistAndBroadcast(workspace);
}
async dismissSessionPlanReview(sessionId: string): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const session = this.requireSession(workspace, sessionId);
session.pendingPlanReview = undefined;
session.updatedAt = nowIso();
return this.persistAndBroadcast(workspace);
}
async updateSessionTooling(
sessionId: string,
enabledMcpServerIds: string[],
@@ -1180,6 +1209,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
session.status = 'idle';
session.lastError = undefined;
session.pendingUserInput = undefined;
session.pendingPlanReview = undefined;
session.updatedAt = completedAt;
const completedRun = this.updateSessionRun(session, requestId, (run) =>
completeSessionRunRecord(run, completedAt));
@@ -1211,6 +1241,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
session.status = 'idle';
session.lastError = undefined;
session.pendingUserInput = undefined;
session.pendingPlanReview = undefined;
session.updatedAt = cancelledAt;
const cancelledRun = this.updateSessionRun(session, requestId, (run) =>
cancelSessionRunRecord(run, cancelledAt));
@@ -1285,6 +1316,30 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
await this.persistAndBroadcast(workspace);
}
private async handleExitPlanModeRequested(
workspace: WorkspaceState,
sessionId: string,
event: ExitPlanModeRequestedEvent,
): Promise<void> {
const session = this.requireSession(workspace, sessionId);
const requestedAt = nowIso();
session.pendingPlanReview = {
id: event.exitPlanId,
status: 'pending',
agentId: event.agentId,
agentName: event.agentName,
summary: event.summary,
planContent: event.planContent,
actions: event.actions,
recommendedAction: event.recommendedAction,
requestedAt,
};
session.updatedAt = requestedAt;
await this.persistAndBroadcast(workspace);
}
private createPendingApprovalFromSidecarEvent(event: ApprovalRequestedEvent): PendingApprovalRecord {
return {
id: event.approvalId,