Skip to content
This repository was archived by the owner on May 29, 2026. It is now read-only.

Commit 9e16835

Browse files
sugyanclaude
andauthored
feat: complete ExitPlanMode implementation with UI integration (#230)
* fix: simplify ExitPlanMode permission error detection Simplified permission error detection to fix ExitPlanMode not showing PlanPermissionInputPanel: - Remove isPermissionError function and string-based permission checking - Use contentItem.is_error directly for all permission error detection - Add ExitPlanMode special display as "Ready to code?" with plan content - Add PlanPermissionInputPanel for ExitPlanMode permission handling - Remove forced plan mode for first messages (debug cleanup) This ensures ExitPlanMode properly displays plan content then shows permission dialog on error. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: improve PlanPermissionInputPanel UX and fix tool error handling UI/UX improvements: - Remove redundant "Ready to code?" header (already shown in message) - Reorder options to prioritize "auto-accept edits" as first choice - Update default selection to "acceptWithEdits" - Change "Keep planning" to only close dialog (consistent with permission deny) - Use "accept" instead of "continue" for plan approval messages Tool error handling: - Add isToolUseError detection for <tool_use_error> content - Skip permission dialog for tool use errors, display as regular results - Allows Claude to see and retry failed tool usage attempts 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: unify ESC key handling across permission dialogs Consistent keyboard navigation: - Add ESC key support to PermissionInputPanel (calls onDeny) - Add ESC key support to PlanPermissionInputPanel (calls onKeepPlanning) - Remove centralized ESC handling from ChatInput.tsx - Each panel now handles its own keyboard events independently UX improvements: - ESC key now works consistently across both permission dialog types - Self-contained components with unified keyboard interaction patterns - Remove forced plan mode debug setting for normal operation 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: complete planMode demo functionality with proper TypeScript types - Add ExitPlanMode special handling in useDemoAutomation to create plan messages - Add plan mode support to DemoPage with handlers and permission data - Add permission error detection for ExitPlanMode tool results - Simplify planMode scenario from complex dark theme to simple README creation - Remove unnecessary result message after plan creation - Fix repetitive messages and improve UX flow - Convert MockScenarioStep from interface to discriminated union type - Add plan permission button automation support - Ensure permission panel disappears correctly after selection 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: address Copilot review feedback for ExitPlanMode handling - Remove unnecessary ExitPlanMode detection logic from MessageComponents - ToolMessageComponent: ExitPlanMode creates plan messages, not tool messages - ToolResultMessageComponent: ExitPlanMode tool_result triggers abort, never reaches component - Clean up unused planContent prop from PlanPermissionInputPanel interface and all usage - Remove meaningless empty comment from useDemoAutomation These changes align with actual ExitPlanMode processing flow: 1. tool_use -> plan message creation (bypasses ToolMessageComponent) 2. tool_result with is_error -> permission error -> abort (bypasses ToolResultMessageComponent) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d4183db commit 9e16835

15 files changed

Lines changed: 850 additions & 72 deletions

frontend/src/App.test.tsx

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { render, screen, waitFor } from "@testing-library/react";
1+
import { render, screen, waitFor, act } from "@testing-library/react";
22
import { describe, it, expect, vi, beforeEach } from "vitest";
33
import { MemoryRouter, Routes, Route } from "react-router-dom";
44
import { ProjectSelector } from "./components/ProjectSelector";
@@ -32,19 +32,23 @@ describe("App Routing", () => {
3232
});
3333
});
3434

35-
it("renders chat page when navigating to projects path", () => {
36-
render(
37-
<EnterBehaviorProvider>
38-
<MemoryRouter initialEntries={["/projects/test-path"]}>
39-
<Routes>
40-
<Route path="/projects/*" element={<ChatPage />} />
41-
</Routes>
42-
</MemoryRouter>
43-
</EnterBehaviorProvider>,
44-
);
35+
it("renders chat page when navigating to projects path", async () => {
36+
await act(async () => {
37+
render(
38+
<EnterBehaviorProvider>
39+
<MemoryRouter initialEntries={["/projects/test-path"]}>
40+
<Routes>
41+
<Route path="/projects/*" element={<ChatPage />} />
42+
</Routes>
43+
</MemoryRouter>
44+
</EnterBehaviorProvider>,
45+
);
46+
});
4547

46-
expect(screen.getByText("Claude Code Web UI")).toBeInTheDocument();
47-
expect(screen.getByText("/test-path")).toBeInTheDocument();
48+
await waitFor(() => {
49+
expect(screen.getByText("Claude Code Web UI")).toBeInTheDocument();
50+
expect(screen.getByText("/test-path")).toBeInTheDocument();
51+
});
4852
});
4953

5054
it("shows new directory selection button", async () => {

frontend/src/components/ChatPage.tsx

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,20 +110,30 @@ export function ChatPage() {
110110
allowToolTemporary,
111111
allowToolPermanent,
112112
isPermissionMode,
113+
planModeRequest,
114+
showPlanModeRequest,
115+
closePlanModeRequest,
113116
} = usePermissions();
114117

115118
const handlePermissionError = useCallback(
116119
(toolName: string, patterns: string[], toolUseId: string) => {
117-
showPermissionRequest(toolName, patterns, toolUseId);
120+
// Check if this is an ExitPlanMode permission error
121+
if (patterns.includes("ExitPlanMode")) {
122+
// For ExitPlanMode, show plan permission interface instead of regular permission
123+
showPlanModeRequest(""); // Empty plan content since it was already displayed
124+
} else {
125+
showPermissionRequest(toolName, patterns, toolUseId);
126+
}
118127
},
119-
[showPermissionRequest],
128+
[showPermissionRequest, showPlanModeRequest],
120129
);
121130

122131
const sendMessage = useCallback(
123132
async (
124133
messageContent?: string,
125134
tools?: string[],
126135
hideUserMessage = false,
136+
permissionMode?: "default" | "plan" | "acceptEdits",
127137
) => {
128138
const content = messageContent || input.trim();
129139
if (!content || isLoading) return;
@@ -154,6 +164,7 @@ export function ChatPage() {
154164
...(currentSessionId ? { sessionId: currentSessionId } : {}),
155165
allowedTools: tools || allowedTools,
156166
...(workingDirectory ? { workingDirectory } : {}),
167+
...(permissionMode ? { permissionMode } : {}),
157168
} as ChatRequest),
158169
});
159170

@@ -293,6 +304,25 @@ export function ChatPage() {
293304
closePermissionRequest();
294305
}, [closePermissionRequest]);
295306

307+
// Plan mode request handlers
308+
const handlePlanAcceptWithEdits = useCallback(() => {
309+
closePlanModeRequest();
310+
if (currentSessionId) {
311+
sendMessage("accept", allowedTools, true, "acceptEdits");
312+
}
313+
}, [closePlanModeRequest, currentSessionId, sendMessage, allowedTools]);
314+
315+
const handlePlanAcceptDefault = useCallback(() => {
316+
closePlanModeRequest();
317+
if (currentSessionId) {
318+
sendMessage("accept", allowedTools, true, "default");
319+
}
320+
}, [closePlanModeRequest, currentSessionId, sendMessage, allowedTools]);
321+
322+
const handlePlanKeepPlanning = useCallback(() => {
323+
closePlanModeRequest();
324+
}, [closePlanModeRequest]);
325+
296326
// Create permission data for inline permission interface
297327
const permissionData = permissionRequest
298328
? {
@@ -303,6 +333,15 @@ export function ChatPage() {
303333
}
304334
: undefined;
305335

336+
// Create plan permission data for plan mode interface
337+
const planPermissionData = planModeRequest
338+
? {
339+
onAcceptWithEdits: handlePlanAcceptWithEdits,
340+
onAcceptDefault: handlePlanAcceptDefault,
341+
onKeepPlanning: handlePlanKeepPlanning,
342+
}
343+
: undefined;
344+
306345
const handleHistoryClick = useCallback(() => {
307346
const searchParams = new URLSearchParams();
308347
searchParams.set("view", "history");
@@ -502,6 +541,7 @@ export function ChatPage() {
502541
onAbort={handleAbort}
503542
showPermissions={isPermissionMode}
504543
permissionData={permissionData}
544+
planPermissionData={planPermissionData}
505545
/>
506546
</>
507547
)}

frontend/src/components/DemoPage.tsx

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,24 @@ export function DemoPage() {
117117
allowToolPermanent,
118118
showPermissionRequest,
119119
isPermissionMode,
120+
planModeRequest,
121+
showPlanModeRequest,
122+
closePlanModeRequest,
120123
} = usePermissions();
121124

125+
const handlePermissionError = useCallback(
126+
(toolName: string, patterns: string[], toolUseId: string) => {
127+
// Check if this is an ExitPlanMode permission error
128+
if (patterns.includes("ExitPlanMode")) {
129+
// For ExitPlanMode, show plan permission interface instead of regular permission
130+
showPlanModeRequest(""); // Empty plan content since it was already displayed
131+
} else {
132+
showPermissionRequest(toolName, patterns, toolUseId);
133+
}
134+
},
135+
[showPermissionRequest, showPlanModeRequest],
136+
);
137+
122138
// Permission request handlers (for demo)
123139
const handlePermissionAllow = useCallback(() => {
124140
if (!permissionRequest) return;
@@ -137,6 +153,19 @@ export function DemoPage() {
137153
closePermissionRequest();
138154
}, [closePermissionRequest]);
139155

156+
// Plan mode request handlers (for demo)
157+
const handlePlanAcceptWithEdits = useCallback(() => {
158+
closePlanModeRequest();
159+
}, [closePlanModeRequest]);
160+
161+
const handlePlanAcceptDefault = useCallback(() => {
162+
closePlanModeRequest();
163+
}, [closePlanModeRequest]);
164+
165+
const handlePlanKeepPlanning = useCallback(() => {
166+
closePlanModeRequest();
167+
}, [closePlanModeRequest]);
168+
140169
// Demo permission selection state (for external control)
141170
const [demoSelectedOption, setDemoSelectedOption] = useState<
142171
"allow" | "allowPermanent" | "deny" | null
@@ -156,6 +185,13 @@ export function DemoPage() {
156185
} else if (buttonType === "permission_deny") {
157186
setActiveButton("deny");
158187
setDemoSelectedOption("deny");
188+
} else if (buttonType === "plan_accept_with_edits") {
189+
// For plan permission focus, we can add visual feedback later if needed
190+
console.log("Plan button focused: Accept with Edits");
191+
} else if (buttonType === "plan_accept_default") {
192+
console.log("Plan button focused: Accept Default");
193+
} else if (buttonType === "plan_keep_planning") {
194+
console.log("Plan button focused: Keep Planning");
159195
}
160196
}, []);
161197

@@ -199,6 +235,15 @@ export function DemoPage() {
199235
}
200236
: undefined;
201237

238+
// Create plan permission data for plan mode interface
239+
const planPermissionData = planModeRequest
240+
? {
241+
onAcceptWithEdits: handlePlanAcceptWithEdits,
242+
onAcceptDefault: handlePlanAcceptDefault,
243+
onKeepPlanning: handlePlanKeepPlanning,
244+
}
245+
: undefined;
246+
202247
// Handle button clicks from demo automation
203248
const handleButtonClick = useCallback(
204249
(buttonType: string) => {
@@ -214,12 +259,21 @@ export function DemoPage() {
214259
} else if (buttonType === "permission_deny") {
215260
setClickedButton("deny");
216261
setTimeout(() => handlePermissionDeny(), 200);
262+
} else if (buttonType === "plan_accept_with_edits") {
263+
setTimeout(() => handlePlanAcceptWithEdits(), 200);
264+
} else if (buttonType === "plan_accept_default") {
265+
setTimeout(() => handlePlanAcceptDefault(), 200);
266+
} else if (buttonType === "plan_keep_planning") {
267+
setTimeout(() => handlePlanKeepPlanning(), 200);
217268
}
218269
},
219270
[
220271
handlePermissionAllow,
221272
handlePermissionAllowPermanent,
222273
handlePermissionDeny,
274+
handlePlanAcceptWithEdits,
275+
handlePlanAcceptDefault,
276+
handlePlanKeepPlanning,
223277
],
224278
);
225279

@@ -252,7 +306,7 @@ export function DemoPage() {
252306
startRequest,
253307
resetRequestState,
254308
generateRequestId,
255-
showPermissionRequest,
309+
showPermissionRequest: handlePermissionError,
256310
onButtonFocus: handleButtonFocus,
257311
onButtonClick: handleButtonClick,
258312
});
@@ -378,6 +432,7 @@ export function DemoPage() {
378432
onAbort={() => {}} // No-op in demo
379433
showPermissions={isPermissionMode}
380434
permissionData={permissionData}
435+
planPermissionData={planPermissionData}
381436
/>
382437
</div>
383438

frontend/src/components/MessageComponents.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
SystemMessage,
44
ToolMessage,
55
ToolResultMessage,
6+
PlanMessage,
67
} from "../types";
78
import { TimestampComponent } from "./TimestampComponent";
89
import { MessageContainer } from "./messages/MessageContainer";
@@ -146,6 +147,43 @@ export function ToolResultMessageComponent({
146147
);
147148
}
148149

150+
interface PlanMessageComponentProps {
151+
message: PlanMessage;
152+
}
153+
154+
export function PlanMessageComponent({ message }: PlanMessageComponentProps) {
155+
return (
156+
<MessageContainer
157+
alignment="left"
158+
colorScheme="bg-blue-50 dark:bg-blue-900/20 text-blue-900 dark:text-blue-100"
159+
>
160+
<div className="mb-3 flex items-center justify-between gap-4">
161+
<div className="text-xs font-semibold opacity-90 text-blue-700 dark:text-blue-300 flex items-center gap-2">
162+
<div className="w-4 h-4 bg-blue-500 dark:bg-blue-600 rounded-full flex items-center justify-center text-white text-xs">
163+
📋
164+
</div>
165+
Ready to code?
166+
</div>
167+
<TimestampComponent
168+
timestamp={message.timestamp}
169+
className="text-xs opacity-70 text-blue-600 dark:text-blue-400"
170+
/>
171+
</div>
172+
173+
<div className="mb-3">
174+
<p className="text-sm font-medium text-blue-800 dark:text-blue-200 mb-2">
175+
Here is Claude's plan:
176+
</p>
177+
<div className="bg-blue-100/50 dark:bg-blue-800/30 border border-blue-200 dark:border-blue-700 rounded-lg p-3">
178+
<pre className="text-sm text-blue-900 dark:text-blue-100 whitespace-pre-wrap font-mono leading-relaxed">
179+
{message.plan}
180+
</pre>
181+
</div>
182+
</div>
183+
</MessageContainer>
184+
);
185+
}
186+
149187
export function LoadingComponent() {
150188
return (
151189
<MessageContainer

frontend/src/components/chat/ChatInput.tsx

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { UI_CONSTANTS, KEYBOARD_SHORTCUTS } from "../../utils/constants";
44
import { useEnterBehavior } from "../../hooks/useEnterBehavior";
55
import { EnterModeMenu } from "./EnterModeMenu";
66
import { PermissionInputPanel } from "./PermissionInputPanel";
7+
import { PlanPermissionInputPanel } from "./PlanPermissionInputPanel";
78

89
interface PermissionData {
910
patterns: string[];
@@ -18,6 +19,24 @@ interface PermissionData {
1819
externalSelectedOption?: "allow" | "allowPermanent" | "deny" | null;
1920
}
2021

22+
interface PlanPermissionData {
23+
onAcceptWithEdits: () => void;
24+
onAcceptDefault: () => void;
25+
onKeepPlanning: () => void;
26+
getButtonClassName?: (
27+
buttonType: "acceptWithEdits" | "acceptDefault" | "keepPlanning",
28+
defaultClassName: string,
29+
) => string;
30+
onSelectionChange?: (
31+
selection: "acceptWithEdits" | "acceptDefault" | "keepPlanning",
32+
) => void;
33+
externalSelectedOption?:
34+
| "acceptWithEdits"
35+
| "acceptDefault"
36+
| "keepPlanning"
37+
| null;
38+
}
39+
2140
interface ChatInputProps {
2241
input: string;
2342
isLoading: boolean;
@@ -28,6 +47,7 @@ interface ChatInputProps {
2847
// Permission mode props
2948
showPermissions?: boolean;
3049
permissionData?: PermissionData;
50+
planPermissionData?: PlanPermissionData;
3151
}
3252

3353
export function ChatInput({
@@ -39,6 +59,7 @@ export function ChatInput({
3959
onAbort,
4060
showPermissions = false,
4161
permissionData,
62+
planPermissionData,
4263
}: ChatInputProps) {
4364
const inputRef = useRef<HTMLTextAreaElement>(null);
4465
const [isComposing, setIsComposing] = useState(false);
@@ -51,21 +72,6 @@ export function ChatInput({
5172
}
5273
}, [isLoading, showPermissions]);
5374

54-
// Handle ESC key for permission denial
55-
useEffect(() => {
56-
if (!showPermissions || !permissionData) return;
57-
58-
const handleEscKey = (e: KeyboardEvent) => {
59-
if (e.key === KEYBOARD_SHORTCUTS.ABORT) {
60-
e.preventDefault();
61-
permissionData.onDeny();
62-
}
63-
};
64-
65-
document.addEventListener("keydown", handleEscKey);
66-
return () => document.removeEventListener("keydown", handleEscKey);
67-
}, [showPermissions, permissionData]);
68-
6975
// Auto-resize textarea
7076
useEffect(() => {
7177
const textarea = inputRef.current;
@@ -125,7 +131,21 @@ export function ChatInput({
125131
setTimeout(() => setIsComposing(false), 0);
126132
};
127133

128-
// If we're in permission mode, show the permission panel instead
134+
// If we're in plan permission mode, show the plan permission panel instead
135+
if (showPermissions && planPermissionData) {
136+
return (
137+
<PlanPermissionInputPanel
138+
onAcceptWithEdits={planPermissionData.onAcceptWithEdits}
139+
onAcceptDefault={planPermissionData.onAcceptDefault}
140+
onKeepPlanning={planPermissionData.onKeepPlanning}
141+
getButtonClassName={planPermissionData.getButtonClassName}
142+
onSelectionChange={planPermissionData.onSelectionChange}
143+
externalSelectedOption={planPermissionData.externalSelectedOption}
144+
/>
145+
);
146+
}
147+
148+
// If we're in regular permission mode, show the permission panel instead
129149
if (showPermissions && permissionData) {
130150
return (
131151
<PermissionInputPanel

0 commit comments

Comments
 (0)