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 { import type {
AgentActivityEvent, AgentActivityEvent,
ApprovalRequestedEvent, ApprovalRequestedEvent,
ExitPlanModeRequestedEvent,
RunTurnToolingConfig, RunTurnToolingConfig,
SidecarCapabilities, SidecarCapabilities,
TurnDeltaEvent, TurnDeltaEvent,
@@ -584,6 +585,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
session.title = resolveSessionTitle(session, effectivePattern, session.messages); session.title = resolveSessionTitle(session, effectivePattern, session.messages);
session.status = 'running'; session.status = 'running';
session.lastError = undefined; session.lastError = undefined;
session.pendingPlanReview = undefined;
session.updatedAt = occurredAt; session.updatedAt = occurredAt;
session.runs = [ session.runs = [
createSessionRunRecord({ createSessionRunRecord({
@@ -613,6 +615,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
sessionId: session.id, sessionId: session.id,
projectPath: project.path, projectPath: project.path,
workspaceKind, workspaceKind,
mode: session.interactionMode ?? 'interactive',
pattern: effectivePattern, pattern: effectivePattern,
messages: session.messages, messages: session.messages,
tooling: this.buildRunTurnToolingConfig(workspace, session), 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) => await this.handleUserInputRequested(workspace, session.id, requestId, event, (answer, wasFreeform) =>
this.sidecar.resolveUserInput(event.userInputId, 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); await this.awaitFinalResponseApproval(workspace, session.id, requestId, effectivePattern, responseMessages);
@@ -845,6 +851,29 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
return this.persistAndBroadcast(workspace); 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( async updateSessionTooling(
sessionId: string, sessionId: string,
enabledMcpServerIds: string[], enabledMcpServerIds: string[],
@@ -1180,6 +1209,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
session.status = 'idle'; session.status = 'idle';
session.lastError = undefined; session.lastError = undefined;
session.pendingUserInput = undefined; session.pendingUserInput = undefined;
session.pendingPlanReview = undefined;
session.updatedAt = completedAt; session.updatedAt = completedAt;
const completedRun = this.updateSessionRun(session, requestId, (run) => const completedRun = this.updateSessionRun(session, requestId, (run) =>
completeSessionRunRecord(run, completedAt)); completeSessionRunRecord(run, completedAt));
@@ -1211,6 +1241,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
session.status = 'idle'; session.status = 'idle';
session.lastError = undefined; session.lastError = undefined;
session.pendingUserInput = undefined; session.pendingUserInput = undefined;
session.pendingPlanReview = undefined;
session.updatedAt = cancelledAt; session.updatedAt = cancelledAt;
const cancelledRun = this.updateSessionRun(session, requestId, (run) => const cancelledRun = this.updateSessionRun(session, requestId, (run) =>
cancelSessionRunRecord(run, cancelledAt)); cancelSessionRunRecord(run, cancelledAt));
@@ -1285,6 +1316,30 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
await this.persistAndBroadcast(workspace); 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 { private createPendingApprovalFromSidecarEvent(event: ApprovalRequestedEvent): PendingApprovalRecord {
return { return {
id: event.approvalId, id: event.approvalId,
+8
View File
@@ -7,6 +7,7 @@ import type {
CreateSessionInput, CreateSessionInput,
ResolveProjectDiscoveredToolingInput, ResolveProjectDiscoveredToolingInput,
ResolveWorkspaceDiscoveredToolingInput, ResolveWorkspaceDiscoveredToolingInput,
DismissSessionPlanReviewInput,
DuplicateSessionInput, DuplicateSessionInput,
RenameSessionInput, RenameSessionInput,
RescanProjectConfigsInput, RescanProjectConfigsInput,
@@ -18,6 +19,7 @@ import type {
SendSessionMessageInput, SendSessionMessageInput,
SetPatternFavoriteInput, SetPatternFavoriteInput,
SetSessionArchivedInput, SetSessionArchivedInput,
SetSessionInteractionModeInput,
SetSessionPinnedInput, SetSessionPinnedInput,
UpdateSessionApprovalSettingsInput, UpdateSessionApprovalSettingsInput,
UpdateSessionToolingInput, UpdateSessionToolingInput,
@@ -114,6 +116,12 @@ export function registerIpcHandlers(window: BrowserWindow, service: AryxAppServi
ipcMain.handle(ipcChannels.resolveSessionUserInput, (_event, input: ResolveSessionUserInputInput) => ipcMain.handle(ipcChannels.resolveSessionUserInput, (_event, input: ResolveSessionUserInputInput) =>
service.resolveSessionUserInput(input.sessionId, input.userInputId, input.answer, input.wasFreeform), 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( ipcMain.handle(
ipcChannels.updateSessionModelConfig, ipcChannels.updateSessionModelConfig,
(_event, input: UpdateSessionModelConfigInput) => (_event, input: UpdateSessionModelConfigInput) =>
+2
View File
@@ -1,6 +1,7 @@
import type { import type {
AgentActivityEvent, AgentActivityEvent,
ApprovalRequestedEvent, ApprovalRequestedEvent,
ExitPlanModeRequestedEvent,
TurnDeltaEvent, TurnDeltaEvent,
UserInputRequestedEvent, UserInputRequestedEvent,
} from '@shared/contracts/sidecar'; } from '@shared/contracts/sidecar';
@@ -14,6 +15,7 @@ export interface RunTurnPendingCommand {
onActivity: (event: AgentActivityEvent) => void | Promise<void>; onActivity: (event: AgentActivityEvent) => void | Promise<void>;
onApproval: (event: ApprovalRequestedEvent) => void | Promise<void>; onApproval: (event: ApprovalRequestedEvent) => void | Promise<void>;
onUserInput: (event: UserInputRequestedEvent) => void | Promise<void>; onUserInput: (event: UserInputRequestedEvent) => void | Promise<void>;
onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise<void>;
errored: boolean; errored: boolean;
} }
+10 -1
View File
@@ -10,6 +10,7 @@ import type {
SidecarEvent, SidecarEvent,
TurnDeltaEvent, TurnDeltaEvent,
UserInputRequestedEvent, UserInputRequestedEvent,
ExitPlanModeRequestedEvent,
ValidatePatternCommand, ValidatePatternCommand,
RunTurnCommand, RunTurnCommand,
} from '@shared/contracts/sidecar'; } from '@shared/contracts/sidecar';
@@ -102,8 +103,9 @@ export class SidecarClient {
onActivity: (event: AgentActivityEvent) => void | Promise<void>, onActivity: (event: AgentActivityEvent) => void | Promise<void>,
onApproval: (event: ApprovalRequestedEvent) => void | Promise<void>, onApproval: (event: ApprovalRequestedEvent) => void | Promise<void>,
onUserInput: (event: UserInputRequestedEvent) => void | Promise<void>, onUserInput: (event: UserInputRequestedEvent) => void | Promise<void>,
onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise<void>,
): Promise<ChatMessageRecord[]> { ): Promise<ChatMessageRecord[]> {
return this.dispatch<ChatMessageRecord[]>(command, onDelta, onActivity, onApproval, onUserInput); return this.dispatch<ChatMessageRecord[]>(command, onDelta, onActivity, onApproval, onUserInput, onExitPlanMode);
} }
async resolveUserInput(userInputId: string, answer: string, wasFreeform: boolean): Promise<void> { async resolveUserInput(userInputId: string, answer: string, wasFreeform: boolean): Promise<void> {
@@ -218,6 +220,7 @@ export class SidecarClient {
onActivity?: (event: AgentActivityEvent) => void | Promise<void>, onActivity?: (event: AgentActivityEvent) => void | Promise<void>,
onApproval?: (event: ApprovalRequestedEvent) => void | Promise<void>, onApproval?: (event: ApprovalRequestedEvent) => void | Promise<void>,
onUserInput?: (event: UserInputRequestedEvent) => void | Promise<void>, onUserInput?: (event: UserInputRequestedEvent) => void | Promise<void>,
onExitPlanMode?: (event: ExitPlanModeRequestedEvent) => void | Promise<void>,
): Promise<TResult> { ): Promise<TResult> {
const state = await this.ensureProcess(); const state = await this.ensureProcess();
@@ -232,6 +235,7 @@ export class SidecarClient {
onActivity: onActivity ?? (() => undefined), onActivity: onActivity ?? (() => undefined),
onApproval: onApproval ?? (() => undefined), onApproval: onApproval ?? (() => undefined),
onUserInput: onUserInput ?? (() => undefined), onUserInput: onUserInput ?? (() => undefined),
onExitPlanMode: onExitPlanMode ?? (() => undefined),
errored: false, errored: false,
}); });
} else if (command.type === 'validate-pattern') { } else if (command.type === 'validate-pattern') {
@@ -329,6 +333,11 @@ export class SidecarClient {
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onUserInput(event)); this.invokeRunTurnHandler(event.requestId, pending, () => pending.onUserInput(event));
} }
return; 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': case 'turn-complete':
if (pending.kind === 'run-turn') { if (pending.kind === 'run-turn') {
if (shouldHandleRunTurnEvent(pending)) { if (shouldHandleRunTurnEvent(pending)) {
+2
View File
@@ -37,6 +37,8 @@ const api: ElectronApi = {
cancelSessionTurn: (input) => ipcRenderer.invoke(ipcChannels.cancelSessionTurn, input), cancelSessionTurn: (input) => ipcRenderer.invoke(ipcChannels.cancelSessionTurn, input),
resolveSessionApproval: (input) => ipcRenderer.invoke(ipcChannels.resolveSessionApproval, input), resolveSessionApproval: (input) => ipcRenderer.invoke(ipcChannels.resolveSessionApproval, input),
resolveSessionUserInput: (input) => ipcRenderer.invoke(ipcChannels.resolveSessionUserInput, input), resolveSessionUserInput: (input) => ipcRenderer.invoke(ipcChannels.resolveSessionUserInput, input),
setSessionInteractionMode: (input) => ipcRenderer.invoke(ipcChannels.setSessionInteractionMode, input),
dismissSessionPlanReview: (input) => ipcRenderer.invoke(ipcChannels.dismissSessionPlanReview, input),
updateSessionModelConfig: (input) => updateSessionModelConfig: (input) =>
ipcRenderer.invoke(ipcChannels.updateSessionModelConfig, input), ipcRenderer.invoke(ipcChannels.updateSessionModelConfig, input),
querySessions: (input) => ipcRenderer.invoke(ipcChannels.querySessions, input), querySessions: (input) => ipcRenderer.invoke(ipcChannels.querySessions, input),
+6
View File
@@ -253,6 +253,12 @@ export default function App() {
onResolveUserInput={(userInputId, answer, wasFreeform) => onResolveUserInput={(userInputId, answer, wasFreeform) =>
api.resolveSessionUserInput({ sessionId: selectedSession.id, 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) => onUpdateSessionModelConfig={(config) =>
api.updateSessionModelConfig({ api.updateSessionModelConfig({
sessionId: selectedSession.id, sessionId: selectedSession.id,
+56 -6
View File
@@ -1,14 +1,16 @@
import { useEffect, useMemo, useRef, useState } from 'react'; 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 { MarkdownContent } from '@renderer/components/MarkdownContent';
import { MarkdownComposer, type MarkdownComposerHandle } from '@renderer/components/MarkdownComposer'; import { MarkdownComposer, type MarkdownComposerHandle } from '@renderer/components/MarkdownComposer';
import { ApprovalBanner, QueuedApprovalsList } from '@renderer/components/chat/ApprovalBanner'; import { ApprovalBanner, QueuedApprovalsList } from '@renderer/components/chat/ApprovalBanner';
import { PlanReviewBanner } from '@renderer/components/chat/PlanReviewBanner';
import { UserInputBanner } from '@renderer/components/chat/UserInputBanner'; import { UserInputBanner } from '@renderer/components/chat/UserInputBanner';
import { InlineApprovalPill, InlineModelPill, InlineThinkingPill, InlineToolsPill } from '@renderer/components/chat/InlinePills'; import { InlineApprovalPill, InlineModelPill, InlineThinkingPill, InlineToolsPill } from '@renderer/components/chat/InlinePills';
import { ThinkingDots } from '@renderer/components/chat/ThinkingDots'; import { ThinkingDots } from '@renderer/components/chat/ThinkingDots';
import { getAssistantMessagePhase } from '@renderer/lib/messagePhase'; import { getAssistantMessagePhase } from '@renderer/lib/messagePhase';
import type { ApprovalDecision } from '@shared/domain/approval'; import type { ApprovalDecision } from '@shared/domain/approval';
import type { InteractionMode } from '@shared/contracts/sidecar';
import { import {
findModel, findModel,
getSupportedReasoningEfforts, getSupportedReasoningEfforts,
@@ -38,6 +40,8 @@ interface ChatPaneProps {
onCancelTurn?: () => void; onCancelTurn?: () => void;
onResolveApproval?: (approvalId: string, decision: ApprovalDecision) => Promise<unknown>; onResolveApproval?: (approvalId: string, decision: ApprovalDecision) => Promise<unknown>;
onResolveUserInput?: (userInputId: string, answer: string, wasFreeform: boolean) => Promise<unknown>; onResolveUserInput?: (userInputId: string, answer: string, wasFreeform: boolean) => Promise<unknown>;
onSetInteractionMode?: (mode: InteractionMode) => void;
onDismissPlanReview?: () => void;
onUpdateSessionModelConfig?: (config: { onUpdateSessionModelConfig?: (config: {
model: string; model: string;
reasoningEffort?: ReasoningEffort; reasoningEffort?: ReasoningEffort;
@@ -57,6 +61,8 @@ export function ChatPane({
onCancelTurn, onCancelTurn,
onResolveApproval, onResolveApproval,
onResolveUserInput, onResolveUserInput,
onSetInteractionMode,
onDismissPlanReview,
onUpdateSessionModelConfig, onUpdateSessionModelConfig,
onUpdateSessionTooling, onUpdateSessionTooling,
onUpdateSessionApprovalSettings, onUpdateSessionApprovalSettings,
@@ -75,6 +81,9 @@ export function ChatPane({
const queuedApprovals = (session.pendingApprovalQueue ?? []).filter((a) => a.status === 'pending'); const queuedApprovals = (session.pendingApprovalQueue ?? []).filter((a) => a.status === 'pending');
const totalPendingCount = (pendingApproval ? 1 : 0) + queuedApprovals.length; const totalPendingCount = (pendingApproval ? 1 : 0) + queuedApprovals.length;
const pendingUserInput = session.pendingUserInput?.status === 'pending' ? session.pendingUserInput : undefined; 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 isScratchpad = isScratchpadProject(project);
const isSingleAgent = pattern.agents.length === 1; const isSingleAgent = pattern.agents.length === 1;
const primaryAgent = pattern.agents[0]; const primaryAgent = pattern.agents[0];
@@ -121,6 +130,16 @@ export function ChatPane({
void onSend(content); void onSend(content);
} }
function handleImplementPlan() {
if (!pendingPlanReview) return;
onDismissPlanReview?.();
void onSend('Implement the plan.');
}
function handleDismissPlan() {
onDismissPlanReview?.();
}
async function handleSessionModelConfigChange(config: { async function handleSessionModelConfigChange(config: {
model: string; model: string;
reasoningEffort?: ReasoningEffort; reasoningEffort?: ReasoningEffort;
@@ -375,6 +394,17 @@ export function ChatPane({
</div> </div>
)} )}
{/* Plan review banner */}
{pendingPlanReview && (
<div className="mb-3">
<PlanReviewBanner
onDismiss={handleDismissPlan}
onImplement={handleImplementPlan}
planReview={pendingPlanReview}
/>
</div>
)}
{/* Session config pills — tools/approval left, model/reasoning right */} {/* Session config pills — tools/approval left, model/reasoning right */}
{isSingleAgent && ( {isSingleAgent && (
<div className="mb-2 flex items-center gap-2"> <div className="mb-2 flex items-center gap-2">
@@ -396,6 +426,22 @@ export function ChatPane({
onUpdate={onUpdateSessionApprovalSettings} onUpdate={onUpdateSessionApprovalSettings}
/> />
)} )}
{onSetInteractionMode && (
<button
aria-pressed={isPlanMode}
className={`inline-flex items-center gap-1 rounded-lg border px-2.5 py-1 text-[11px] font-medium transition ${
isPlanMode
? 'border-emerald-500/40 bg-emerald-500/15 text-emerald-300 hover:bg-emerald-500/25'
: 'border-zinc-700 text-zinc-400 hover:border-zinc-600 hover:text-zinc-300'
}`}
disabled={isComposerDisabled}
onClick={() => onSetInteractionMode(isPlanMode ? 'interactive' : 'plan')}
type="button"
>
<ClipboardList className="size-3" />
Plan
</button>
)}
{primaryAgent && ( {primaryAgent && (
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
<InlineModelPill <InlineModelPill
@@ -464,11 +510,15 @@ export function ChatPane({
? 'Awaiting approval...' ? 'Awaiting approval...'
: pendingUserInput : pendingUserInput
? 'Awaiting your input above...' ? 'Awaiting your input above...'
: isSessionBusy : pendingPlanReview
? 'Waiting for response...' ? 'Review the plan above...'
: isUpdatingSessionModelConfig : isSessionBusy
? 'Saving model settings...' ? 'Waiting for response...'
: 'Message...' : isUpdatingSessionModelConfig
? 'Saving model settings...'
: isPlanMode
? 'Describe what to plan...'
: 'Message...'
} }
> >
<button <button
@@ -0,0 +1,89 @@
import { useCallback } from 'react';
import { ClipboardList, Play, X } from 'lucide-react';
import { MarkdownContent } from '@renderer/components/MarkdownContent';
import type { PendingPlanReviewRecord } from '@shared/domain/planReview';
export function PlanReviewBanner({
planReview,
onImplement,
onDismiss,
}: {
planReview: PendingPlanReviewRecord;
onImplement: (planReview: PendingPlanReviewRecord) => void;
onDismiss: (planReview: PendingPlanReviewRecord) => void;
}) {
const handleImplement = useCallback(() => {
onImplement(planReview);
}, [planReview, onImplement]);
const handleDismiss = useCallback(() => {
onDismiss(planReview);
}, [planReview, onDismiss]);
return (
<div className="rounded-xl border border-emerald-500/30 bg-emerald-500/5 px-4 py-3" role="alert">
{/* Header */}
<div className="flex items-start gap-2.5">
<ClipboardList className="mt-0.5 size-4 shrink-0 text-emerald-400" />
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="text-[13px] font-semibold text-emerald-200">Plan ready for review</span>
<span className="rounded-full bg-emerald-500/15 px-2 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-emerald-400">
Plan mode
</span>
</div>
<button
aria-label="Dismiss plan"
className="rounded p-0.5 text-zinc-500 transition hover:bg-zinc-700/50 hover:text-zinc-300"
onClick={handleDismiss}
type="button"
>
<X className="size-3.5" />
</button>
</div>
{planReview.agentName && (
<div className="mt-1 text-[11px] text-zinc-400">
Agent: <span className="text-zinc-300">{planReview.agentName}</span>
</div>
)}
{/* Summary */}
{planReview.summary && (
<p className="mt-2 text-[13px] leading-relaxed text-zinc-200">
{planReview.summary}
</p>
)}
{/* Plan content (rendered markdown) */}
{planReview.planContent && (
<div className="mt-3 max-h-80 overflow-y-auto rounded-lg border border-zinc-700/50 bg-zinc-900/60 p-3">
<MarkdownContent content={planReview.planContent} />
</div>
)}
</div>
</div>
{/* Actions */}
<div className="mt-3 flex items-center gap-2">
<button
className="inline-flex items-center gap-1.5 rounded-lg bg-emerald-600 px-3.5 py-1.5 text-[12px] font-medium text-white transition hover:bg-emerald-500"
onClick={handleImplement}
type="button"
>
<Play className="size-3" />
Implement this plan
</button>
<button
className="rounded-lg border border-zinc-600 px-3.5 py-1.5 text-[12px] font-medium text-zinc-300 transition hover:border-zinc-500 hover:bg-zinc-800 hover:text-white"
onClick={handleDismiss}
type="button"
>
Dismiss
</button>
</div>
</div>
);
}
+2
View File
@@ -27,6 +27,8 @@ export const ipcChannels = {
cancelSessionTurn: 'sessions:cancel-turn', cancelSessionTurn: 'sessions:cancel-turn',
resolveSessionApproval: 'sessions:resolve-approval', resolveSessionApproval: 'sessions:resolve-approval',
resolveSessionUserInput: 'sessions:resolve-user-input', resolveSessionUserInput: 'sessions:resolve-user-input',
setSessionInteractionMode: 'sessions:set-interaction-mode',
dismissSessionPlanReview: 'sessions:dismiss-plan-review',
querySessions: 'sessions:query', querySessions: 'sessions:query',
updateSessionModelConfig: 'sessions:update-model-config', updateSessionModelConfig: 'sessions:update-model-config',
selectProject: 'selection:project', selectProject: 'selection:project',
+12 -1
View File
@@ -1,5 +1,5 @@
import type { ApprovalDecision } from '@shared/domain/approval'; 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 { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
import type { ProjectRecord } from '@shared/domain/project'; import type { ProjectRecord } from '@shared/domain/project';
import type { QuerySessionsInput, SessionQueryResult } from '@shared/domain/sessionLibrary'; import type { QuerySessionsInput, SessionQueryResult } from '@shared/domain/sessionLibrary';
@@ -107,6 +107,15 @@ export interface UpdateSessionApprovalSettingsInput {
autoApprovedToolNames?: string[]; autoApprovedToolNames?: string[];
} }
export interface SetSessionInteractionModeInput {
sessionId: string;
mode: InteractionMode;
}
export interface DismissSessionPlanReviewInput {
sessionId: string;
}
export interface ElectronApi { export interface ElectronApi {
describeSidecarCapabilities(): Promise<SidecarCapabilities>; describeSidecarCapabilities(): Promise<SidecarCapabilities>;
refreshSidecarCapabilities(): Promise<SidecarCapabilities>; refreshSidecarCapabilities(): Promise<SidecarCapabilities>;
@@ -134,6 +143,8 @@ export interface ElectronApi {
cancelSessionTurn(input: CancelSessionTurnInput): Promise<void>; cancelSessionTurn(input: CancelSessionTurnInput): Promise<void>;
resolveSessionApproval(input: ResolveSessionApprovalInput): Promise<WorkspaceState>; resolveSessionApproval(input: ResolveSessionApprovalInput): Promise<WorkspaceState>;
resolveSessionUserInput(input: ResolveSessionUserInputInput): Promise<WorkspaceState>; resolveSessionUserInput(input: ResolveSessionUserInputInput): Promise<WorkspaceState>;
setSessionInteractionMode(input: SetSessionInteractionModeInput): Promise<WorkspaceState>;
dismissSessionPlanReview(input: DismissSessionPlanReviewInput): Promise<WorkspaceState>;
updateSessionModelConfig(input: UpdateSessionModelConfigInput): Promise<WorkspaceState>; updateSessionModelConfig(input: UpdateSessionModelConfigInput): Promise<WorkspaceState>;
querySessions(input: QuerySessionsInput): Promise<SessionQueryResult[]>; querySessions(input: QuerySessionsInput): Promise<SessionQueryResult[]>;
selectProject(projectId?: string): Promise<WorkspaceState>; selectProject(projectId?: string): Promise<WorkspaceState>;
+17
View File
@@ -68,12 +68,15 @@ export interface ValidatePatternCommand {
pattern: PatternDefinition; pattern: PatternDefinition;
} }
export type InteractionMode = 'interactive' | 'plan';
export interface RunTurnCommand { export interface RunTurnCommand {
type: 'run-turn'; type: 'run-turn';
requestId: string; requestId: string;
sessionId: string; sessionId: string;
projectPath: string; projectPath: string;
workspaceKind?: 'project' | 'scratchpad'; workspaceKind?: 'project' | 'scratchpad';
mode?: InteractionMode;
pattern: PatternDefinition; pattern: PatternDefinition;
messages: ChatMessageRecord[]; messages: ChatMessageRecord[];
tooling?: RunTurnToolingConfig; tooling?: RunTurnToolingConfig;
@@ -241,6 +244,19 @@ export interface UserInputRequestedEvent {
allowFreeform?: boolean; 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 { export interface CommandErrorEvent {
type: 'command-error'; type: 'command-error';
requestId: string; requestId: string;
@@ -260,5 +276,6 @@ export type SidecarEvent =
| AgentActivityEvent | AgentActivityEvent
| ApprovalRequestedEvent | ApprovalRequestedEvent
| UserInputRequestedEvent | UserInputRequestedEvent
| ExitPlanModeRequestedEvent
| CommandErrorEvent | CommandErrorEvent
| CommandCompleteEvent; | CommandCompleteEvent;
+14
View File
@@ -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;
}
+4
View File
@@ -12,6 +12,8 @@ import {
} from '@shared/domain/approval'; } from '@shared/domain/approval';
import type { SessionRunRecord } from '@shared/domain/runTimeline'; import type { SessionRunRecord } from '@shared/domain/runTimeline';
import type { PendingUserInputRecord } from '@shared/domain/userInput'; 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 ChatRole = 'system' | 'user' | 'assistant';
export type SessionStatus = 'idle' | 'running' | 'error'; export type SessionStatus = 'idle' | 'running' | 'error';
@@ -42,6 +44,7 @@ export interface SessionRecord {
status: SessionStatus; status: SessionStatus;
isPinned?: boolean; isPinned?: boolean;
isArchived?: boolean; isArchived?: boolean;
interactionMode?: InteractionMode;
messages: ChatMessageRecord[]; messages: ChatMessageRecord[];
lastError?: string; lastError?: string;
sessionModelConfig?: SessionModelConfig; sessionModelConfig?: SessionModelConfig;
@@ -50,6 +53,7 @@ export interface SessionRecord {
pendingApproval?: PendingApprovalRecord; pendingApproval?: PendingApprovalRecord;
pendingApprovalQueue?: PendingApprovalRecord[]; pendingApprovalQueue?: PendingApprovalRecord[];
pendingUserInput?: PendingUserInputRecord; pendingUserInput?: PendingUserInputRecord;
pendingPlanReview?: PendingPlanReviewRecord;
runs: SessionRunRecord[]; runs: SessionRunRecord[];
} }
+2
View File
@@ -17,6 +17,7 @@ describe('run turn pending helpers', () => {
onActivity: () => undefined, onActivity: () => undefined,
onApproval: () => undefined, onApproval: () => undefined,
onUserInput: () => undefined, onUserInput: () => undefined,
onExitPlanMode: () => undefined,
errored: false, errored: false,
}; };
@@ -40,6 +41,7 @@ describe('run turn pending helpers', () => {
onActivity: () => undefined, onActivity: () => undefined,
onApproval: () => undefined, onApproval: () => undefined,
onUserInput: () => undefined, onUserInput: () => undefined,
onExitPlanMode: () => undefined,
errored: false, errored: false,
}; };