[Plan Mode 4/6] Web UI + i18n#70068
Conversation
Greptile SummaryThis PR adds the plan-mode web UI layer: inline approval cards, a mode-switcher chip, a plan-view sidebar, a plan-resume helper, and i18n coverage across all supported locales. The overall design is solid and the defensive parsing in the event-stream handlers (
Confidence Score: 4/5One P1 defect in the approval decision flow could mislead users after transient network errors; safe to merge once that is fixed. The core logic is well-structured and the event-stream parsing is appropriately defensive. The P1 bug (resumePendingPlanInteraction sharing a try block with sessions.patch) produces a false "Plan approval failed" UX on transient network errors — wrong state is shown to the user and extra interaction is required to recover, warranting a 4 rather than 5. ui/src/ui/app.ts — specifically the handlePlanApprovalDecision try/catch block around line 299
|
| /** | ||
| * Build the synthetic user-turn message sent to the agent after the | ||
| /** | ||
| * PR-8 follow-up Round 2: shared placeholder content for the plan-view | ||
| * sidebar when no `update_plan` event has fired yet on this session. | ||
| * Module-level so `togglePlanViewSidebar` and other consumers can do | ||
| * an identity check (===) against the same string instance. | ||
| */ |
There was a problem hiding this comment.
Truncated / merged JSDoc block
Lines 134–141 contain what looks like a merge artifact: a /** comment opens on line 134 with an incomplete sentence ("Build the synthetic user-turn message sent to the agent after the"), then a second /** starts inside the same block on line 136. The intended standalone introductory comment for buildPlanViewMarkdown was never closed — the first block swallowed the new one. The function is still reachable (the comment is syntactically harmless in JS/TS), but the dangling sentence is dead documentation that will mislead future readers.
Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/ui/app.ts
Line: 134-141
Comment:
**Truncated / merged JSDoc block**
Lines 134–141 contain what looks like a merge artifact: a `/**` comment opens on line 134 with an incomplete sentence ("Build the synthetic user-turn message sent to the agent after the"), then a second `/**` starts inside the same block on line 136. The intended standalone introductory comment for `buildPlanViewMarkdown` was never closed — the first block swallowed the new one. The function is still reachable (the comment is syntactically harmless in JS/TS), but the dangling sentence is dead documentation that will mislead future readers.
How can I resolve this? If you propose a fix, please make it concise.| function renderPlanStep(step: PlanCardStep): TemplateResult { | ||
| const label = step.status === "in_progress" && step.activeForm ? step.activeForm : step.text; | ||
| const marker = STATUS_MARKERS[step.status] ?? "⬚"; | ||
| const statusClass = `chat-plan-card__step--${step.status.replace("_", "-")}`; |
There was a problem hiding this comment.
replace("_", "-") only substitutes the first underscore
step.status.replace("_", "-") uses the string-literal overload of String.prototype.replace, which replaces only the first occurrence. All current status values (in_progress, pending, completed, cancelled) have at most one underscore, so this works today. A future status like not_yet_started would produce the class chat-plan-card__step--not-yet_started (trailing underscore), breaking CSS targeting. Using a regex (/_/g) or replaceAll future-proofs this.
| const statusClass = `chat-plan-card__step--${step.status.replace("_", "-")}`; | |
| const statusClass = `chat-plan-card__step--${step.status.replaceAll("_", "-")}`; |
Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/ui/chat/plan-cards.ts
Line: 88
Comment:
**`replace("_", "-")` only substitutes the first underscore**
`step.status.replace("_", "-")` uses the string-literal overload of `String.prototype.replace`, which replaces only the **first** occurrence. All current status values (`in_progress`, `pending`, `completed`, `cancelled`) have at most one underscore, so this works today. A future status like `not_yet_started` would produce the class `chat-plan-card__step--not-yet_started` (trailing underscore), breaking CSS targeting. Using a regex (`/_/g`) or `replaceAll` future-proofs this.
```suggestion
const statusClass = `chat-plan-card__step--${step.status.replaceAll("_", "-")}`;
```
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5b59872470
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // PR-10 review fix (Codex P2 #3104743333): authoritative refresh | ||
| // for merge-mode plans via the agent_plan_event stream's | ||
| // `mergedSteps` field. See maybeForwardMergedPlanEvent for the why. | ||
| maybeForwardMergedPlanEvent(host as PlanApprovalHost, payload); |
There was a problem hiding this comment.
Handle plan stream before filtering to tool events
maybeForwardMergedPlanEvent is called from the payload.stream === "tool" branch, but that helper only processes payload.stream === "plan". Because of this ordering, merged plan events are never forwarded, so update_plan merge-mode updates won't refresh the live plan sidebar and users will see stale plan state while execution progresses.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Real concern, acknowledged. Not fixed in this immediate cycle (we're in cleanup-mode focused on maintainer-takeover; see #70101 for context). Tracked in the umbrella's deferred-items checklist for the bot-triage follow-up cycle. The fix will land on this branch with a Fixed in {SHA} reply here per the standard pr-review-loop pattern. Maintainers can also opt to merge the per-part PR as-is and address in a post-merge polish cycle.
| if (!snapshot || snapshot.length === 0) { | ||
| return; |
There was a problem hiding this comment.
Clear cached plan markdown when no session snapshot exists
When switching to a session that has no planMode.lastPlanSteps, this early return leaves latestPlanMarkdown untouched from the previous session. The plan-view toggle then reuses stale markdown from another session instead of showing the placeholder, which can display incorrect session context and mislead approvals/navigation.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Real concern, acknowledged. Not fixed in this immediate cycle (we're in cleanup-mode focused on maintainer-takeover; see #70101 for context). Tracked in the umbrella's deferred-items checklist for the bot-triage follow-up cycle. The fix will land on this branch with a Fixed in {SHA} reply here per the standard pr-review-loop pattern. Maintainers can also opt to merge the per-part PR as-is and address in a post-merge polish cycle.
There was a problem hiding this comment.
Pull request overview
Adds the Plan Mode webchat UI surfaces (inline approval/question card, mode-switcher chip, plan sidebar/view plumbing, plan rendering cards, and resume-on-reconnect behavior), plus updates to slash-command handling and tool display metadata.
Changes:
- Introduces inline plan approval/question UI and wiring in the main chat view, including a hidden “resume” send after resolve.
- Adds the mode-switcher chip (with keyboard shortcut helper) and plan card rendering + styles.
- Extends
/planlocal command support and updates session/UI types + tool display metadata.
Reviewed changes
Copilot reviewed 49 out of 49 changed files in this pull request and generated 18 comments.
Show a summary per file
| File | Description |
|---|---|
| ui/src/ui/views/plan-approval-inline.ts | Inline plan/question approval card renderer (Lit templates). |
| ui/src/ui/views/plan-approval-inline.test.ts | Unit tests for inline approval rendering and interactions. |
| ui/src/ui/views/chat.ts | Integrates inline approval card, hides input while pending, adds mode-switcher UI, adds subagent-blocking toast. |
| ui/src/ui/types.ts | Extends session row types with plan-mode/permission-mode/pending-interaction fields. |
| ui/src/ui/chat/slash-commands.ts | Adds /plan to local commands + UI-only command catalog metadata. |
| ui/src/ui/chat/slash-command-executor.ts | Implements /plan subcommands (on/off/status/view/auto/accept/revise/answer/etc.) and helpers. |
| ui/src/ui/chat/slash-command-executor.node.test.ts | Node tests for /plan auto, /plan accept, and /plan answer behavior. |
| ui/src/ui/chat/plan-resume.ts | Sends hidden “continue” message to resume after approval/answer. |
| ui/src/ui/chat/plan-resume.node.test.ts | Tests idempotency key and hidden send payload. |
| ui/src/ui/chat/plan-cards.ts | Adds expandable plan cards + markdown formatting helper. |
| ui/src/ui/chat/plan-cards.test.ts | Tests plan card rendering and markdown formatting. |
| ui/src/ui/chat/mode-switcher.ts | Adds mode definitions, chip/menu renderer, and keyboard shortcut helper. |
| ui/src/ui/chat/mode-switcher.test.ts | Tests mode resolution, shortcut handling, focus guard, and chip/menu rendering. |
| ui/src/ui/chat/grouped-render.test.ts | Updates chat rendering tests to cover inline approval visibility and media/tool-card behaviors. |
| ui/src/ui/app.ts | Adds plan sidebar markdown builder, approval handlers/state, hydration from sessions, and subagent-blocking toast state. |
| ui/src/ui/app-view-state.ts | Plumbs new plan approval state/handlers and subagent-blocking status into the view-state contract. |
| ui/src/ui/app-tool-stream.ts | Adds PlanApprovalRequest/SubagentBlockingStatus types and tool/approval stream handlers for plan events. |
| ui/src/ui/app-render.ts | Wires mode chip selection to sessions.patch and routes plan approval + sidebar open handlers into chat rendering. |
| ui/src/ui/app-render.helpers.ts | Adds “Plan view” button to chat controls with pressed-state tracking. |
| ui/src/ui/app-chat.ts | Dispatches new slash actions (toggle-plan-view) and triggers resume after successful plan commands. |
| ui/src/styles/chat/plan-cards.css | Styling for in-thread plan cards. |
| ui/src/styles/chat/layout.css | Styling for mode switcher chip/menu and inline plan approval card. |
| ui/src/styles/chat.css | Imports plan card styles into chat bundle. |
| ui/src/i18n/locales/en.ts | Adds chat.planViewToggle but also removes multiple existing keys. |
| ui/src/i18n/locales/de.ts | Adds chat.planViewToggle (currently English string). |
| ui/src/i18n/locales/es.ts | Adds chat.planViewToggle (currently English string). |
| ui/src/i18n/locales/fr.ts | Adds chat.planViewToggle (currently English string). |
| ui/src/i18n/locales/id.ts | Adds chat.planViewToggle (currently English string). |
| ui/src/i18n/locales/ja-JP.ts | Adds chat.planViewToggle (currently English string). |
| ui/src/i18n/locales/ko.ts | Adds chat.planViewToggle (currently English string). |
| ui/src/i18n/locales/pl.ts | Adds chat.planViewToggle (currently English string). |
| ui/src/i18n/locales/pt-BR.ts | Adds chat.planViewToggle (currently English string). |
| ui/src/i18n/locales/tr.ts | Adds chat.planViewToggle (currently English string). |
| ui/src/i18n/locales/uk.ts | Adds chat.planViewToggle (currently English string). |
| ui/src/i18n/locales/zh-CN.ts | Adds chat.planViewToggle (currently English string). |
| ui/src/i18n/locales/zh-TW.ts | Adds chat.planViewToggle (currently English string). |
| ui/src/i18n/.i18n/de.meta.json | Locale metadata updates (fallback key tracked). |
| ui/src/i18n/.i18n/es.meta.json | Locale metadata updates (fallback key tracked). |
| ui/src/i18n/.i18n/fr.meta.json | Locale metadata updates (fallback key tracked). |
| ui/src/i18n/.i18n/id.meta.json | Locale metadata updates (fallback key tracked). |
| ui/src/i18n/.i18n/ja-JP.meta.json | Locale metadata updates (fallback key tracked). |
| ui/src/i18n/.i18n/ko.meta.json | Locale metadata updates (fallback key tracked). |
| ui/src/i18n/.i18n/pl.meta.json | Locale metadata updates (fallback key tracked). |
| ui/src/i18n/.i18n/pt-BR.meta.json | Locale metadata updates (fallback key tracked). |
| ui/src/i18n/.i18n/tr.meta.json | Locale metadata updates (fallback key tracked). |
| ui/src/i18n/.i18n/uk.meta.json | Locale metadata updates (fallback key tracked). |
| ui/src/i18n/.i18n/zh-CN.meta.json | Locale metadata updates (fallback key tracked). |
| ui/src/i18n/.i18n/zh-TW.meta.json | Locale metadata updates (fallback key tracked). |
| apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json | Adds display metadata for plan-mode tools (enter/exit/status/question). |
| // PR-8 follow-up: track whether the right sidebar is currently | ||
| // showing the live plan markdown. Used to set aria-pressed on the | ||
| // plan-view toggle so the chip looks "on" while the sidebar is open. | ||
| const sidebarPlanOpen = | ||
| state.sidebarOpen && | ||
| state.sidebarContent?.kind === "markdown" && | ||
| state.latestPlanMarkdown != null && | ||
| state.sidebarContent.content === state.latestPlanMarkdown; |
There was a problem hiding this comment.
sidebarPlanOpen only returns true when latestPlanMarkdown != null and the sidebar content equals it. When the plan sidebar is opened with the placeholder (before any plan is tracked), aria-pressed/active styling stays false even though the toggle is logically on. Consider treating the placeholder markdown as an "open" state as well so the button state and accessibility signal match the UI.
| wsUrl: "WebSocket URL", | ||
| token: "Gateway Token", | ||
| password: "Password (not stored)", | ||
| passwordPlaceholder: "system or shared password", | ||
| sessionKey: "Default Session Key", | ||
| language: "Language", | ||
| connectHint: "Click Connect to apply connection changes.", | ||
| trustedProxy: "Authenticated via trusted proxy.", | ||
| showToken: "Show token", | ||
| hideToken: "Hide token", | ||
| toggleTokenVisibility: "Toggle token visibility", | ||
| showPassword: "Show password", | ||
| hidePassword: "Hide password", | ||
| togglePasswordVisibility: "Toggle password visibility", | ||
| }, |
There was a problem hiding this comment.
These translation keys were removed from the locale map, but they are still referenced in the UI (e.g. ui/src/ui/views/overview.ts uses overview.access.passwordPlaceholder, overview.access.showToken/hideToken, overview.access.showPassword/hidePassword, and the corresponding toggle labels). Removing them will cause missing-string fallbacks at runtime. Either restore these keys (and keep them in sync across locales) or update the UI to use the replacement login.* keys everywhere they’re needed.
| chat: { | ||
| disconnected: "已斷開與網關的連接。", | ||
| refreshTitle: "刷新聊天數據", | ||
| planViewToggle: "Toggle plan view sidebar", |
There was a problem hiding this comment.
This locale’s chat.planViewToggle string is still English ("Toggle plan view sidebar"), which will show untranslated UI text for zh-TW users. Please provide a proper zh-TW translation (and clear the fallback key in the locale metadata once translated).
| planViewToggle: "Toggle plan view sidebar", | |
| planViewToggle: "切換計畫檢視側邊欄", |
| chat: { | ||
| disconnected: "Gateway bağlantısı kesildi.", | ||
| refreshTitle: "Sohbet verilerini yenile", | ||
| planViewToggle: "Toggle plan view sidebar", |
There was a problem hiding this comment.
This locale’s chat.planViewToggle string is still English ("Toggle plan view sidebar"), which will show untranslated UI text for Turkish users. Please provide a proper Turkish translation (and clear the fallback key in the locale metadata once translated).
| planViewToggle: "Toggle plan view sidebar", | |
| planViewToggle: "Plan görünümü kenar çubuğunu aç/kapat", |
| chat: { | ||
| disconnected: "Gateway와 연결이 끊어졌습니다.", | ||
| refreshTitle: "채팅 데이터 새로고침", | ||
| planViewToggle: "Toggle plan view sidebar", |
There was a problem hiding this comment.
This locale’s chat.planViewToggle string is still English ("Toggle plan view sidebar"), which will show untranslated UI text for Korean users. Please provide a proper Korean translation (and clear the fallback key in the locale metadata once translated).
| planViewToggle: "Toggle plan view sidebar", | |
| planViewToggle: "플랜 보기 사이드바 전환", |
| /** | ||
| * Build the synthetic user-turn message sent to the agent after the | ||
| /** | ||
| * PR-8 follow-up Round 2: shared placeholder content for the plan-view | ||
| * sidebar when no `update_plan` event has fired yet on this session. |
There was a problem hiding this comment.
There’s an unfinished/duplicated JSDoc block here (a stray /** Build the synthetic user-turn message... line immediately followed by another /**). This breaks comment structure and makes the header misleading; either complete the first docblock or remove the stray fragment so the file has valid, intentional documentation.
| chat: { | ||
| disconnected: "Відключено від шлюзу.", | ||
| refreshTitle: "Оновити дані чату", | ||
| planViewToggle: "Toggle plan view sidebar", |
There was a problem hiding this comment.
This locale’s chat.planViewToggle string is still English ("Toggle plan view sidebar"), which will show untranslated UI text for Ukrainian users. Please provide a proper Ukrainian translation (and clear the fallback key in the locale metadata once translated).
| planViewToggle: "Toggle plan view sidebar", | |
| planViewToggle: "Перемкнути бічну панель перегляду плану", |
| chat: { | ||
| disconnected: "Rozłączono z Gateway.", | ||
| refreshTitle: "Odśwież dane czatu", | ||
| planViewToggle: "Toggle plan view sidebar", |
There was a problem hiding this comment.
This locale’s chat.planViewToggle string is still English ("Toggle plan view sidebar"), which will show untranslated UI text for Polish users. Please provide a proper Polish translation (and clear the fallback key in the locale metadata once translated).
| planViewToggle: "Toggle plan view sidebar", | |
| planViewToggle: "Przełącz pasek boczny widoku planu", |
| chat: { | ||
| disconnected: "Desconectado de la puerta de enlace.", | ||
| refreshTitle: "Actualizar datos del chat", | ||
| planViewToggle: "Toggle plan view sidebar", |
There was a problem hiding this comment.
This locale’s chat.planViewToggle string is still English ("Toggle plan view sidebar"), which will show untranslated UI text for Spanish users. Please provide a proper Spanish translation (and clear the fallback key in the locale metadata once translated).
| planViewToggle: "Toggle plan view sidebar", | |
| planViewToggle: "Alternar barra lateral de vista del plan", |
| chat: { | ||
| disconnected: "Verbindung zum Gateway getrennt.", | ||
| refreshTitle: "Chat-Daten aktualisieren", | ||
| planViewToggle: "Toggle plan view sidebar", |
There was a problem hiding this comment.
This locale’s chat.planViewToggle string is still English ("Toggle plan view sidebar"), which will show untranslated UI text for German users. Please provide a proper German translation (and clear the fallback key in the locale metadata once translated).
| planViewToggle: "Toggle plan view sidebar", | |
| planViewToggle: "Planansicht-Seitenleiste ein-/ausblenden", |
|
Acknowledging Greptile / Copilot / Codex review feedback on this PR. Triage status:
For end-to-end integration testing in the meantime, see #70071 [Plan Mode FULL] — the green-CI integrated bundle of all parts. |
5b59872 to
f0195c2
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f0195c23fc
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| state: Pick<AppViewState, "settings" | "password">, | ||
| ) { | ||
| return resolveControlUiAuthToken(state); | ||
| return ( | ||
| normalizeOptionalString(state.settings.token) ?? normalizeOptionalString(state.password) ?? null |
There was a problem hiding this comment.
Restore device-token fallback for assistant media auth
resolveAssistantAttachmentAuthToken now ignores hello.auth.deviceToken and only returns settings.token/password. In paired-device sessions that rely on the device token, assistant-media URLs are generated without an auth token, so attachment metadata checks and local media previews can fail as unavailable even when the session is authenticated. This regresses chat attachment rendering for paired clients.
Useful? React with 👍 / 👎.
| const url = buildAvatarMetaUrl(host.basePath, agentId); | ||
| try { | ||
| const res = await fetch(url, { method: "GET", ...(headers ? { headers } : {}) }); | ||
| const res = await fetch(url, { method: "GET" }); |
There was a problem hiding this comment.
Preserve authenticated avatar fetch path
refreshChatAvatar now performs an unauthenticated metadata fetch and directly trusts the returned avatar URL. In token-auth setups (session token/password/device token), /avatar/... endpoints can require Authorization, so metadata fetches can return 401 and local avatar URLs can render as broken images. The previous authenticated/blob path was needed to keep avatars working under authenticated Control UI sessions.
Useful? React with 👍 / 👎.
| await this.client.request("sessions.patch", params); | ||
| await resumePendingPlanInteraction(this.client, active.sessionKey); | ||
| } catch (err) { |
There was a problem hiding this comment.
Don't roll back approved plan UI when resume send fails
This try/catch combines the approval state transition and the follow-up resume send; if sessions.patch succeeds but resumePendingPlanInteraction fails transiently, the catch path restores the approval card and reports failure even though the plan was already resolved server-side. That can re-open stale approval UI and encourage duplicate retries against an already-committed approval.
Useful? React with 👍 / 👎.
f0195c2 to
27c24da
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 27c24da4b0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const md = `# ${request.summary || "Proposed plan"}\n\n${stepLines}\n`; | ||
| state.handleOpenSidebar({ kind: "markdown", content: md }); |
There was a problem hiding this comment.
Use full plan formatter for inline card sidebar open
This callback rebuilds markdown from request.summary and step lines only, so opening a plan from the approval card drops the richer fields now carried on PlanApprovalRequest (title, analysis, assumptions, risks, verification, references). In sessions where the agent supplies that context, closing and reopening via “Open plan” shows an incomplete plan and can hide decision-critical details from the reviewer.
Useful? React with 👍 / 👎.
| this.planApprovalQuestionOtherOpen = false; | ||
| this.planApprovalQuestionOtherDraft = ""; | ||
| await this.handlePlanApprovalAnswer(draft); |
There was a problem hiding this comment.
Preserve Other answer text when submit request fails
The free-text question path clears planApprovalQuestionOtherOpen and the draft before awaiting handlePlanApprovalAnswer. If the patch/resume request fails, handlePlanApprovalAnswer restores the pending question card but does not restore the typed “Other” text, so users must re-open and retype their answer after transient failures.
Useful? React with 👍 / 👎.
|
Related work from PRtags group Title: Open PR candidate: plan-mode carve-out overlaps integrated full bundle
|
📋 Umbrella tracker: #70101 — master tracker for the 9-PR plan-mode rollout. See it for status of all parts + suggested merge order + carry-forward backlog.
Executive summary
This PR ships the web UI surface of plan mode: the visual layer a webchat user actually touches when a session enters plan mode. It adds (a) plan cards that render the agent's proposed checklist inline in the message thread with per-step status, (b) a mode-switcher chip in the chat input toolbar that lets users toggle plan-vs-normal (and the PR-10 "Plan ⚡" auto-approve variant) with both pointer and keyboard, (c) an inline plan-approval card above the chat input — Accept / Accept-allow-edits / Revise — that doubles as the surface for
AskUserQuestioninteractions, and (d) plan-resume wiring that sends a hiddenchat.sendafter a web-side approval/answer lands so the agent run continues without echoing a synthetic"continue"into the visible transcript.Integration with the rest of the stack is intentionally narrow. The UI is a pure consumer of state shapes from 2/6 (
planMode,planApproval,pendingAgentInjections) and tool contracts from 3/6 (AskUserQuestion,exit_plan_mode). The chip writes viasessions.patch; the approval card writes viasessions.patch { planApproval: { action } }; resume sendschat.send { deliver: false }so the runtime can pick up the persisted decision/answer without the user seeing an extra message bubble. Nothing in this PR adds new RPCs or new state — it surfaces what 2/6 + 3/6 already manage.The four core component files (
plan-cards.ts,mode-switcher.ts,plan-resume.ts,plan-approval-inline.ts) total 873 LoC, with 1067 LoC of tests against them. The remaining ~4.7k lines of the diff is integration glue inviews/chat.ts(host wiring),app-tool-stream.ts(event-stream side detection of plan-related tool events),app.ts/app-chat.ts/app-render.ts/app-view-state.ts(top-level app state machine extensions for the approval-card local state), CSS (plan-card visuals + chat-shell layout adjustments), the slash-command executor for/plan, and the i18n cleanup deletions described below. The components themselves are small, pure, and testable in isolation — by design.TL;DR
plan-cards.ts,mode-switcher.ts,plan-resume.ts,plan-approval-inline.ts) + their CSS + their tests; integration intoviews/chat.ts,app-chat.ts,app-render.ts,app-tool-stream.ts; plan-mode entries intool-display.json; one new i18n key (planViewToggle) across 13 locales.en,de,es,fr,id,ja-JP,ko,pl,pt-BR,tr,uk,zh-CN,zh-TW. Plus the i18n cleanup deletions described below.:focus-visibleoutline on plan-card<summary>(Copilot review fix from feat(plan-mode): full rollout + iter-1/2/3 hardening (consolidates PRs A/B/C/D/E/F/7/8/10/11) #68939,plan-cards.css:46-50),aria-haspopup="menu"+aria-expandedon the mode chip,role="region"+aria-labelon the approval card, deliberate non-claim ofrole="menu"on the dropdown so the WAI-ARIA menu keyboard contract isn't falsely advertised (mode-switcher.ts:328-339)..shadowRoot.activeElementso Lit composers' inner inputs don't have their keystrokes stolen (mode-switcher.ts:384-402).connected === false(plan-approval-inline.ts:98-102; testplan-approval-inline.test.ts:133-150).Web UI component tree
How the new pieces slot into the existing webchat layout. Bold = added by this PR; everything else is the existing chat shell from
views/chat.ts.Plan-resume on web reconnect
Why the resume primitive exists: when a web client approves a plan or answers a question, the authoritative decision lands in session state via
sessions.patch(handled by 2/6). But the agent run that produced the approval request is paused. Something has to kick the run back into life without echoing a synthetic"continue"into the visible transcript or duplicating the decision the user already made.sequenceDiagram participant U as User (web) participant W as Webchat client participant G as Gateway participant R as Runtime participant S as Session state U->>W: clicks "Accept" on approval card W->>G: sessions.patch { planApproval: { action: "approve" } } G->>S: persist decision in pendingAgentInjections G-->>W: 200 OK Note over W: card vanishes; composer<br/>re-enables W->>G: chat.send { message: "continue", deliver: false,<br/>idempotencyKey: "plan-resume-<uuid>" } Note right of W: hidden — does NOT post a<br/>visible "continue" bubble<br/>(plan-resume.ts:11-21) G->>R: dispatch run with persisted decision context R->>S: read pendingAgentInjections, drain R-->>W: streams agent output (now executing the approved plan)The resume primitive is one function:
resumePendingPlanInteraction(client, sessionKey)atui/src/ui/chat/plan-resume.ts:11-21. Three things matter about its shape:deliver: false— the gateway records the message in the session log but does NOT broadcast it to the channel as a user-visible bubble. Without this, every plan approval would inject a stray"continue"into the transcript.idempotencyKey: "plan-resume-<uuid>"— theplan-resume-prefix is the load-bearing piece. Server-side correlation (in 2/6) treats any send carrying this prefix as a resume signal rather than a normal user message, which short-circuits thependingAgentInjectionsdrain logic.views/chat.ts, which fires it after thesessions.patchfor an approval/answer resolves.The single test (
plan-resume.node.test.ts:9-26) pins the contract: the call shape ischat.send { sessionKey, message: "continue", deliver: false, idempotencyKey: "plan-resume-uuid-fixed" }. If a future refactor changes the prefix, the runtime correlation breaks silently — this test is the canary.Mode-switcher state
The chip has a small derived-state machine driven by three independent session fields:
(execSecurity, execAsk, planMode, planAutoApprove). The derivation is centralised inresolveCurrentMode()atmode-switcher.ts:237-278.stateDiagram-v2 [*] --> Default: execSec=undef<br/>execAsk=undef<br/>planMode=undef Default --> Ask: pick "Ask" / Ctrl+2 Default --> Accept: pick "Accept" / Ctrl+3 Default --> Plan: pick "Plan" / Ctrl+4 Default --> PlanAuto: pick "Plan ⚡" / Ctrl+5 Default --> Bypass: pick "Bypass" / Ctrl+6 Ask --> Plan: planMode→"plan"<br/>(perm-mode preserved) Accept --> Plan: planMode→"plan" Bypass --> Plan: planMode→"plan" Plan --> PlanAuto: pick "Plan ⚡"<br/>(planAutoApprove=true) PlanAuto --> Plan: pick plain "Plan"<br/>(autoApprove cleared) Plan --> Default: pick "Default" → planMode→"normal"<br/>+ clear execSec/execAsk overrides PlanAuto --> Default: pick "Default" Default --> Custom: server returns<br/>(execSec="deny", …) or other<br/>non-preset combo note right of Plan Plan WINS over permission mode in chip display — chip shows "Plan" regardless of underlying (execSec, execAsk). end note note right of Custom Synthetic mode for non-preset (execSec, execAsk) combos. Was: silently mislabeled as Ask (PR #67721 fix). end noteThe state machine has three load-bearing rules:
planMode === "plan", the chip shows "Plan" (or "Plan ⚡") regardless of the underlying(execSecurity, execAsk). Test:mode-switcher.test.ts:26-29.planAutoApproveis meaningful only whenplanMode === "plan"— pre-arming auto-approve while still in normal mode does NOT make the chip lie about being in plan mode. Test:mode-switcher.test.ts:49-55.(execSecurity="deny", execAsk="off")which is a valid non-preset state, and showing "Ask" there would let the user accidentally loosen permissions. Test:mode-switcher.test.ts:77-86.Note on i18n surface area (Codex P2 review)
In addition to the plan-mode UI work, this PR's diff includes mechanical cleanup of 12 locale files (
ui/src/i18n/locales/*.ts+ corresponding.i18n/*.meta.json) that delete unused auth/pairing/login/docs strings unrelated to plan mode. The pattern per locale file is:planViewToggle: "Toggle plan view sidebar"plan-mode key (this is the actual plan-mode work)passwordPlaceholder,showToken/hideToken/toggleTokenVisibility,scopeUpgradeTitle/scopeUpgradeSummary/roleUpgradeTitle,authDocsTitle/tailscaleDocsTitle/etc.Codex review flagged the deletions as stylistically misplaced (they belong in a separate housekeeping PR). We considered surgically removing them but the i18n CI check (
pnpm ui:i18n:check) requires the.meta.jsontotals/hashes to match the.tscontent; mechanically reverting the deletions risks breaking the check without a regen step.Maintainer call: the deletions are valid (those keys are genuinely unused — verified by absence of references in the UI source) but they're stylistically separate from plan-mode UI work. Two acceptable resolutions:
mainis identical to landing the plan-mode key separately. ~518 LoC of cleanup is a bonus side effect.planViewToggleaddition) and ship the deletions in a follow-up housekeeping PR after this rolls out. We'd need apnpm ui:i18n:regenstep (or its equivalent) to keep.meta.jsonconsistent.Tracking either decision in umbrella #70101.
How it wires into
views/chat.tsThe four UI primitives are pure functions; the integration glue lives in
ui/src/ui/views/chat.ts(already large; +371 net lines in this PR). The relevant block atchat.ts:1382-1456is the contract worth eyeballing:Three things to notice in that block, all of which are review-debt receipts rather than fresh design choices:
sessionKey === activeSession?.keygate — the sameplanApprovalRequestcould (in theory) belong to a session the user has navigated away from. The card only renders for the active session; this prevents an approval from leaking across session contexts.feedback: minLength: 1(closes the "reject with no guidance" loophole from earlier iters of plan mode). The host short-circuits the submit so the user sees the textarea remain in place to type into, instead of a confusing server-side validation error toast.planApprovalRequestwas present. Copilot review #3105170553 / #3105219639 flagged that if the host forgets to wireonPlanApprovalDecision, the user gets neither the card (which checks the handler) nor the input. The fixed predicate gates on BOTH being present.The mode-switcher integration is similarly defensive at
chat.ts:1503:— derivation runs every render, so the chip stays in sync with whatever
sessions.patchevents the gateway streams down.Per-file deep dive
ui/src/ui/chat/plan-cards.ts(122 LoC)Inline plan rendering for the message thread. Two exports:
renderPlanCard(plan)andformatPlanAsMarkdown(plan).Shape —
PlanCardData = { title, explanation?, steps: PlanCardStep[], source? };PlanCardStep = { text, status: "pending"|"in_progress"|"completed"|"cancelled", activeForm? }. Status-marker glyphs atplan-cards.ts:23-28are deliberately monospaced-friendly (⬚ ⏳ ✅ ❌) so terminal-style operators reading raw markdown via the sidebar's "Copy as markdown" still get a parseable checklist.Markdown formatter —
formatPlanAsMarkdown()atplan-cards.ts:101-122renders for the right-sidebar pane and clipboard export. Cancelled steps render~~strikethrough~~ (cancelled); in-progress steps render**bold** (in progress)and useactiveForm(the ongoing-tense label, e.g. "Building artifacts") instead oftext("Build artifacts"). All step text passes through a single newline-strippingclean()so multi-line text from the agent doesn't break the bullet structure.<details>/<summary>rendering — thesummaryshows<plan-icon> <title> <N/M done | N steps> <chevron>. Native::-webkit-details-markerand Firefox's::markerare both suppressed (plan-cards.css:25-33) so the custom chevron isn't doubled. Focus-visible outline on the summary atplan-cards.css:46-50(Copilot review fix from umbrella #68939).Test coverage —
plan-cards.test.ts(159 LoC). Splits into aformatPlanAsMarkdowngroup (markdown-shape assertions including the activeForm-shadowing edge case at line 47-51) and arenderPlanCard (jsdom render)group that asserts real DOM structure:<details>exists, summary text contains "1/2 done" or "2 steps" depending on completion state, one<li>per step with the right status class, activeForm shadows text in in-progress rows.ui/src/ui/chat/mode-switcher.ts(424 LoC)The chip + dropdown menu in the chat input toolbar. Three exports drive the host:
MODE_DEFINITIONS(the catalog),resolveCurrentMode(execSecurity, execAsk, planMode, planAutoApprove)(state derivation),renderModeSwitcher(...)(Lit template),handleModeShortcut(e)(Ctrl+1..6 dispatcher).Mode catalog —
MODE_DEFINITIONSatmode-switcher.ts:121-192carries six entries: Default (Ctrl+1), Ask (Ctrl+2), Accept (Ctrl+3), Plan (Ctrl+4), Plan ⚡ (Ctrl+5), Bypass (Ctrl+6). TheDefaultentry has bothexecSecurityandexecAskundefined — the host treats undefined as "DELETE the per-session overrides via patch", so picking Default returns the session to whatever the operator configured atagents.defaults. Without this, the post-plan-mode fallback would lock back to Ask, which most operator configs don't want.Plan as a dimension, not a permutation — the file-level header comment (
mode-switcher.ts:1-16) explains whyplanModeis NOT mapped ontoexecSecurity: plan mode needs read-only exec for research, so blocking exec viaexecSecurity=allowlistwould defeat its purpose. Plan mode + permission mode coexist, andresolveCurrentModelets plan WIN for display purposes only.Shadow-DOM-aware focus guard —
getDeepActiveElement()atmode-switcher.ts:384-402walksdocument.activeElement.shadowRoot.activeElementrecursively (capped at depth 32) until it bottoms out at the real focus target. Without this, focus inside a<openclaw-chat-composer>Web Component's internal<input>returns the host element, the focus guard fails to bail, and Ctrl+1..6 steal keystrokes the user meant for typing. Two regression tests cover depth-1 (mode-switcher.test.ts:249-270) and depth-2 (mode-switcher.test.ts:272-290) Shadow DOM nesting.Deliberate non-claim of
role="menu"—mode-switcher.ts:328-339explains why the dropdown does NOT declarerole="menu": claiming the menu role without implementing arrow-nav, Home/End, roving tabindex, and focus trap would mislead assistive tech (per WAI-ARIA spec). Plain<button>s give native focus + Escape-on-chip, which is a real usable interaction with no false ARIA promise. Test asserting the role is absent:mode-switcher.test.ts:331-334.Test coverage —
mode-switcher.test.ts(388 LoC). Four describe blocks:resolveCurrentMode(10 cases including the PR-10 plan-auto interaction, the PR-8 Default/undefined handling, the sandboxdenyregression),handleModeShortcut(8 cases for the modifier-exclusion matrix — Ctrl alone OK, Cmd/Shift/Alt all bail),focus guard(5 cases with the Shadow-DOM regression coverage),renderModeSwitcher (jsdom render)(5 DOM-shape cases).ui/src/ui/chat/plan-resume.ts(21 LoC)Single function, single test. Covered above in the "Plan-resume on web reconnect" section. The 21 LoC is the entire UI side of plan resume — everything else (decision persistence, runtime drain, idempotency-key correlation) lives in 2/6.
ui/src/ui/views/plan-approval-inline.ts(306 LoC)The card that appears ABOVE the chat input bar when
planApprovalRequest != null. Two visual variants share the same shell: the plan variant (3-button triad: Accept / Accept-allow-edits / Revise + an "Open plan" link) and the question variant (PR-10, AskUserQuestion: 1 button per option + optional "Other…" textarea).Plan variant —
renderInlinePlanApproval()atplan-approval-inline.ts:53-175. Buttons map tosessions.patch { planApproval: { action: "approve" | "approve-with-edits" | "revise" } }, fired by the host. The "Revise" button opens an inline textarea (matching Claude Code's web revision UX) with Ctrl/Cmd+Enter to submit and Escape to cancel; the chat input is hidden by the caller while the card is showing so users don't accidentally type into the wrong surface.Title fallback —
plan-approval-inline.ts:73-77: when the agent'sexit_plan_modecall carries an explicittitle(PR-9 Tier 1 contract), use it; when it's the generic "Plan approval requested" boilerplate or the legacy "Plan approval — …" prefix, fall back to "Agent proposed a plan". This means agents that don't yet emit Tier-1 titles still get a sensible headline. Test atplan-approval-inline.test.ts:47-68.Question variant —
renderInlineQuestion()atplan-approval-inline.ts:183-306. Same shell, different actions row. Carries a defensive guard: if the host forgets to wireonAnswerOption, the buttons render asdisabledand a visible warning appears (⚠️ Question handler not wired (host did not pass onPlanApprovalAnswer). Buttons disabled.) — instead of mute no-op buttons that look interactive (plan-approval-inline.ts:196-222). This was a Copilot review fix (#3104741709) on the umbrella.Offline behavior — when
connected === false, every button across both variants is disabled and a banner appears ("Reconnect to resolve this plan. The approval stays pending while offline." for plans; analogous for questions). Tests assert disabled state atplan-approval-inline.test.ts:133-150(plan) andplan-approval-inline.test.ts:181-210(question)."Other…" textarea state — PR-13 Bug 2 fix: caller owns
questionOtherOpen/questionOtherDraftso the textarea state survives across re-renders, and Escape returns to the option list (instead of dismissing the entire card, which awindow.prompt-based implementation would have done). Test atplan-approval-inline.test.ts:248-294.Test coverage —
plan-approval-inline.test.ts(295 LoC). 10 cases: nothing-when-no-request, generic-title fallback, button wiring, revise-editor draft+keyboard, offline plan, missing-handler warning, offline question, option click + Other handoff, free-text submit + cancel.Supporting files (the rest of the diff)
apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json(+29 lines) — adds 5 plan-mode tool entries the native apps render:update_plan(🗺️),enter_plan_mode(🧭),exit_plan_mode(✅),plan_mode_status(🔍),ask_user_question(❓). Each carriesdetailKeysso the native side knows which payload fields to render in the tool card. The web UI doesn't read this file directly — it has its owntool-display-config.tsmirror — but native apps depend on this catalog being in sync.ui/src/styles/chat/plan-cards.css(+134 lines) — accent-bordered card with status-marker glyphs. Both::-webkit-details-marker(Chromium/Safari) and::marker(Firefox) suppressed so the custom chevron isn't doubled.:focus-visibleoutline added on<summary>(Copilot review fix from umbrella).ui/src/styles/chat/layout.css(+228 lines) — chat shell layout adjustments to make room for the inline approval card (it sits between the message thread bottom and the composer top, with deliberate top/bottom margins so the card visually associates with the composer it replaces, not the most-recent message).ui/src/styles/chat.css(+1) — single@importline wiringplan-cards.cssinto the chat bundle.ui/src/ui/chat/slash-command-executor.ts(+374) +.node.test.ts(+160) —/plan on|off|statusslash-command handlers; pre-existing executor stub gets the plan-mode action arms. Tests pin the patch shape ({ planMode: "plan" | "normal" }) and the chip-state side effects.ui/src/ui/chat/slash-commands.ts(+12) — registers/planin the slash-command catalog so it autocompletes from the/menu.ui/src/ui/chat/grouped-render.test.ts(+309 / -79) — extends the grouped-render fixture set with plan-card cases so the message-thread renderer's grouping logic correctly de-dupes consecutive plan events into a single rendered card.ui/src/ui/views/chat.ts(+477 / -106) — the integration host detailed above.ui/src/ui/app-render.ts+app-tool-stream.ts+app-chat.ts+app-view-state.ts+app.ts(~1300 lines net additions) — wires plan-approval-request lifecycle into the top-level chat app: stream-side detection ofexit_plan_mode/ask_user_questionevents, view-state shape extensions for the approval-card local state (revise textarea, "Other…" textarea), patch-and-resume sequencing on approve/answer/reject.Accessibility + i18n
Accessibility — six concrete things, all asserted by tests:
:focus-visibleoutline on<summary>using accent tokenplan-cards.css:46-50aria-haspopup="menu"+aria-expandedtogglemode-switcher.ts:308-309; testmode-switcher.test.ts:308-311role="menu"(no false promise of WAI-ARIA contract)mode-switcher.ts:328-339; testmode-switcher.test.ts:331-334role="region"+aria-label="Plan approval"/"Agent question"plan-approval-inline.ts:79,:201mode-switcher.ts:384-402; tests:249-290plan-approval-inline.ts:113-121,:233-243i18n — exactly one new key carries the plan-mode UI work:
planViewToggle: "Toggle plan view sidebar", present in all 13 locale files (en,de,es,fr,id,ja-JP,ko,pl,pt-BR,tr,uk,zh-CN,zh-TW). The plan card / approval card / mode menu surface strings are not yet i18n'd in this PR — they're rendered from English literals in the Lit templates. This is intentional: extracting the approval/menu strings is a follow-up that depends on settling the user-facing copy after iter-3 of the umbrella. Tracking in #70101 carry-forward.Edge cases + review-debt receipts
These are the non-obvious behaviors hardened by previous review cycles on the umbrella that survive into this PR. Calling them out so a reviewer doesn't unwittingly "simplify" them away:
onPlanApprovalDecisionnot wiredchat.ts:1444-1450chat.ts:1399-1411onAnswerOptionhandlerdisabled+ visible warning banner ("plan-approval-inline.ts:196-222(execSecurity, execAsk)combo not in the preset tablemode-switcher.ts:259-278; test:77-86planAutoApprovewhileplanMode !== "plan"mode-switcher.ts:243-258; test:49-55<input>.shadowRoot.activeElementrecursively (depth ≤ 32) and bails.mode-switcher.ts:384-402; tests:249-290mode-switcher.ts:406-408; test:136-138plan-approval-inline.ts:98-102; test:133-150window.promptcancel would have).plan-approval-inline.ts:237-243plan-approval-inline.ts:73-77; test:47-68<details>marker::-webkit-details-markerand::markersuppressed; without the latter, Firefox doubles the disclosure triangle alongside the custom chevron.plan-cards.css:25-33clean()strips newlines + trims so bullet structure doesn't break in clipboard markdown.plan-cards.ts:101-122Test coverage matrix
plan-cards.test.tsmode-switcher.test.tsresolveCurrentModederivation matrix incl. Plan/Plan ⚡/Custom; Ctrl+1..6 modifier-exclusion matrix; Shadow-DOM focus guard depth 1+2; render assertions for chip + menu + active-class; non-claim ofrole="menu"plan-resume.node.test.tschat.sendcall shape —deliver: false,plan-resume-idempotency-key prefix, "continue" message (load-bearing for runtime correlation in 2/6)plan-approval-inline.test.tsAll tests use
vitestwith jsdom (@vitest-environment jsdom) and assert against real rendered DOM rather than snapshot strings, so a CSS-class rename or template restructuring doesn't accidentally pass. The test-LoC ratio (1067 / 873 ≈ 1.22×) is in line with the umbrella's UI test density.Styling + CSS tokens
The new components use existing design tokens rather than introducing new color variables — every
var()call falls back to a sensible literal so the components render correctly on a fresh checkout before theme tokens are loaded:--accent#6366f1(indigo)--border--card,--bg-hover#1a1a2e,rgba(255,255,255,.04)--text-secondary#a0a0b0--radius-md8pxThe accent-bordered-left treatment on plan cards (3px left border, 1px elsewhere) deliberately mirrors
tool-cards.cssso plan cards visually associate with tool output rather than reading as a separate UI surface. The approval card uses a warmer surface (caller-supplied) with a danger-button variant for Revise —--dangerfor the destructive action remains the standard system token.The new layout adjustments in
layout.css(+228 lines) carve out vertical space for the inline approval card by:.plan-inline-cardis present.Parity benchmark callout
Earlier prompt-parity benchmarking (same prompts hit OpenClaw + Codex + Claude Code) measured ~90% parity on response quality and ~95% parity on session lengths across the corpus, on top of plan mode landing. For UI specifically, two patterns in this PR are deliberate convergences:
plan-approval-inline.ts:1-14documents the parity intent.The convergences are about UX expectations (users coming from those tools find familiar surfaces) rather than implementation lifting — the underlying state model (plan as its own dimension coexisting with permission mode, the synthesized "Custom" mode for non-preset combos) is OpenClaw-specific and has no analogue in either source.
What a reviewer can verify in <30 min
Concrete checklist that exercises every load-bearing surface in this PR. Assumes the merge order is 1/6 → 2/6 → 3/6 → 4/6 (or that you're reviewing on the FULL bundle):
pnpm devor equivalent; navigate to/chat.update_planevent should render an expandable<details>card in the thread; click the summary to expand; verify1/3 donestyle meta updates as the agent runs.exit_plan_mode, the inline approval card appears ABOVE the composer. Composer input hides. Click "Open plan" — the right sidebar opens with the formatted markdown. Click "Revise" — inline textarea opens; type feedback; Cmd+Enter submits; card vanishes; agent receives the revision viapendingAgentInjectionsand continues in plan mode.chat.sendfires withdeliver: false+idempotencyKey: "plan-resume-<uuid>"(this is theplan-resume.tsprimitive). Agent run resumes; no synthetic "continue" bubble appears in the transcript.AskUserQuestion-capable agent, ask something that triggers the tool. The same approval-card shell renders with one button per option (and Other… ifallowFreetext: true). Click an option; verify thesessions.patch { planApproval: { action: "answer", answer: <text> } }fires.ja-JPorzh-CN; verify the plan-view toggle tooltip uses the localized string fromplanViewToggle.Total: ~25 min for a full happy-path + offline + question + i18n sweep.
Suggested commands for reviewers
The four-file test command above runs in <2s on a warm vitest cache and is the tightest smoke check that exercises every component-level invariant in this PR.
What This PR Does NOT Include
/planslash commands across text channels, Telegram attachment delivery) →[Plan Mode 5/6] Text channels + Telegram[Plan Mode AUTOMATION]([Plan Mode AUTOMATION] Cron nudges + auto-enable + subagent follow-ups #70089) + bundled in[Plan Mode FULL]([Plan Mode FULL] Integrated bundle for testing (Parts 1\u20136 + automation + executing-state lifecycle) #70071)[Plan Mode 6/6] Docs, QA, and helptool-display.jsonplan-mode entries added in this PR but render their own approval cards.role="region"+aria-labelship here; full audit (screen-reader walkthrough, contrast verification on the danger button, focus-ring on the textarea) is a follow-up cycle in [Plan Mode] Master tracker for the 9-PR upstream rollout #70101.planViewTogglekey is i18n'd here; copy for the approval card buttons and the mode menu labels is still English literals, pending iter-3 copy-finalization.Carry-forward / deferred (tracked in #70101)
tool-display.jsonhere, but render their own approval cards. The native counterpart ofplan-approval-inline.tsis a follow-up; the contract (Accept / Accept-allow-edits / Revise + Open plan, plus the question variant with N options + Other) is settled by this PR and can be ported as-is.role="region"+aria-label) and keyboard contracts (focus-visible on the disclosure summary, Ctrl/Cmd+Enter to submit, Escape to cancel) ship here. A full screen-reader pass — including SR announcement of "approval pending" when the card appears, the contrast ratio of the danger-button variant on the dark card surface, and the focus-ring on the textarea — is a follow-up cycle.planViewTogglekey is i18n'd in this PR. The approval card buttons ("Accept", "Accept, allow edits", "Revise", "Send revision", "Send answer", "Back to options") and the mode-menu labels ("Default permissions", "Ask each mutation", etc.) are still English literals. Extraction is queued behind iter-3 copy-finalization so we don't churn translators with copy that may still change.tool-display.jsoncarry English titles; localized variants will land with the broader tool-display i18n pass tracked separately.Issue references
PlanModeSessionState+planApproval(2/6),AskUserQuestion+exit_plan_modeTier-1 contract (3/6)