Skip to content

[Plan Mode 4/6] Web UI + i18n#70068

Closed
100yenadmin wants to merge 1 commit into
openclaw:mainfrom
electricsheephq:isolated/pm-4-of-6-ui
Closed

[Plan Mode 4/6] Web UI + i18n#70068
100yenadmin wants to merge 1 commit into
openclaw:mainfrom
electricsheephq:isolated/pm-4-of-6-ui

Conversation

@100yenadmin

@100yenadmin 100yenadmin commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

📋 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.


📋 Stack position: This is [Plan Mode 4/6], the fourth part of a 6-PR per-part decomposition of the original umbrella #68939 (closed).

  • Previous in stack: [Plan Mode 3/6] Advanced plan interactions
  • Next in stack: [Plan Mode 5/6] Text channels + Telegram
  • Integration bundle: [Plan Mode FULL] — green-CI bundle of all parts + automation + executing-state lifecycle

⚠️ CI on this PR will be RED: this part adds UI components that reference plan-mode types (PlanModeSessionState, PlanStep) from [Plan Mode 1/6] + [Plan Mode 2/6]. CI will pass once earlier parts merge in order, OR review the green-CI integrated state in [Plan Mode FULL].

Ways to land this feature (maintainer choice):

  • Per-part review + sequential merge of 1/6 → 6/6
  • Single bundle merge via [Plan Mode FULL]

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 AskUserQuestion interactions, and (d) plan-resume wiring that sends a hidden chat.send after 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 via sessions.patch; the approval card writes via sessions.patch { planApproval: { action } }; resume sends chat.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 in views/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

  • Scope: 4 new UI components (plan-cards.ts, mode-switcher.ts, plan-resume.ts, plan-approval-inline.ts) + their CSS + their tests; integration into views/chat.ts, app-chat.ts, app-render.ts, app-tool-stream.ts; plan-mode entries in tool-display.json; one new i18n key (planViewToggle) across 13 locales.
  • i18n languages covered (13): en, de, es, fr, id, ja-JP, ko, pl, pt-BR, tr, uk, zh-CN, zh-TW. Plus the i18n cleanup deletions described below.
  • Accessibility: :focus-visible outline 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-expanded on the mode chip, role="region" + aria-label on the approval card, deliberate non-claim of role="menu" on the dropdown so the WAI-ARIA menu keyboard contract isn't falsely advertised (mode-switcher.ts:328-339).
  • Keyboard: Ctrl+1..6 mode shortcuts with a Shadow-DOM-aware focus guard that walks .shadowRoot.activeElement so Lit composers' inner inputs don't have their keystrokes stolen (mode-switcher.ts:384-402).
  • Offline-resilient: approval card disables every action button + surfaces a "Reconnect to resolve this plan. The approval stays pending while offline." banner when connected === false (plan-approval-inline.ts:98-102; test plan-approval-inline.test.ts:133-150).
  • Tests: 4 component test files (1067 LoC of tests for ~873 LoC of components — ~1.2× coverage by line count); all jsdom-rendered and assert real DOM state, not snapshot strings.

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.

graph TD
  Root["chat view (views/chat.ts)"]
  Root --> Header["chat header"]
  Root --> Thread["message thread"]
  Root --> Composer["composer area"]
  Root --> Sidebar["right sidebar"]

  Header --> ModeChip["<b>mode-switcher chip</b><br/>(mode-switcher.ts)"]
  ModeChip --> ModeMenu["<b>mode menu popover</b><br/>Default / Ask / Accept /<br/>Plan / Plan ⚡ / Bypass"]

  Thread --> ToolCards["tool-cards (existing)"]
  Thread --> PlanCard["<b>plan-cards.ts</b><br/>&lt;details&gt;/&lt;summary&gt; with<br/>per-step status markers"]

  Composer --> ApprovalCard["<b>plan-approval-inline.ts</b><br/>shown ABOVE composer when<br/>planApprovalRequest != null"]
  ApprovalCard --> PlanVariant["plan variant:<br/>Accept / Accept-allow-edits / Revise"]
  ApprovalCard --> QuestionVariant["question variant (PR-10):<br/>1 button per option + Other…"]
  Composer --> Input["chat input (hidden when card open)"]

  Sidebar --> PlanPane["plan pane (formatted via<br/>formatPlanAsMarkdown())"]

  classDef new fill:#1e293b,stroke:#6366f1,stroke-width:2px,color:#e2e8f0
  class ModeChip,ModeMenu,PlanCard,ApprovalCard,PlanVariant,QuestionVariant new
Loading

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)
Loading

The resume primitive is one function: resumePendingPlanInteraction(client, sessionKey) at ui/src/ui/chat/plan-resume.ts:11-21. Three things matter about its shape:

  1. 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.
  2. idempotencyKey: "plan-resume-<uuid>" — the plan-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 the pendingAgentInjections drain logic.
  3. Pure UI primitive — no decision logic lives here. The function is dumb: it fires the resume RPC. The decision-making (when to call it) belongs to the host views/chat.ts, which fires it after the sessions.patch for an approval/answer resolves.

The single test (plan-resume.node.test.ts:9-26) pins the contract: the call shape is chat.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 in resolveCurrentMode() at mode-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 note
Loading

The state machine has three load-bearing rules:

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:

  • +1 line: the new planViewToggle: "Toggle plan view sidebar" plan-mode key (this is the actual plan-mode work)
  • -30 lines: deletions of unused keys like 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.json totals/hashes to match the .ts content; 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:

  1. Accept as-is — net effect on main is identical to landing the plan-mode key separately. ~518 LoC of cleanup is a bonus side effect.
  2. Pre-merge: revert the deletions on this branch (keep just the planViewToggle addition) and ship the deletions in a follow-up housekeeping PR after this rolls out. We'd need a pnpm ui:i18n:regen step (or its equivalent) to keep .meta.json consistent.

Tracking either decision in umbrella #70101.

How it wires into views/chat.ts

The 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 at chat.ts:1382-1456 is the contract worth eyeballing:

${props.planApprovalRequest &&
props.planApprovalRequest.sessionKey === activeSession?.key &&
props.onPlanApprovalDecision
  ? renderInlinePlanApproval({
      request: props.planApprovalRequest,
      connected: props.connected,
      busy: props.planApprovalBusy ?? false,
      // … 17 props total covering:
      //   - plan-variant: onApprove / onAcceptWithEdits / onReviseOpen / …
      //   - question-variant (PR-10): onAnswerOption
      //   - "Other…" textarea (PR-13 Bug 2): questionOtherOpen / Draft / handlers
      //   - sidebar handoff: onOpenPlan
      onReviseSubmit: () => {
        const draft = (props.planApprovalReviseDraft ?? "").trim();
        // Codex P2 review #68939 (2026-04-19): block empty client-side
        // submits — the wire schema's reject variant requires
        // feedback: minLength: 1, so empty would produce a confusing
        // server-side validation error. The textarea stays visible.
        if (!draft) return;
        void props.onPlanApprovalDecision!("reject", draft);
      },
      // …
    })
  : nothing}

<!-- PR-7 review fix (Copilot #3105170553 / #3105219639):
     hide the input only when BOTH planApprovalRequest AND
     onPlanApprovalDecision are present. Otherwise the user would see
     neither the card (which requires the handler) nor the input. -->
${props.planApprovalRequest && /* … */ props.onPlanApprovalDecision
  ? nothing
  : html`<div class="agent-chat__input">…composer…</div>`}

Three things to notice in that block, all of which are review-debt receipts rather than fresh design choices:

  1. sessionKey === activeSession?.key gate — the same planApprovalRequest could (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.
  2. Empty-revise client-side block — the wire schema's reject variant requires 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.
  3. Both-or-neither input visibility — the original implementation hid the input whenever a planApprovalRequest was present. Copilot review #3105170553 / #3105219639 flagged that if the host forgets to wire onPlanApprovalDecision, 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:

return renderModeSwitcher({
  currentMode: resolveCurrentMode(
    activeSession?.execSecurity,
    activeSession?.execAsk,
    activeSession?.planMode,
    activeSession?.planAutoApprove,
  ),
  menuOpen: props.modeMenuOpen,
  onToggleMenu: props.onToggleModeMenu,
  onSelectMode: props.onSelectMode,
});

— derivation runs every render, so the chip stays in sync with whatever sessions.patch events 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) and formatPlanAsMarkdown(plan).

ShapePlanCardData = { title, explanation?, steps: PlanCardStep[], source? }; PlanCardStep = { text, status: "pending"|"in_progress"|"completed"|"cancelled", activeForm? }. Status-marker glyphs at plan-cards.ts:23-28 are deliberately monospaced-friendly (⬚ ⏳ ✅ ❌) so terminal-style operators reading raw markdown via the sidebar's "Copy as markdown" still get a parseable checklist.

Markdown formatterformatPlanAsMarkdown() at plan-cards.ts:101-122 renders for the right-sidebar pane and clipboard export. Cancelled steps render ~~strikethrough~~ (cancelled); in-progress steps render **bold** (in progress) and use activeForm (the ongoing-tense label, e.g. "Building artifacts") instead of text ("Build artifacts"). All step text passes through a single newline-stripping clean() so multi-line text from the agent doesn't break the bullet structure.

<details>/<summary> rendering — the summary shows <plan-icon> <title> <N/M done | N steps> <chevron>. Native ::-webkit-details-marker and Firefox's ::marker are both suppressed (plan-cards.css:25-33) so the custom chevron isn't doubled. Focus-visible outline on the summary at plan-cards.css:46-50 (Copilot review fix from umbrella #68939).

Test coverageplan-cards.test.ts (159 LoC). Splits into a formatPlanAsMarkdown group (markdown-shape assertions including the activeForm-shadowing edge case at line 47-51) and a renderPlanCard (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 catalogMODE_DEFINITIONS at mode-switcher.ts:121-192 carries six entries: Default (Ctrl+1), Ask (Ctrl+2), Accept (Ctrl+3), Plan (Ctrl+4), Plan ⚡ (Ctrl+5), Bypass (Ctrl+6). The Default entry has both execSecurity and execAsk undefined — the host treats undefined as "DELETE the per-session overrides via patch", so picking Default returns the session to whatever the operator configured at agents.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 why planMode is NOT mapped onto execSecurity: plan mode needs read-only exec for research, so blocking exec via execSecurity=allowlist would defeat its purpose. Plan mode + permission mode coexist, and resolveCurrentMode lets plan WIN for display purposes only.

Shadow-DOM-aware focus guardgetDeepActiveElement() at mode-switcher.ts:384-402 walks document.activeElement.shadowRoot.activeElement recursively (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-339 explains why the dropdown does NOT declare role="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 coveragemode-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 sandbox deny regression), 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 variantrenderInlinePlanApproval() at plan-approval-inline.ts:53-175. Buttons map to sessions.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 fallbackplan-approval-inline.ts:73-77: when the agent's exit_plan_mode call carries an explicit title (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 at plan-approval-inline.test.ts:47-68.

Question variantrenderInlineQuestion() at plan-approval-inline.ts:183-306. Same shell, different actions row. Carries a defensive guard: if the host forgets to wire onAnswerOption, the buttons render as disabled and 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 at plan-approval-inline.test.ts:133-150 (plan) and plan-approval-inline.test.ts:181-210 (question).

"Other…" textarea state — PR-13 Bug 2 fix: caller owns questionOtherOpen / questionOtherDraft so the textarea state survives across re-renders, and Escape returns to the option list (instead of dismissing the entire card, which a window.prompt-based implementation would have done). Test at plan-approval-inline.test.ts:248-294.

Test coverageplan-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 carries detailKeys so 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 own tool-display-config.ts mirror — 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-visible outline 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 @import line wiring plan-cards.css into the chat bundle.
  • ui/src/ui/chat/slash-command-executor.ts (+374) + .node.test.ts (+160) — /plan on|off|status slash-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 /plan in 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 of exit_plan_mode / ask_user_question events, 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:

Concern Implementation Evidence
Keyboard focus on plan card :focus-visible outline on <summary> using accent token plan-cards.css:46-50
Mode-chip semantics aria-haspopup="menu" + aria-expanded toggle mode-switcher.ts:308-309; test mode-switcher.test.ts:308-311
Honest dropdown ARIA Deliberately omit role="menu" (no false promise of WAI-ARIA contract) mode-switcher.ts:328-339; test mode-switcher.test.ts:331-334
Approval card landmark role="region" + aria-label="Plan approval" / "Agent question" plan-approval-inline.ts:79, :201
Keyboard composer protection Shadow-DOM-aware focus guard for Ctrl+1..6 mode-switcher.ts:384-402; tests :249-290
Revise textarea keyboard contract Ctrl/Cmd+Enter submits, Escape cancels (does NOT dismiss card) plan-approval-inline.ts:113-121, :233-243

i18n — 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:

Edge case Behavior Provenance
Approval card visible but onPlanApprovalDecision not wired Composer stays visible (was: BOTH hidden, leaving the user with no surface to interact). Copilot #3105170553 / #3105219639; chat.ts:1444-1450
Empty Revise submit Client-side short-circuit; textarea remains visible for the user to type into. Codex P2 #68939 (2026-04-19); chat.ts:1399-1411
Question card with missing onAnswerOption handler Buttons render disabled + visible warning banner ("⚠️ Question handler not wired…"). Copilot #3104741709; plan-approval-inline.ts:196-222
(execSecurity, execAsk) combo not in the preset table Synthesizes a "Custom" mode entry instead of silently mislabeling as Ask. PR #67721; mode-switcher.ts:259-278; test :77-86
Pre-armed planAutoApprove while planMode !== "plan" Chip displays the underlying permission mode; the auto-approve flag is meaningful only AFTER planMode is "plan". mode-switcher.ts:243-258; test :49-55
Ctrl+1..6 with focus inside a Web Component's inner <input> Focus guard walks .shadowRoot.activeElement recursively (depth ≤ 32) and bails. mode-switcher.ts:384-402; tests :249-290
Cmd+1 on macOS (browser tab switch) Returns null; modifier-exclusion matrix accepts ONLY bare Ctrl. mode-switcher.ts:406-408; test :136-138
Plan-approval card while disconnected Every action button disabled + banner ("Reconnect to resolve this plan…"). The approval persists server-side; user can resolve after reconnect. plan-approval-inline.ts:98-102; test :133-150
"Other…" textarea Escape Returns to the option list (does NOT dismiss the entire card the way a window.prompt cancel would have). PR-13 Bug 2; plan-approval-inline.ts:237-243
Generic "Plan approval requested" boilerplate title Falls back to "Agent proposed a plan"; honors agent-supplied Tier-1 titles when distinct. PR-9 Tier 1; plan-approval-inline.ts:73-77; test :47-68
Firefox vs Chromium <details> marker BOTH ::-webkit-details-marker and ::marker suppressed; without the latter, Firefox doubles the disclosure triangle alongside the custom chevron. plan-cards.css:25-33
Multi-line agent-supplied step text in markdown export clean() strips newlines + trims so bullet structure doesn't break in clipboard markdown. plan-cards.ts:101-122

Test coverage matrix

File Tests LoC ratio Coverage focus
plan-cards.test.ts 16 cases 1.30× Markdown formatter (status mapping, activeForm shadowing); jsdom render (DOM shape, status classes, meta line, explanation conditional)
mode-switcher.test.ts 28 cases 0.92× resolveCurrentMode derivation 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 of role="menu"
plan-resume.node.test.ts 1 case 1.24× Pins the chat.send call shape — deliver: false, plan-resume- idempotency-key prefix, "continue" message (load-bearing for runtime correlation in 2/6)
plan-approval-inline.test.ts 10 cases 0.96× Both variants (plan + question); button wiring; revise-editor draft + keyboard (Cmd+Enter, Escape); offline disable + banner; missing-handler warning; "Other…" textarea submit/cancel

All tests use vitest with 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:

Token Used by Fallback
--accent plan card border, focus-visible outline, plan icon color, mode chip active state #6366f1 (indigo)
--border plan card outer border (theme-defined)
--card, --bg-hover plan card background, summary hover #1a1a2e, rgba(255,255,255,.04)
--text-secondary plan card meta line #a0a0b0
--radius-md plan card border radius 8px

The accent-bordered-left treatment on plan cards (3px left border, 1px elsewhere) deliberately mirrors tool-cards.css so 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 — --danger for 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:

  • Reserving a min-height region above the composer that grows when .plan-inline-card is present.
  • Setting the composer's bottom-anchor offset to account for the card's measured height (CSS-only, no JS measurement).
  • Ensuring the card doesn't overlap the message thread's scroll-bottom indicator (the "↓ New messages" jump button) by giving it a stacking-context that sits beneath that floating affordance.

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:

  • Inline plan-approval card — the "card above the composer with Accept / Accept-allow-edits / Revise" shape mirrors Claude Code's web UX. The Revise → inline textarea (rather than popup) is also lifted from there. Header comment at plan-approval-inline.ts:1-14 documents the parity intent.
  • Mode-switcher chip — the chip-with-dropdown in the chat header matches Codex's run-mode toggle pattern (single chip showing current mode, dropdown to change, kbd shortcuts displayed in the menu). The 6-mode catalog (Default/Ask/Accept/Plan/Plan ⚡/Bypass) is OpenClaw-specific — the Plan ⚡ entry is the PR-10 auto-approve variant which is novel to this stack.

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):

  1. Spin up the gateway + open webchat (~3 min) — pnpm dev or equivalent; navigate to /chat.
  2. Mode chip renders (~2 min) — chip should show "Default" with the shield icon. Click it; menu should show all 6 modes with Ctrl+1..6 hints. Press Escape; menu closes. Ctrl+4 (with focus NOT in the composer); chip switches to "Plan" with the checkmark-checkbox icon.
  3. Focus guard works (~2 min) — click into the composer; type "ctrl+4" (literally). Should type the characters, NOT switch modes. (Exercises the Shadow-DOM-aware guard.)
  4. Plan card renders inline (~5 min) — with a plan-mode-capable agent, send "make a 3-step plan to refactor the login component". The agent's update_plan event should render an expandable <details> card in the thread; click the summary to expand; verify 1/3 done style meta updates as the agent runs.
  5. Approval card flow (~5 min) — when the agent calls 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 via pendingAgentInjections and continues in plan mode.
  6. Plan resume on reconnect (~5 min) — with a plan approval pending, kill the gateway. Card buttons disable; banner shows "Reconnect to resolve this plan…". Restart the gateway. Buttons re-enable. Click Accept; verify in network tab that chat.send fires with deliver: false + idempotencyKey: "plan-resume-<uuid>" (this is the plan-resume.ts primitive). Agent run resumes; no synthetic "continue" bubble appears in the transcript.
  7. Question variant (~3 min) — with an AskUserQuestion-capable agent, ask something that triggers the tool. The same approval-card shell renders with one button per option (and Other… if allowFreetext: true). Click an option; verify the sessions.patch { planApproval: { action: "answer", answer: <text> } } fires.
  8. i18n spot-check (~2 min) — switch the UI locale to (say) ja-JP or zh-CN; verify the plan-view toggle tooltip uses the localized string from planViewToggle.

Total: ~25 min for a full happy-path + offline + question + i18n sweep.

Suggested commands for reviewers

# Run the four new component test files in isolation:
pnpm --filter ui test -- ui/src/ui/chat/plan-cards.test.ts \
                        ui/src/ui/chat/mode-switcher.test.ts \
                        ui/src/ui/chat/plan-resume.node.test.ts \
                        ui/src/ui/views/plan-approval-inline.test.ts

# Sanity-check the i18n cleanup (verify deleted keys are genuinely unreferenced):
pnpm ui:i18n:check

# Spin up the gateway + open webchat for the manual smoke pass:
pnpm dev   # then open http://localhost:<gateway-port>/chat

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

Carry-forward / deferred (tracked in #70101)

  • Mobile (iOS/macOS/Android) plan UI — native apps consume the plan-mode entries added to tool-display.json here, but render their own approval cards. The native counterpart of plan-approval-inline.ts is 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.
  • Accessibility audit — basic landmarks (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.
  • i18n extraction of approval-card / mode-menu strings — only the planViewToggle key 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 strings i18n — the 5 plan-mode entries in tool-display.json carry English titles; localized variants will land with the broader tool-display i18n pass tracked separately.

Issue references

@greptile-apps

greptile-apps Bot commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This 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 (handlePlanApprovalEvent, maybeForwardMergedPlanEvent) is careful.

  • P1 (app.ts:299): resumePendingPlanInteraction is called inside the same try block as sessions.patch. A transient network failure in the resume call after a successful patch causes the else-branch to restore the approval card with "Plan approval failed" — even though the approval already landed server-side. The fix is to give the resume call its own error handler so it can't masquerade as a patch failure.

Confidence Score: 4/5

One 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

Comments Outside Diff (1)

  1. ui/src/ui/app.ts, line 299 (link)

    P1 Resume failure incorrectly surfaces as plan-approval failure

    resumePendingPlanInteraction shares the same try block as the sessions.patch call. If sessions.patch succeeds (approval lands server-side) but resumePendingPlanInteraction throws a transient network error, none of the specialised error-code branches match (staleStateError, subagentBlockedError, gateStateUnavailableError are all false), so the else branch fires and restores the approval card with "Plan approval failed: <network error>". The approval has already been committed server-side, so clicking Accept again will produce a stale-approvalId rejection followed by a second dismiss — confusing and requiring an extra click to clear.

    The resume call should have its own try/catch (or be moved outside this block) so a transient send failure doesn't masquerade as a plan-approval failure.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: ui/src/ui/app.ts
    Line: 299
    
    Comment:
    **Resume failure incorrectly surfaces as plan-approval failure**
    
    `resumePendingPlanInteraction` shares the same `try` block as the `sessions.patch` call. If `sessions.patch` succeeds (approval lands server-side) but `resumePendingPlanInteraction` throws a transient network error, none of the specialised error-code branches match (`staleStateError`, `subagentBlockedError`, `gateStateUnavailableError` are all false), so the `else` branch fires and restores the approval card with `"Plan approval failed: <network error>"`. The approval has already been committed server-side, so clicking Accept again will produce a stale-approvalId rejection followed by a second dismiss — confusing and requiring an extra click to clear.
    
    The resume call should have its own `try/catch` (or be moved outside this block) so a transient send failure doesn't masquerade as a plan-approval failure.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: ui/src/ui/app.ts
Line: 299

Comment:
**Resume failure incorrectly surfaces as plan-approval failure**

`resumePendingPlanInteraction` shares the same `try` block as the `sessions.patch` call. If `sessions.patch` succeeds (approval lands server-side) but `resumePendingPlanInteraction` throws a transient network error, none of the specialised error-code branches match (`staleStateError`, `subagentBlockedError`, `gateStateUnavailableError` are all false), so the `else` branch fires and restores the approval card with `"Plan approval failed: <network error>"`. The approval has already been committed server-side, so clicking Accept again will produce a stale-approvalId rejection followed by a second dismiss — confusing and requiring an extra click to clear.

The resume call should have its own `try/catch` (or be moved outside this block) so a transient send failure doesn't masquerade as a plan-approval failure.

How can I resolve this? If you propose a fix, please make it concise.

---

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.

---

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.

Reviews (1): Last reviewed commit: "feat(plan-mode): split PR6 web UI and i1..." | Re-trigger Greptile

Comment thread ui/src/ui/app.ts
Comment on lines +134 to +141
/**
* 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.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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("_", "-")}`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Suggested change
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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread ui/src/ui/app.ts
Comment on lines +1348 to +1349
if (!snapshot || snapshot.length === 0) {
return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 /plan local 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).

Comment on lines +236 to +243
// 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;

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread ui/src/i18n/locales/en.ts
Comment on lines 193 to 200
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",
},

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
chat: {
disconnected: "已斷開與網關的連接。",
refreshTitle: "刷新聊天數據",
planViewToggle: "Toggle plan view sidebar",

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
planViewToggle: "Toggle plan view sidebar",
planViewToggle: "切換計畫檢視側邊欄",

Copilot uses AI. Check for mistakes.
Comment thread ui/src/i18n/locales/tr.ts
chat: {
disconnected: "Gateway bağlantısı kesildi.",
refreshTitle: "Sohbet verilerini yenile",
planViewToggle: "Toggle plan view sidebar",

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
planViewToggle: "Toggle plan view sidebar",
planViewToggle: "Plan görünümü kenar çubuğunu aç/kapat",

Copilot uses AI. Check for mistakes.
Comment thread ui/src/i18n/locales/ko.ts
chat: {
disconnected: "Gateway와 연결이 끊어졌습니다.",
refreshTitle: "채팅 데이터 새로고침",
planViewToggle: "Toggle plan view sidebar",

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
planViewToggle: "Toggle plan view sidebar",
planViewToggle: "플랜 보기 사이드바 전환",

Copilot uses AI. Check for mistakes.
Comment thread ui/src/ui/app.ts
Comment on lines +134 to +138
/**
* 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.

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread ui/src/i18n/locales/uk.ts
chat: {
disconnected: "Відключено від шлюзу.",
refreshTitle: "Оновити дані чату",
planViewToggle: "Toggle plan view sidebar",

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
planViewToggle: "Toggle plan view sidebar",
planViewToggle: "Перемкнути бічну панель перегляду плану",

Copilot uses AI. Check for mistakes.
Comment thread ui/src/i18n/locales/pl.ts
chat: {
disconnected: "Rozłączono z Gateway.",
refreshTitle: "Odśwież dane czatu",
planViewToggle: "Toggle plan view sidebar",

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
planViewToggle: "Toggle plan view sidebar",
planViewToggle: "Przełącz pasek boczny widoku planu",

Copilot uses AI. Check for mistakes.
Comment thread ui/src/i18n/locales/es.ts
chat: {
disconnected: "Desconectado de la puerta de enlace.",
refreshTitle: "Actualizar datos del chat",
planViewToggle: "Toggle plan view sidebar",

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
planViewToggle: "Toggle plan view sidebar",
planViewToggle: "Alternar barra lateral de vista del plan",

Copilot uses AI. Check for mistakes.
Comment thread ui/src/i18n/locales/de.ts
chat: {
disconnected: "Verbindung zum Gateway getrennt.",
refreshTitle: "Chat-Daten aktualisieren",
planViewToggle: "Toggle plan view sidebar",

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
planViewToggle: "Toggle plan view sidebar",
planViewToggle: "Planansicht-Seitenleiste ein-/ausblenden",

Copilot uses AI. Check for mistakes.
@100yenadmin

Copy link
Copy Markdown
Contributor Author

Acknowledging Greptile / Copilot / Codex review feedback on this PR. Triage status:

  • Stack-coordination concerns (e.g. unresolved imports referencing symbols added by earlier per-part PRs in the stack) — expected by design. Red CI on this PR is per the rollout plan; CI turns green as the chain merges in sequence (1/6 → 6/6). See the stack-position header banner at the top of this PR body.
  • Real source-code issues flagged here (P1 bugs, P2 nits, etc.) — will be triaged + fixed in a focused follow-up cycle within ~24h. Fix SHAs will be posted as in-line replies on each individual comment per the standard pr-review-loop pattern.

For end-to-end integration testing in the meantime, see #70071 [Plan Mode FULL] — the green-CI integrated bundle of all parts.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread ui/src/ui/app-render.helpers.ts Outdated
Comment on lines +47 to +50
state: Pick<AppViewState, "settings" | "password">,
) {
return resolveControlUiAuthToken(state);
return (
normalizeOptionalString(state.settings.token) ?? normalizeOptionalString(state.password) ?? null

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread ui/src/ui/app-chat.ts Outdated
const url = buildAvatarMetaUrl(host.basePath, agentId);
try {
const res = await fetch(url, { method: "GET", ...(headers ? { headers } : {}) });
const res = await fetch(url, { method: "GET" });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread ui/src/ui/app.ts
Comment on lines +1009 to +1011
await this.client.request("sessions.patch", params);
await resumePendingPlanInteraction(this.client, active.sessionKey);
} catch (err) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Cherry-pick / file-copy of 48 files from feat/plan-channel-parity-merged-upstream
(merged-head 651a22d) onto current upstream/main. Branch: isolated/pm-4-of-6-ui.

v4 absorbs the upstream merge into plan-mode dev (Option B; merge commit
b1ead74) plus the post-merge build/test fixes (commit 651a22d).
@100yenadmin
100yenadmin force-pushed the isolated/pm-4-of-6-ui branch from f0195c2 to 27c24da Compare April 22, 2026 14:36

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread ui/src/ui/app-render.ts
Comment on lines +2468 to +2469
const md = `# ${request.summary || "Proposed plan"}\n\n${stepLines}\n`;
state.handleOpenSidebar({ kind: "markdown", content: md });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread ui/src/ui/app.ts
Comment on lines +1232 to +1234
this.planApprovalQuestionOtherOpen = false;
this.planApprovalQuestionOtherDraft = "";
await this.handlePlanApprovalAnswer(draft);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@prtags

prtags Bot commented Apr 23, 2026

Copy link
Copy Markdown

Related work from PRtags group ace-bullfrog-hw3e

Title: Open PR candidate: plan-mode carve-out overlaps integrated full bundle

Number Title
#70031 [Plan Mode 1/6] Plan-state foundation
#70066 [Plan Mode 2/6] Core backend MVP
#70067 [Plan Mode 3/6] Advanced plan interactions
#70068* [Plan Mode 4/6] Web UI + i18n
#70069 [Plan Mode 5/6] Text channels + Telegram
#70070 [Plan Mode 6/6] Docs, QA, and help
#70071 [Plan Mode FULL] Integrated bundle for testing (Parts 1\u20136 + automation + executing-state lifecycle)
#70088 [Plan Mode INJECTIONS] Typed pending-injection queue foundation
#70089 [Plan Mode AUTOMATION] Cron nudges + auto-enable + subagent follow-ups

* This PR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants