Skip to content

fix(web): answer agent questions with a plain text composer

MacroscopeApp / Macroscope - UI Consistency failed Aug 21, 2026 in 13m 10s

UI Consistency: 1 issue found

1 finding

  • apps/web/src/components/ComposerPromptEditor.tsx (line 1879): adding the mode to the LexicalComposer key remounts the editor whenever a pending question opens or closes, replacing the contentEditable element and dropping DOM focus. Nothing re-focuses afterwards, so a focused composer loses focus and caret on the flip (mobile also loses the on-screen keyboard). Previously the editor stayed mounted and kept focus across question start/end.

No Tailwind/CSS ownership, shared-primitive, theme-variant, or scroll/mask issues were introduced: the diff contains no class-string, selector, or global CSS changes, and the only rendered markup change is conditionally mounting the inline-token plugins.

Details

Note

Your check run agent prompt is: .macroscope/check-run-agents/ui-consistency.md
More information about how Check Run Agents work can be found in our Help Center.

Scope reviewed

  • apps/web/src/components/ComposerPromptEditor.tsx
  • apps/web/src/components/chat/ChatComposer.tsx
  • Directly affected consumers: apps/web/src/components/ChatView.tsx (pending-user-input wiring, focus helpers), apps/web/src/pendingUserInput.ts

Method

  • Traced plainText / plainAnswerMode through the editor's controlled-sync layout effect, cursor helpers, imperative handle, and the ChatComposer pending-question effects.
  • Checked all composer focus call sites in ChatView.tsx and ChatComposer.tsx for anything that would re-focus after the LexicalComposer key change; there is no mount-focus effect and no pending-input focus effect, so focus loss on the mode flip is unmitigated.
  • Verified no styling surface changed: no class strings, CSS variables, index.css rules, theme variants, or mask/scroll selectors are touched by the diff, so the Tailwind-ownership, theme-variant, MenuPopup width-defaulting, and virtualized-fade rules are not engaged.

Related observation (not a failure driver, outside changed lines)

ChatComposer.addTerminalContext still writes a collapsed cursor offset and a detected trigger (collapseExpandedComposerCursor / detectComposerTrigger) with no plainAnswerMode guard. That path is reachable from the terminal panel while a question is active, where the editor now interprets cursor offsets as raw. The value is clamped, so the worst case is a misplaced caret and a non-rendered trigger, and the path already mutated the draft rather than the answer before this PR; noted for context rather than reported inline.

Suggested verification gates for the changed behavior

  • Focused interaction test (or manual check) covering: composer focused -> question arrives -> focus/caret retained; answer submitted -> focus retained.
  • Web typecheck (already reported by the author).

🔍 Agent activity — 16 tool calls

💭 I'm examining the diff for the composer editor's plainText mode support, checking whether the unconditional call to clear the composer trigger could introduce a UI consistency regression compared to its previous conditional placement. The other changes—stable function references in dependency arrays, a render-time ref assignment, and plugin removal in plain mode—don't seem like real issues. I should pull up the actual files to confirm the context around that trigger-clearing call.

1. browse_code — apps/web/src/components/chat/ChatComposer.tsx

2. browse_code — apps/web/src/components/ComposerPromptEditor.tsx

💭 I'm spotting a mismatch between the condition used to decide plain-text mode for the editor and the condition used in onPromptChange—one checks for an active question with pending inputs, the other just checks that activePendingProgress isn't null, so they can diverge and cause the wrong code path to run. I'm also noting that the inline token paste plugin and chip selection plugin are disabled in plain mode, which makes sense since raw text doesn't need token conversion, but I want to verify that's intentional rather than an oversight. Looking deeper, the fallback path in readComposerSnapshot still expands collapsed cursors even in plain mode, which seems inconsistent since plain mode should use raw offsets throughout. More concerning, applyPromptReplacement still collapses/expands cursors and detects triggers even on the active pending-question path, which contradicts the stated goal that trigger menus never open in plain mode—even though composerMenuOpen itself is gated off, this leftover logic seems like a real inconsistency worth flagging. I'm also noticing the LexicalComposer key includes plainText, so toggling it remounts the editor and wipes undo history and focus — meaning after a question resolves, the composer could lose keyboard focus unless something explicitly refocuses it. I should check whether there's refocus logic and trace composerTrigger usage alongside focus handling to confirm.

3. git_tools — composerTrigger|composerMenuOpen|plainAnswerMode

4. git_tools — activePendingProgress

💭 I'm puzzled that the grep line numbers don't match what I saw when viewing the file, even though both should be referencing the same commit. That's unexpected since the revision should be identical. Setting that aside, I'm focusing on the actual logic: plainAnswerMode is true whenever activePendingProgress is non-null, but the answer-routing branch in onPromptChange only fires when activePendingProgress?.activeQuestion is set and pendingUserInputs.length > 0. This mismatch looks like the key issue — if activePendingProgress exists but activeQuestion is null, the plain answer mode flag would be true while the routing branch wouldn't trigger. I want to verify whether activeQuestion can actually be null in that state, so I'm going to check the type definition and the surrounding props around lines 520-660.

5. browse_code — apps/web/src/components/chat/ChatComposer.tsx

💭 I realize activeQuestion can be null within activePendingProgress, so plainAnswerMode could be true even when activeQuestion is null. I need to find where the parent computes activePendingProgress.

6. git_tools — activePendingProgress={|activePendingProgress =|activeQuestion:

7. browse_code — apps/web/src/components/ChatView.tsx

8. browse_code — apps/web/src/pendingUserInput.ts

💭 This mismatch only really surfaces for a pending user input with zero questions, which is a rare edge case — probably not worth flagging as a UI consistency issue, though it does mean typed text would go into the regular draft while the editor renders mentions as plain text. The more significant concern is the key change causing a full Lexical remount whenever plainAnswerMode flips, which would lose focus and undo history. I need to check whether the composer gets refocused when a question appears or resolves — if a question arrives mid-typing, this remount could interrupt the user's typing, which would be a real regression compared to the previous behavior where mode never flipped. I also want to check whether plugins like ComposerInlineTokenPastePlugin handle more than just token-specific behavior — if they manage general paste functionality and get dropped in plain mode, that could break paste for users in that state. I'm also tracing other callers of applyPromptReplacement beyond the menu selections, since slash commands, file drops, or mention insertions from elsewhere in the UI could still trigger it while plain answer mode is active, potentially miscalculating cursor position or setting an unwanted composer trigger. Let me check the editor render site and how terminalContexts prop is handled in plain mode.

9. browse_code — apps/web/src/components/chat/ChatComposer.tsx

10. git_tools — applyPromptReplacement(

💭 My grep for applyPromptReplacement came up empty, likely because the file has a null byte that stops grep early. I'm noting that plainAnswerMode is true whenever a pending user input exists, with the value set to customAnswer, and terminalContexts get cleared once pendingUserInputs has entries—so the plain mode logic lines up correctly. The real concern is focus behavior on remount: when a pending question arrives and plainText flips true, the LexicalComposer key changes, forcing the whole editor to unmount and remount. That means the DOM element gets replaced and focus is lost to the body, undo history resets since HistoryPlugin reinitializes, though the editorRef imperative handle reattaches fine. I want to check whether ChatView has compensating logic—something like scheduleComposerFocus—that restores focus when pendingUserInputs appear or resolve, since this focus loss on remount seems like the more significant finding here.

11. browse_code — In apps/web/src/components/ChatView.tsx and chat composer code, is there any effect that focuses the composer when a pending user input question appears or is resolved/answered (e.g. scheduleComposerFocus / focusComposer calls tied to pendingUserInputs, activePendingProgress, or advancing questions)? Show the relevant code with line numbers.

💭 I'm noting that before this PR, no remount occurred on plainText flip, so the composer retained focus when a question appeared or resolved—this remount-induced focus loss is a real interaction regression affecting users typing mid-flow. I'm now checking whether the remount is actually necessary, since it seems tied to how the plugin is structured. Actually, plugins are declarative React children that register/unregister on mount, so they could be swapped without remounting the whole composer. The real reason for the remount seems to be that the layout effect only rewrites editor content when value, contexts, or skills change—plainText alone doesn't trigger a rewrite, so they force a remount instead; a cleaner fix would be to add plainText as a tracked change in that effect so the editor state updates without remounting and losing focus. Focus loss on remount is real—when the contentEditable unmounts while focused, focus shifts to body, which would dismiss a mobile keyboard mid-typing. I'm also checking whether cursor-clamping functions and the skill metadata map recomputation are stable across renders, since unnecessary recreation there could compound the issue, though they seem mostly fine aside from minor perf overhead. Now I want to trace resetCursorState in ChatComposer to see whether it properly collapses the cursor position when in plain-text mode, since that's called externally from ChatView and could be another source of cursor/focus mismatch.

12. git_tools — resetCursorState

💭 I want to check how resetCursorState is implemented in ChatComposer, since the diff left it unchanged. Let me look near where the imperative handle is defined and search for detectTrigger usage there.

13. browse_code — In apps/web/src/components/chat/ChatComposer.tsx at commit 9f35647, show the full implementation of the imperative handle methods resetCursorState, insertTextAtEnd, addTerminalContext, and applyPromptReplacement (with line numbers), and note every place they call collapseExpandedComposerCursor / expandCollapsedComposerCursor / detectComposerTrigger. I want to know whether these paths can run while plainAnswerMode (activePendingProgress !== null) is true.

💭 I'm examining addTerminalContext and whether its handling of composerCursor during plainAnswerMode is a pre-existing behavior or something the new raw/collapsed cursor split has broken—previously the cursor was always collapsed so the editor's interpretation was consistent, but now that distinction matters. Actually, the more significant issue is the LexicalComposer remount when plainText flips, which causes genuine focus and undo-history loss—this looks like a real regression introduced by the diff, so I want to trace exactly when that flip occurs, starting with what happens when a question arrives and the composer unmounts. This matches the guidance on preserving focus rings, keyboard behavior, and interaction semantics during migrations, so a key-based remount that drops DOM focus and undo history is a legitimate regression worth flagging. A better fix would keep the composer mounted and handle the mode switch through the existing layout effect rather than forcing a remount, since React does tear down and recreate the contentEditable DOM whenever the key changes, shifting focus to the document body. I want to check whether there's any existing mitigation in the code for this before finalizing the finding. Tracing through ChatView, the focus-restoration call only fires on cursor mismatches during typing, not on the mode-flip effect itself, so when a question arrives mid-typing the user loses focus and potentially drops keystrokes — that's the concrete regression I want to flag, along with the secondary issue of undo/redo history resetting on remount. I also want to verify how the plain-text append function handles new content to make sure there isn't a related edge case there. I should double-check that the key line in ComposerPromptEditor.tsx is actually within the diff hunk so an inline comment there is valid — it is, since it's a changed line. I'll post one inline comment there about the remount causing focus/undo loss, without a diff suggestion since the fix spans multiple hunks. I was also considering a comment on the ChatComposer terminal-context addition, but that code is outside the diff range, so inline commenting there isn't possible. I should grep for a mobile pending answer input to see if there's a separate path, and check the PR description or tests to see if the remount focus issue was already considered.

14. github_api_read_only — repos/{owner}/{repo}/pulls/{pull_number}

15. browse_code — In apps/web/src, are there tests covering ComposerPromptEditor plainText mode or ChatComposer pending-question answer typing/focus behavior (e.g. files matching ComposerPromptEditor.test.tsx, ChatComposer tests)? Also: does anything re-focus the composer editor after the Lexical editor remounts (LexicalComposer key change)? List test files touching the composer editor.

💭 The undo-history reset seems intentional per the PR author's note, so I shouldn't flag that. But focus loss is a genuine side effect worth raising—when the composer is focused and a question arrives, or when the question resolves after submitting an answer, the DOM remount drops focus with nothing to restore it, and on mobile this closes the keyboard. A cleaner fix might avoid remounting altogether: instead of swapping the editor, dispatch CLEAR_HISTORY_COMMAND within the existing mount to reset undo state while preserving focus, satisfying both the isolation goal and the interaction-semantics requirement. I'm checking whether the layout effect would actually rewrite content just from the plainText flip alone, and confirming that without a remount the editor would need an explicit plainTextChanged flag to trigger re-tokenization. Given the focus-loss risk is a real regression tied to the PR's own scope around keyboard/interaction semantics, I'm leaning toward flagging this as a failing finding, pinned to the specific line in ComposerPromptEditor.tsx, while double-checking how the pre-PR behavior handled the same scenario to make sure this is a genuine regression and not pre-existing behavior. I'm also weighing whether to raise the secondary issue about addTerminalContext writing collapsed cursor offsets while plain mode expects raw offsets -- since that inconsistency is only made relevant by this patch's cursor-space split, I could attach it as a note on the nearby changed line rather than opening a separate comment, though I'm still deciding if the impact (a possibly misplaced caret) is significant enough to include. I'm drafting the actual comment text now: keying LexicalComposer on mode remounts the editor and drops DOM focus since nothing re-focuses after mount, whereas previously the editor stayed mounted and preserved focus/caret across question open/close — now users have to click back in, and on mobile the keyboard closes too. I'll also suggest that if the remount exists only to isolate undo history, there may be a lighter-weight fix.

16. modify_pr — UI consistency review: one interaction regression found in apps/web/src/components/ComposerPromptEditor.tsx (editor remount on mode flip drops composer focus). Details inline.


Agent Credits: 660 credits