mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-08 12:48:48 +02:00
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:
@@ -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,
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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,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)) {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user