From a1bde18cd187fa655eee9a740c8f6f9470b5dd41 Mon Sep 17 00:00:00 2001 From: jasonjaclyn2017 Date: Wed, 4 Mar 2026 21:00:02 -0600 Subject: [PATCH] feat: add View Plan modal and feedback textbox to plan approval UI (#164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add "View Plan" button with fullscreen modal to ExitPlanMode UI When Claude writes a plan in plan mode, the plan content is written to a file on disk but the ExitPlanMode UI only shows Approve/Reject buttons — the user never sees the actual plan without manually finding the file. Add a "View Plan" button that opens a fullscreen Dialog modal showing the plan content rendered as markdown. The plan content is extracted from the tool uses array (the Write tool's input for the plan file), so no new API endpoints are needed. Changes in StreamingMessage.tsx: - extractPlanContent(): walks toolUses backwards to find the last Write to a plans/*.md file and returns its content - ExitPlanModeUI: accepts toolUses prop, renders "View Plan" button (hidden if no plan content found), opens Radix Dialog modal with MessageResponse markdown renderer - Modal is 80vh tall, max-w-4xl, scrollable, with Close button Co-Authored-By: Claude Opus 4.6 * feat: add "Do this instead" feedback textbox to plan approval UI When rejecting a plan, users can now type feedback and click "Do this instead" (or press Enter) to send a custom deny message to the SDK. This matches the Claude Code CLI behavior where users can provide alternative instructions when rejecting a plan. Previously, the deny message was hardcoded to "User denied permission" — the API already supported custom messages but the client never sent them. Changes: - StreamingMessage.tsx: add feedback textbox + "Do this instead" button to ExitPlanModeUI, wire onDenyWithMessage callback - ChatView.tsx: pass denyMessage through handlePermissionResponse - MessageList.tsx: update onPermissionResponse type signature - stream-session-manager.ts: add denyMessage param to respondToPermission(), use it in the deny decision body Co-Authored-By: Claude Opus 4.6 * fix: fetch plan content from disk so View Plan always shows latest version Previously extractPlanContent read the Write tool's input.content, which showed stale content when Claude later edited the plan via the Edit tool. Now extractPlanFilePath finds the plan file path (matching both Write and Edit tools, with Windows backslash support), and the View Plan button fetches the file from disk on each click via /api/files/preview. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- src/components/chat/ChatView.tsx | 4 +- src/components/chat/MessageList.tsx | 2 +- src/components/chat/StreamingMessage.tsx | 96 +++++++++++++++++++++++- src/lib/stream-session-manager.ts | 3 +- 4 files changed, 100 insertions(+), 5 deletions(-) diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index e12c78b..8357255 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -210,9 +210,9 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal // Permission response — delegates to manager const handlePermissionResponse = useCallback( - async (decision: 'allow' | 'allow_session' | 'deny', updatedInput?: Record) => { + async (decision: 'allow' | 'allow_session' | 'deny', updatedInput?: Record, denyMessage?: string) => { setPendingApprovalSessionId(''); - await respondToPermission(sessionId, decision, updatedInput); + await respondToPermission(sessionId, decision, updatedInput, denyMessage); }, [sessionId, setPendingApprovalSessionId] ); diff --git a/src/components/chat/MessageList.tsx b/src/components/chat/MessageList.tsx index 202496e..50d62b5 100644 --- a/src/components/chat/MessageList.tsx +++ b/src/components/chat/MessageList.tsx @@ -62,7 +62,7 @@ interface MessageListProps { streamingToolOutput?: string; statusText?: string; pendingPermission?: PermissionRequestEvent | null; - onPermissionResponse?: (decision: 'allow' | 'allow_session' | 'deny') => void; + onPermissionResponse?: (decision: 'allow' | 'allow_session' | 'deny', updatedInput?: Record, denyMessage?: string) => void; permissionResolved?: 'allow' | 'deny' | null; onForceStop?: () => void; hasMore?: boolean; diff --git a/src/components/chat/StreamingMessage.tsx b/src/components/chat/StreamingMessage.tsx index 8f2965e..83c574f 100644 --- a/src/components/chat/StreamingMessage.tsx +++ b/src/components/chat/StreamingMessage.tsx @@ -18,6 +18,13 @@ import { ConfirmationAction, } from '@/components/ai-elements/confirmation'; import { Shimmer } from '@/components/ai-elements/shimmer'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; import { ImageGenConfirmation } from './ImageGenConfirmation'; import { BatchPlanInlinePreview } from './batch-image-gen/BatchPlanInlinePreview'; import { PENDING_KEY, buildReferenceImages } from '@/lib/image-ref-store'; @@ -114,7 +121,7 @@ interface StreamingMessageProps { streamingToolOutput?: string; statusText?: string; pendingPermission?: PermissionRequestEvent | null; - onPermissionResponse?: (decision: 'allow' | 'allow_session' | 'deny', updatedInput?: Record) => void; + onPermissionResponse?: (decision: 'allow' | 'allow_session' | 'deny', updatedInput?: Record, denyMessage?: string) => void; permissionResolved?: 'allow' | 'deny' | null; onForceStop?: () => void; } @@ -269,15 +276,39 @@ function AskUserQuestionUI({ ); } +function extractPlanFilePath(toolUses: ToolUseInfo[]): string | null { + // Walk backwards to find the most recent Write or Edit targeting a plans/*.md file + for (let i = toolUses.length - 1; i >= 0; i--) { + const tool = toolUses[i]; + const input = tool.input as Record; + if ((tool.name === 'Write' || tool.name === 'Edit') && typeof input.file_path === 'string') { + const fp = input.file_path; + if (fp.endsWith('.md') && (fp.includes('plans/') || fp.includes('plans\\'))) { + return fp; + } + } + } + return null; +} + function ExitPlanModeUI({ toolInput, + toolUses, onApprove, onDeny, + onDenyWithMessage, }: { toolInput: Record; + toolUses: ToolUseInfo[]; onApprove: () => void; onDeny: () => void; + onDenyWithMessage: (message: string) => void; }) { + const [planOpen, setPlanOpen] = useState(false); + const [planContent, setPlanContent] = useState(null); + const [planLoading, setPlanLoading] = useState(false); + const [feedback, setFeedback] = useState(''); + const planFilePath = extractPlanFilePath(toolUses); const allowedPrompts = (toolInput.allowedPrompts || []) as Array<{ tool: string; prompt: string; @@ -309,6 +340,30 @@ function ExitPlanModeUI({ > Reject + {planFilePath && ( + + )} + + + {planOpen && planContent && ( + + + + Plan + +
+ {planContent} +
+ +
+
+ )} ); } @@ -443,8 +535,10 @@ export function StreamingMessage({ {pendingPermission?.toolName === 'ExitPlanMode' && !permissionResolved && ( } + toolUses={toolUses} onApprove={() => onPermissionResponse?.('allow')} onDeny={() => onPermissionResponse?.('deny')} + onDenyWithMessage={(msg) => onPermissionResponse?.('deny', undefined, msg)} /> )} {pendingPermission?.toolName === 'ExitPlanMode' && permissionResolved === 'allow' && ( diff --git a/src/lib/stream-session-manager.ts b/src/lib/stream-session-manager.ts index 36bbd5a..e0548aa 100644 --- a/src/lib/stream-session-manager.ts +++ b/src/lib/stream-session-manager.ts @@ -568,6 +568,7 @@ export async function respondToPermission( sessionId: string, decision: 'allow' | 'allow_session' | 'deny', updatedInput?: Record, + denyMessage?: string, ): Promise { const stream = getStreamsMap().get(sessionId); if (!stream || !stream.snapshot.pendingPermission) return; @@ -577,7 +578,7 @@ export async function respondToPermission( const body = { permissionRequestId: perm.permissionRequestId, decision: decision === 'deny' - ? { behavior: 'deny' as const, message: 'User denied permission' } + ? { behavior: 'deny' as const, message: denyMessage || 'User denied permission' } : { behavior: 'allow' as const, ...(decision === 'allow_session' && perm.suggestions