feat(web): add Plan Mode toggle and plan preview UI#1406
Conversation
Signed-off-by: Kai <me@kaiyi.cool>
There was a problem hiding this comment.
Pull request overview
This PR introduces a Web UI Plan Mode toggle backed by a new Wire 1.4 toggle_plan_mode JSON-RPC method, propagates plan-mode state via StatusUpdate events, and adds an inline plan preview in the question dialog for ExitPlanMode.
Changes:
- Add
plan_modetoStatusUpdateand bump Wire protocol version to 1.4 (types + docs + snapshot). - Implement
toggle_plan_modeJSON-RPC request on the backend and expose it via the Web session stream hook + UI toggle. - Render a collapsible Markdown “Plan Preview” in
QuestionDialogwhen the plan content is provided inQuestionItem.body.
Reviewed changes
Copilot reviewed 20 out of 20 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| web/src/hooks/wireTypes.ts | Extend Wire TS types: StatusUpdate.plan_mode, QuestionItem.body/other_*. |
| web/src/hooks/useSessionStream.ts | Track planMode from StatusUpdate, send toggle_plan_mode, bump init protocol to 1.4 and advertise supports_plan_mode. |
| web/src/features/chat/global-config-controls.tsx | Add Plan Mode switch UI. |
| web/src/features/chat/components/question-dialog.tsx | Add collapsible plan preview UI (Markdown render). |
| web/src/features/chat/components/prompt-toolbar/index.tsx | Show “plan” badge and keep toolbar visible while plan mode is active. |
| web/src/features/chat/components/chat-prompt-composer.tsx | Wire planMode/toggle props into controls. |
| web/src/features/chat/chat.tsx | Thread planMode/toggle props through workspace. |
| web/src/features/chat/chat-workspace-container.tsx | Connect session-stream planMode + togglePlanMode() to the workspace. |
| tests/core/test_wire_message.py | Update snapshot for new plan_mode field in StatusUpdate. |
| src/kimi_cli/wire/types.py | Add plan_mode to StatusUpdate model. |
| src/kimi_cli/wire/server.py | Add toggle_plan_mode request handling + emit StatusUpdate(plan_mode=...). |
| src/kimi_cli/wire/protocol.py | Bump WIRE_PROTOCOL_VERSION to 1.4. |
| src/kimi_cli/wire/jsonrpc.py | Add JSONRPCTogglePlanModeMessage and inbound method allowlist entry. |
| src/kimi_cli/soul/slash.py | Emit StatusUpdate(plan_mode=...) when /plan changes state. |
| src/kimi_cli/soul/kimisoul.py | Include plan_mode in step StatusUpdate. |
| docs/**/customization/wire-mode.md | Document supports_plan_mode, toggle_plan_mode, and StatusUpdate.plan_mode. |
| docs/**/release-notes/changelog.md, CHANGELOG.md | Add release-note entry for the web plan mode toggle. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
web/src/hooks/useSessionStream.ts
Outdated
| // Toggle plan mode via silent RPC (no context message) | ||
| const togglePlanMode = useCallback(() => { | ||
| if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { | ||
| return; | ||
| } | ||
| const message: JsonRpcRequest = { | ||
| jsonrpc: "2.0", | ||
| method: "toggle_plan_mode", | ||
| id: uuidV4(), | ||
| }; | ||
| wsRef.current.send(JSON.stringify(message)); | ||
| }, []); |
There was a problem hiding this comment.
togglePlanMode() sends a JSON-RPC request but the hook doesn’t track the request id or handle its error responses specially. In handleMessage(), any JSON-RPC error (except initialize retries) is treated as fatal, so a toggle_plan_mode failure would incorrectly put the whole session stream into the "error" state. Consider recording the toggle request id(s) and handling errors for those ids as non-fatal (e.g., show a toast and keep the socket/session alive).
| <CollapsibleTrigger className="flex items-center gap-2 w-full text-xs text-muted-foreground hover:text-foreground transition-colors py-1"> | ||
| <ChevronRightIcon className="size-3.5 transition-transform [[data-state=open]>&]:rotate-90" /> |
There was a problem hiding this comment.
The Chevron rotation class [[data-state=open]>&]:rotate-90 doesn’t match the existing Collapsible patterns in this codebase (which use group-data-[state=...] on an icon inside a group trigger). As written, the icon is unlikely to rotate when the collapsible opens/closes. Consider adding a group class to the CollapsibleTrigger and switching to a group-data-[state=open]:rotate-90 (or equivalent) variant on the icon.
| <CollapsibleTrigger className="flex items-center gap-2 w-full text-xs text-muted-foreground hover:text-foreground transition-colors py-1"> | |
| <ChevronRightIcon className="size-3.5 transition-transform [[data-state=open]>&]:rotate-90" /> | |
| <CollapsibleTrigger className="group flex items-center gap-2 w-full text-xs text-muted-foreground hover:text-foreground transition-colors py-1"> | |
| <ChevronRightIcon className="size-3.5 transition-transform group-data-[state=open]:rotate-90" /> |
| {/* Plan body preview */} | ||
| {currentQuestion.body && ( | ||
| <Collapsible defaultOpen className="mx-4 mb-2"> | ||
| <CollapsibleTrigger className="flex items-center gap-2 w-full text-xs text-muted-foreground hover:text-foreground transition-colors py-1"> | ||
| <ChevronRightIcon className="size-3.5 transition-transform [[data-state=open]>&]:rotate-90" /> | ||
| <span>Plan Preview</span> | ||
| </CollapsibleTrigger> | ||
| <CollapsibleContent> | ||
| <div className="border-l-2 border-blue-400/40 pl-3 mt-1 max-h-[360px] overflow-y-auto"> | ||
| <MessageResponse>{currentQuestion.body}</MessageResponse> | ||
| </div> | ||
| </CollapsibleContent> | ||
| </Collapsible> | ||
| )} |
There was a problem hiding this comment.
ExitPlanMode questions include other_label/other_description (e.g. "Revise") for the free-text option, but the dialog UI still renders the synthetic free-text row without any label/description, making the intended action unclear. Since this PR adds plan review UX based on currentQuestion.body, consider also rendering currentQuestion.other_label and currentQuestion.other_description alongside the free-text input (falling back to defaults when empty).
| const sendSetPlanMode = useCallback((enabled: boolean) => { | ||
| if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { | ||
| return; | ||
| } | ||
| const message: JsonRpcRequest = { | ||
| jsonrpc: "2.0", | ||
| method: "set_plan_mode", | ||
| id: uuidV4(), | ||
| params: { enabled }, | ||
| }; | ||
| wsRef.current.send(JSON.stringify(message)); | ||
| }, []); |
There was a problem hiding this comment.
🔴 Web client treats set_plan_mode JSON-RPC error as fatal, crashing the chat session
When sendSetPlanMode sends the set_plan_mode RPC (web/src/hooks/useSessionStream.ts:2631-2638), the response is handled by the generic handleMessage callback. If the server returns an error (e.g., "Plan mode is not supported" from src/kimi_cli/wire/server.py:563-569), the generic error handler (around line 1862-1896 of useSessionStream.ts) treats it as fatal: it sets setStatus("error"), calls onError, and calls completeStreamingMessages(). This puts the entire chat session into an error state and shows an error toast, even though a failed plan mode toggle should be a non-fatal, recoverable condition. The sendSetPlanMode function should either handle the response itself or the generic error handler should have an allowlist for non-fatal RPC error responses.
Prompt for agents
In web/src/hooks/useSessionStream.ts, the sendSetPlanMode function (around line 2627-2638) sends a set_plan_mode JSON-RPC request but does not track its message ID. The generic error handler in handleMessage (around line 1862-1896) treats all non-initialize error responses as fatal stream errors.
Fix: Track the set_plan_mode request ID (similar to initializeIdRef) so that the error handler can recognize it and handle it gracefully (e.g., log a warning or show a non-fatal toast) instead of crashing the chat session. Alternatively, add the set_plan_mode message ID to a set of non-fatal RPC IDs that get special handling in the error branch.
Was this helpful? React with 👍 or 👎 to provide feedback.
| {/* ── Tab bar ── */} | ||
| <div className="flex items-center gap-1.5 px-1"> | ||
| {activityStatus && ( | ||
| {activityStatus && ( |
There was a problem hiding this comment.
🟡 Broken indentation in prompt-toolbar JSX
Line 102 of web/src/features/chat/components/prompt-toolbar/index.tsx has lost its indentation. The {activityStatus && ( expression is at column 0 instead of being indented within the parent <div> element. This is a formatting-only issue that doesn't affect rendering but violates the code style convention used throughout the codebase.
| {activityStatus && ( | |
| {activityStatus && ( |
Was this helpful? React with 👍 or 👎 to provide feedback.
- Add StatusUpdate.plan_mode field for real-time plan state broadcast - Add SessionState.plan_mode for cross-session persistence - Add set_plan_mode_from_manual() for idempotent state setting - Add set_plan_mode Wire protocol method and handler - Broadcast StatusUpdate after plan mode changes in /plan slash command - Bump Wire protocol version to 1.4 - Fix test_display_block_diff_str_replace to use Edit tool
Summary
toggle_plan_modeRPC instead of injecting/planinto contextplan_modestate from backend to frontend viaStatusUpdatewire eventsExitPlanModeis triggeredplan_modefield toStatusUpdatewire type and snapshot testsChecklist
make gen-changelogto update the changelog.make gen-docsto update the user documentation.