feat: add View Plan modal and feedback textbox to plan approval UI (#164)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jasonjaclyn2017andClaude Opus 4.6 authored and GitHub committed 2026-03-05 11:00:02 +08:00
1 parent a906172807
commit a1bde18cd1
4 files changed
+100 -5

No files matched your search

+2 -2
View File
@@ -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<string, unknown>) => {
async (decision: 'allow' | 'allow_session' | 'deny', updatedInput?: Record<string, unknown>, denyMessage?: string) => {
setPendingApprovalSessionId('');
await respondToPermission(sessionId, decision, updatedInput);
await respondToPermission(sessionId, decision, updatedInput, denyMessage);
},
[sessionId, setPendingApprovalSessionId]
);
+1 -1
View File
@@ -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<string, unknown>, denyMessage?: string) => void;
permissionResolved?: 'allow' | 'deny' | null;
onForceStop?: () => void;
hasMore?: boolean;
+95 -1
View File
@@ -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<string, unknown>) => void;
onPermissionResponse?: (decision: 'allow' | 'allow_session' | 'deny', updatedInput?: Record<string, unknown>, 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<string, unknown>;
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<string, unknown>;
toolUses: ToolUseInfo[];
onApprove: () => void;
onDeny: () => void;
onDenyWithMessage: (message: string) => void;
}) {
const [planOpen, setPlanOpen] = useState(false);
const [planContent, setPlanContent] = useState<string | null>(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
</button>
{planFilePath && (
<button
onClick={async () => {
setPlanLoading(true);
try {
const res = await fetch(`/api/files/preview?path=${encodeURIComponent(planFilePath)}&maxLines=1000`);
if (res.ok) {
const data = await res.json();
setPlanContent(data.preview?.content || 'Failed to load plan');
} else {
setPlanContent('Failed to load plan file');
}
} catch {
setPlanContent('Failed to load plan file');
}
setPlanLoading(false);
setPlanOpen(true);
}}
disabled={planLoading}
className="rounded-lg border border-primary/30 px-3 py-1.5 text-xs font-medium text-primary transition-colors hover:bg-primary/10 disabled:opacity-50"
>
{planLoading ? 'Loading...' : 'View Plan'}
</button>
)}
<button
onClick={onApprove}
className="rounded-lg bg-primary px-4 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
@@ -316,6 +371,43 @@ function ExitPlanModeUI({
Approve & Execute
</button>
</div>
<div className="flex gap-2">
<input
type="text"
placeholder="Provide feedback on the plan..."
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && feedback.trim()) {
onDenyWithMessage(feedback.trim());
}
}}
className="flex-1 rounded-lg border border-border bg-background px-3 py-1.5 text-xs focus:border-primary focus:outline-none"
/>
<button
onClick={() => {
if (feedback.trim()) onDenyWithMessage(feedback.trim());
}}
disabled={!feedback.trim()}
className="rounded-lg border border-border px-3 py-1.5 text-xs transition-colors hover:bg-muted disabled:opacity-40"
>
Do this instead
</button>
</div>
{planOpen && planContent && (
<Dialog open={planOpen} onOpenChange={setPlanOpen}>
<DialogContent className="max-w-4xl h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>Plan</DialogTitle>
</DialogHeader>
<div className="overflow-y-auto flex-1 min-h-0">
<MessageResponse>{planContent}</MessageResponse>
</div>
<DialogFooter showCloseButton />
</DialogContent>
</Dialog>
)}
</div>
);
}
@@ -443,8 +535,10 @@ export function StreamingMessage({
{pendingPermission?.toolName === 'ExitPlanMode' && !permissionResolved && (
<ExitPlanModeUI
toolInput={pendingPermission.toolInput as Record<string, unknown>}
toolUses={toolUses}
onApprove={() => onPermissionResponse?.('allow')}
onDeny={() => onPermissionResponse?.('deny')}
onDenyWithMessage={(msg) => onPermissionResponse?.('deny', undefined, msg)}
/>
)}
{pendingPermission?.toolName === 'ExitPlanMode' && permissionResolved === 'allow' && (
+2 -1
View File
@@ -568,6 +568,7 @@ export async function respondToPermission(
sessionId: string,
decision: 'allow' | 'allow_session' | 'deny',
updatedInput?: Record<string, unknown>,
denyMessage?: string,
): Promise<void> {
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