diff --git a/src/main/AryxAppService.ts b/src/main/AryxAppService.ts index 251a147..a09903d 100644 --- a/src/main/AryxAppService.ts +++ b/src/main/AryxAppService.ts @@ -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 { 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 { 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 { 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 { return this.persistAndBroadcast(workspace); } + async setSessionInteractionMode( + sessionId: string, + mode: 'interactive' | 'plan', + ): Promise { + 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 { + 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 { 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 { 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 { await this.persistAndBroadcast(workspace); } + private async handleExitPlanModeRequested( + workspace: WorkspaceState, + sessionId: string, + event: ExitPlanModeRequestedEvent, + ): Promise { + 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, diff --git a/src/main/ipc/registerIpcHandlers.ts b/src/main/ipc/registerIpcHandlers.ts index 28807a7..a386925 100644 --- a/src/main/ipc/registerIpcHandlers.ts +++ b/src/main/ipc/registerIpcHandlers.ts @@ -7,6 +7,7 @@ import type { CreateSessionInput, ResolveProjectDiscoveredToolingInput, ResolveWorkspaceDiscoveredToolingInput, + DismissSessionPlanReviewInput, DuplicateSessionInput, RenameSessionInput, RescanProjectConfigsInput, @@ -18,6 +19,7 @@ import type { SendSessionMessageInput, SetPatternFavoriteInput, SetSessionArchivedInput, + SetSessionInteractionModeInput, SetSessionPinnedInput, UpdateSessionApprovalSettingsInput, UpdateSessionToolingInput, @@ -114,6 +116,12 @@ export function registerIpcHandlers(window: BrowserWindow, service: AryxAppServi ipcMain.handle(ipcChannels.resolveSessionUserInput, (_event, input: ResolveSessionUserInputInput) => service.resolveSessionUserInput(input.sessionId, input.userInputId, input.answer, input.wasFreeform), ); + ipcMain.handle(ipcChannels.setSessionInteractionMode, (_event, input: SetSessionInteractionModeInput) => + service.setSessionInteractionMode(input.sessionId, input.mode), + ); + ipcMain.handle(ipcChannels.dismissSessionPlanReview, (_event, input: DismissSessionPlanReviewInput) => + service.dismissSessionPlanReview(input.sessionId), + ); ipcMain.handle( ipcChannels.updateSessionModelConfig, (_event, input: UpdateSessionModelConfigInput) => diff --git a/src/main/sidecar/runTurnPending.ts b/src/main/sidecar/runTurnPending.ts index 4f17115..8c9d68f 100644 --- a/src/main/sidecar/runTurnPending.ts +++ b/src/main/sidecar/runTurnPending.ts @@ -1,6 +1,7 @@ import type { AgentActivityEvent, ApprovalRequestedEvent, + ExitPlanModeRequestedEvent, TurnDeltaEvent, UserInputRequestedEvent, } from '@shared/contracts/sidecar'; @@ -14,6 +15,7 @@ export interface RunTurnPendingCommand { onActivity: (event: AgentActivityEvent) => void | Promise; onApproval: (event: ApprovalRequestedEvent) => void | Promise; onUserInput: (event: UserInputRequestedEvent) => void | Promise; + onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise; errored: boolean; } diff --git a/src/main/sidecar/sidecarProcess.ts b/src/main/sidecar/sidecarProcess.ts index 5bf75d8..52dd10d 100644 --- a/src/main/sidecar/sidecarProcess.ts +++ b/src/main/sidecar/sidecarProcess.ts @@ -10,6 +10,7 @@ import type { SidecarEvent, TurnDeltaEvent, UserInputRequestedEvent, + ExitPlanModeRequestedEvent, ValidatePatternCommand, RunTurnCommand, } from '@shared/contracts/sidecar'; @@ -102,8 +103,9 @@ export class SidecarClient { onActivity: (event: AgentActivityEvent) => void | Promise, onApproval: (event: ApprovalRequestedEvent) => void | Promise, onUserInput: (event: UserInputRequestedEvent) => void | Promise, + onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise, ): Promise { - return this.dispatch(command, onDelta, onActivity, onApproval, onUserInput); + return this.dispatch(command, onDelta, onActivity, onApproval, onUserInput, onExitPlanMode); } async resolveUserInput(userInputId: string, answer: string, wasFreeform: boolean): Promise { @@ -218,6 +220,7 @@ export class SidecarClient { onActivity?: (event: AgentActivityEvent) => void | Promise, onApproval?: (event: ApprovalRequestedEvent) => void | Promise, onUserInput?: (event: UserInputRequestedEvent) => void | Promise, + onExitPlanMode?: (event: ExitPlanModeRequestedEvent) => void | Promise, ): Promise { const state = await this.ensureProcess(); @@ -232,6 +235,7 @@ export class SidecarClient { onActivity: onActivity ?? (() => undefined), onApproval: onApproval ?? (() => undefined), onUserInput: onUserInput ?? (() => undefined), + onExitPlanMode: onExitPlanMode ?? (() => undefined), errored: false, }); } else if (command.type === 'validate-pattern') { @@ -329,6 +333,11 @@ export class SidecarClient { this.invokeRunTurnHandler(event.requestId, pending, () => pending.onUserInput(event)); } return; + case 'exit-plan-mode-requested': + if (pending.kind === 'run-turn' && shouldHandleRunTurnEvent(pending)) { + this.invokeRunTurnHandler(event.requestId, pending, () => pending.onExitPlanMode(event)); + } + return; case 'turn-complete': if (pending.kind === 'run-turn') { if (shouldHandleRunTurnEvent(pending)) { diff --git a/src/preload/index.ts b/src/preload/index.ts index d685022..219f77c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -37,6 +37,8 @@ const api: ElectronApi = { cancelSessionTurn: (input) => ipcRenderer.invoke(ipcChannels.cancelSessionTurn, input), resolveSessionApproval: (input) => ipcRenderer.invoke(ipcChannels.resolveSessionApproval, input), resolveSessionUserInput: (input) => ipcRenderer.invoke(ipcChannels.resolveSessionUserInput, input), + setSessionInteractionMode: (input) => ipcRenderer.invoke(ipcChannels.setSessionInteractionMode, input), + dismissSessionPlanReview: (input) => ipcRenderer.invoke(ipcChannels.dismissSessionPlanReview, input), updateSessionModelConfig: (input) => ipcRenderer.invoke(ipcChannels.updateSessionModelConfig, input), querySessions: (input) => ipcRenderer.invoke(ipcChannels.querySessions, input), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 9e47935..fda2253 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -253,6 +253,12 @@ export default function App() { onResolveUserInput={(userInputId, answer, wasFreeform) => api.resolveSessionUserInput({ sessionId: selectedSession.id, userInputId, answer, wasFreeform }) } + onSetInteractionMode={(mode) => { + void api.setSessionInteractionMode({ sessionId: selectedSession.id, mode }); + }} + onDismissPlanReview={() => { + void api.dismissSessionPlanReview({ sessionId: selectedSession.id }); + }} onUpdateSessionModelConfig={(config) => api.updateSessionModelConfig({ sessionId: selectedSession.id, diff --git a/src/renderer/components/ChatPane.tsx b/src/renderer/components/ChatPane.tsx index 951555e..ce09e61 100644 --- a/src/renderer/components/ChatPane.tsx +++ b/src/renderer/components/ChatPane.tsx @@ -1,14 +1,16 @@ import { useEffect, useMemo, useRef, useState } from 'react'; -import { AlertCircle, ArrowUp, Bot, Circle, GitBranch, Loader2, MessageCircleQuestion, ShieldAlert, Square, User } from 'lucide-react'; +import { AlertCircle, ArrowUp, Bot, Circle, ClipboardList, GitBranch, Loader2, MessageCircleQuestion, ShieldAlert, Square, User } from 'lucide-react'; import { MarkdownContent } from '@renderer/components/MarkdownContent'; import { MarkdownComposer, type MarkdownComposerHandle } from '@renderer/components/MarkdownComposer'; import { ApprovalBanner, QueuedApprovalsList } from '@renderer/components/chat/ApprovalBanner'; +import { PlanReviewBanner } from '@renderer/components/chat/PlanReviewBanner'; import { UserInputBanner } from '@renderer/components/chat/UserInputBanner'; import { InlineApprovalPill, InlineModelPill, InlineThinkingPill, InlineToolsPill } from '@renderer/components/chat/InlinePills'; import { ThinkingDots } from '@renderer/components/chat/ThinkingDots'; import { getAssistantMessagePhase } from '@renderer/lib/messagePhase'; import type { ApprovalDecision } from '@shared/domain/approval'; +import type { InteractionMode } from '@shared/contracts/sidecar'; import { findModel, getSupportedReasoningEfforts, @@ -38,6 +40,8 @@ interface ChatPaneProps { onCancelTurn?: () => void; onResolveApproval?: (approvalId: string, decision: ApprovalDecision) => Promise; onResolveUserInput?: (userInputId: string, answer: string, wasFreeform: boolean) => Promise; + onSetInteractionMode?: (mode: InteractionMode) => void; + onDismissPlanReview?: () => void; onUpdateSessionModelConfig?: (config: { model: string; reasoningEffort?: ReasoningEffort; @@ -57,6 +61,8 @@ export function ChatPane({ onCancelTurn, onResolveApproval, onResolveUserInput, + onSetInteractionMode, + onDismissPlanReview, onUpdateSessionModelConfig, onUpdateSessionTooling, onUpdateSessionApprovalSettings, @@ -75,6 +81,9 @@ export function ChatPane({ const queuedApprovals = (session.pendingApprovalQueue ?? []).filter((a) => a.status === 'pending'); const totalPendingCount = (pendingApproval ? 1 : 0) + queuedApprovals.length; const pendingUserInput = session.pendingUserInput?.status === 'pending' ? session.pendingUserInput : undefined; + const pendingPlanReview = session.pendingPlanReview?.status === 'pending' ? session.pendingPlanReview : undefined; + const interactionMode: InteractionMode = session.interactionMode ?? 'interactive'; + const isPlanMode = interactionMode === 'plan'; const isScratchpad = isScratchpadProject(project); const isSingleAgent = pattern.agents.length === 1; const primaryAgent = pattern.agents[0]; @@ -121,6 +130,16 @@ export function ChatPane({ void onSend(content); } + function handleImplementPlan() { + if (!pendingPlanReview) return; + onDismissPlanReview?.(); + void onSend('Implement the plan.'); + } + + function handleDismissPlan() { + onDismissPlanReview?.(); + } + async function handleSessionModelConfigChange(config: { model: string; reasoningEffort?: ReasoningEffort; @@ -375,6 +394,17 @@ export function ChatPane({ )} + {/* Plan review banner */} + {pendingPlanReview && ( +
+ +
+ )} + {/* Session config pills — tools/approval left, model/reasoning right */} {isSingleAgent && (
@@ -396,6 +426,22 @@ export function ChatPane({ onUpdate={onUpdateSessionApprovalSettings} /> )} + {onSetInteractionMode && ( + + )} {primaryAgent && (
+
+ + {planReview.agentName && ( +
+ Agent: {planReview.agentName} +
+ )} + + {/* Summary */} + {planReview.summary && ( +

+ {planReview.summary} +

+ )} + + {/* Plan content (rendered markdown) */} + {planReview.planContent && ( +
+ +
+ )} +
+ + + {/* Actions */} +
+ + +
+ + ); +} diff --git a/src/shared/contracts/channels.ts b/src/shared/contracts/channels.ts index c6e3c1b..02b76ae 100644 --- a/src/shared/contracts/channels.ts +++ b/src/shared/contracts/channels.ts @@ -27,6 +27,8 @@ export const ipcChannels = { cancelSessionTurn: 'sessions:cancel-turn', resolveSessionApproval: 'sessions:resolve-approval', resolveSessionUserInput: 'sessions:resolve-user-input', + setSessionInteractionMode: 'sessions:set-interaction-mode', + dismissSessionPlanReview: 'sessions:dismiss-plan-review', querySessions: 'sessions:query', updateSessionModelConfig: 'sessions:update-model-config', selectProject: 'selection:project', diff --git a/src/shared/contracts/ipc.ts b/src/shared/contracts/ipc.ts index 79ba60b..467114f 100644 --- a/src/shared/contracts/ipc.ts +++ b/src/shared/contracts/ipc.ts @@ -1,5 +1,5 @@ import type { ApprovalDecision } from '@shared/domain/approval'; -import type { SidecarCapabilities } from '@shared/contracts/sidecar'; +import type { SidecarCapabilities, InteractionMode } from '@shared/contracts/sidecar'; import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern'; import type { ProjectRecord } from '@shared/domain/project'; import type { QuerySessionsInput, SessionQueryResult } from '@shared/domain/sessionLibrary'; @@ -107,6 +107,15 @@ export interface UpdateSessionApprovalSettingsInput { autoApprovedToolNames?: string[]; } +export interface SetSessionInteractionModeInput { + sessionId: string; + mode: InteractionMode; +} + +export interface DismissSessionPlanReviewInput { + sessionId: string; +} + export interface ElectronApi { describeSidecarCapabilities(): Promise; refreshSidecarCapabilities(): Promise; @@ -134,6 +143,8 @@ export interface ElectronApi { cancelSessionTurn(input: CancelSessionTurnInput): Promise; resolveSessionApproval(input: ResolveSessionApprovalInput): Promise; resolveSessionUserInput(input: ResolveSessionUserInputInput): Promise; + setSessionInteractionMode(input: SetSessionInteractionModeInput): Promise; + dismissSessionPlanReview(input: DismissSessionPlanReviewInput): Promise; updateSessionModelConfig(input: UpdateSessionModelConfigInput): Promise; querySessions(input: QuerySessionsInput): Promise; selectProject(projectId?: string): Promise; diff --git a/src/shared/contracts/sidecar.ts b/src/shared/contracts/sidecar.ts index 2adeba2..ce9c645 100644 --- a/src/shared/contracts/sidecar.ts +++ b/src/shared/contracts/sidecar.ts @@ -68,12 +68,15 @@ export interface ValidatePatternCommand { pattern: PatternDefinition; } +export type InteractionMode = 'interactive' | 'plan'; + export interface RunTurnCommand { type: 'run-turn'; requestId: string; sessionId: string; projectPath: string; workspaceKind?: 'project' | 'scratchpad'; + mode?: InteractionMode; pattern: PatternDefinition; messages: ChatMessageRecord[]; tooling?: RunTurnToolingConfig; @@ -241,6 +244,19 @@ export interface UserInputRequestedEvent { allowFreeform?: boolean; } +export interface ExitPlanModeRequestedEvent { + type: 'exit-plan-mode-requested'; + requestId: string; + sessionId: string; + exitPlanId: string; + agentId?: string; + agentName?: string; + summary: string; + planContent: string; + actions?: string[]; + recommendedAction?: string; +} + export interface CommandErrorEvent { type: 'command-error'; requestId: string; @@ -260,5 +276,6 @@ export type SidecarEvent = | AgentActivityEvent | ApprovalRequestedEvent | UserInputRequestedEvent + | ExitPlanModeRequestedEvent | CommandErrorEvent | CommandCompleteEvent; diff --git a/src/shared/domain/planReview.ts b/src/shared/domain/planReview.ts new file mode 100644 index 0000000..6c75e78 --- /dev/null +++ b/src/shared/domain/planReview.ts @@ -0,0 +1,14 @@ +export type PlanReviewStatus = 'pending' | 'acted'; + +export interface PendingPlanReviewRecord { + id: string; + status: PlanReviewStatus; + agentId?: string; + agentName?: string; + summary: string; + planContent: string; + actions?: string[]; + recommendedAction?: string; + requestedAt: string; + actedAt?: string; +} diff --git a/src/shared/domain/session.ts b/src/shared/domain/session.ts index f54dcc7..7741f40 100644 --- a/src/shared/domain/session.ts +++ b/src/shared/domain/session.ts @@ -12,6 +12,8 @@ import { } from '@shared/domain/approval'; import type { SessionRunRecord } from '@shared/domain/runTimeline'; import type { PendingUserInputRecord } from '@shared/domain/userInput'; +import type { PendingPlanReviewRecord } from '@shared/domain/planReview'; +import type { InteractionMode } from '@shared/contracts/sidecar'; export type ChatRole = 'system' | 'user' | 'assistant'; export type SessionStatus = 'idle' | 'running' | 'error'; @@ -42,6 +44,7 @@ export interface SessionRecord { status: SessionStatus; isPinned?: boolean; isArchived?: boolean; + interactionMode?: InteractionMode; messages: ChatMessageRecord[]; lastError?: string; sessionModelConfig?: SessionModelConfig; @@ -50,6 +53,7 @@ export interface SessionRecord { pendingApproval?: PendingApprovalRecord; pendingApprovalQueue?: PendingApprovalRecord[]; pendingUserInput?: PendingUserInputRecord; + pendingPlanReview?: PendingPlanReviewRecord; runs: SessionRunRecord[]; } diff --git a/tests/main/runTurnPending.test.ts b/tests/main/runTurnPending.test.ts index f472da2..93a4f3a 100644 --- a/tests/main/runTurnPending.test.ts +++ b/tests/main/runTurnPending.test.ts @@ -17,6 +17,7 @@ describe('run turn pending helpers', () => { onActivity: () => undefined, onApproval: () => undefined, onUserInput: () => undefined, + onExitPlanMode: () => undefined, errored: false, }; @@ -40,6 +41,7 @@ describe('run turn pending helpers', () => { onActivity: () => undefined, onApproval: () => undefined, onUserInput: () => undefined, + onExitPlanMode: () => undefined, errored: false, };