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
+56 -6
View File
@@ -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<unknown>;
onResolveUserInput?: (userInputId: string, answer: string, wasFreeform: boolean) => Promise<unknown>;
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({
</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 */}
{isSingleAgent && (
<div className="mb-2 flex items-center gap-2">
@@ -396,6 +426,22 @@ export function ChatPane({
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 && (
<div className="ml-auto flex items-center gap-2">
<InlineModelPill
@@ -464,11 +510,15 @@ export function ChatPane({
? 'Awaiting approval...'
: pendingUserInput
? 'Awaiting your input above...'
: isSessionBusy
? 'Waiting for response...'
: isUpdatingSessionModelConfig
? 'Saving model settings...'
: 'Message...'
: pendingPlanReview
? 'Review the plan above...'
: isSessionBusy
? 'Waiting for response...'
: isUpdatingSessionModelConfig
? 'Saving model settings...'
: isPlanMode
? 'Describe what to plan...'
: 'Message...'
}
>
<button