Repository navigation
UI Consistency: 1 issue found
apps/web/src/components/ComposerPromptEditor.tsx (lines 1835-1844) — The new plainText plugin gate also disables ComposerSurroundSelectionPlugin, which is the only plugin in that group that is not token-specific. It implements the general "type a bracket/quote over a selection to wrap it" behavior (including dead-key handling), so pending-question answers lose a keyboard behavior they had before this PR (they were previously typed in the same rich editor). Suggested smallest fix: keep that plugin mounted in both modes and thread plainText into its $setComposerEditorPrompt / collapseExpandedComposerCursor calls, leaving only the token-dependent plugins mode-gated.
The focus-loss regression flagged on the previous revision (editor remount on the plainText flip dropping DOM focus) is now addressed by the restoreFocusOnRemountRef layout effect; commit-phase ordering (deletion cleanup before new-tree layout effects) makes the recorded focus ownership reliable, so it is not re-reported.
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.
Files examined: apps/web/src/components/ComposerPromptEditor.tsx, apps/web/src/components/chat/ChatComposer.tsx, plus context from apps/web/src/components/composerInlineTokenPaste.ts, apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx, apps/web/src/pendingUserInput.ts, and apps/mobile/src/features/threads/PendingUserInputCard.tsx.
Checks performed and cleared:
- No styling/primitive ownership changes: the diff touches no CSS, no
components/uiprimitives, and reuses the sameContentEditableclass string and placeholder markup in both modes, so theming, focus ring, hit target, and coarse-pointer font sizing are unchanged. - Token plugin gating is correct for the other five plugins:
ComposerInlineTokenArrow/SelectionNormalize/Backspace/PasteandComposerChipSelectionPluginall operate exclusively on mention/skill/terminal-context nodes.registerComposerInlineTokenPastebails out unless the clipboard text contains a mention token, so plain multi-line paste still goes throughPlainTextPlugin's default path. - Placeholder suppression (
terminalContexts.length > 0) is safe in plain mode because the call site passes[]while a pending input exists, so the "Type your own answer..." placeholder still renders. - Cursor mapping in plain mode collapses to raw clamps as documented, and the mode-derived helper identities are module-level functions, so the new effect/callback deps stay stable for a mount.
- Draft vs. answer split (
draftPromptRef) keeps trait toggles, stash restore, and external draft syncs from clobbering the answer text or caret, and the question-ended handoff restores token-aware cursor state from the draft ref.
Suggested verification gates for the changed behavior: typecheck plus a focused test covering the plainText contract (plugin gating, raw cursor clamping, and focus retention across the mode flip), since this PR changes state transitions and keyboard handling but adds no tests.
🔍 Agent activity — 25 tool calls
💭 The prior focus-loss issue on remount seems resolved now that a layout effect restores focus via a ref. I'm now checking for UI consistency issues in this plainText editor mode PR, looking at the focus restoration logic and whether there's a separate plain input implementation for mobile that needs similar treatment.
1. git_tools — plainText
2. git_tools — plainAnswerMode
💭 I should look at the relevant parts of ChatComposer.tsx to understand its structure.
3. browse_code — apps/web/src/components/chat/ChatComposer.tsx
4. browse_code — apps/web/src/components/chat/ChatComposer.tsx
💭 Now I'm examining the ComposerPromptEditor changes around lines 1540-1930 to understand that section's context.
5. browse_code — apps/web/src/components/ComposerPromptEditor.tsx
6. browse_code — apps/web/src/components/ComposerPromptEditor.tsx
💭 I'm checking the conditional prop passing looks fine, then examining the focus restoration effect for potential issues with how it behaves across mount and remount cycles. I'm tracing whether the cursor-setting effect runs before or after the editor remounts when switching from rich to plain answer mode, since the value prop source isn't entirely clear to me yet. I'm also noting that
focusAttriggers the change handler, which during plain answer mode writes to the custom answer—this could be fine, but I want to verify the focus-ownership handoff during remount cleanup doesn't cause a race. I'm also checking that the restore-focus ref only matters when the inner component remounts due to a key change, not when the whole composer unmounts, and confirming that child layout effects (ContentEditable mounting) run before the parent's layout effect that attempts to restore focus. Now I'm tracing whether plain mode preserves interaction semantics correctly — it keeps the home/end key plugin but drops the inline token plugins since plain mode has no tokens, and I'm checking whether terminal context chips still render properly when plain text mode appends text without chip nodes. I'm finding a concrete UI consistency issue: terminalContexts are still passed through in plain mode, and the placeholder gets suppressed whenever terminalContexts.length > 0, so if a user has terminal contexts attached when a question opens in plain mode, the editor renders empty with no visible placeholder text guiding them to answer.
7. browse_code — apps/web/src/components/chat/ChatComposer.tsx
💭 That terminal-context concern seems handled since terminalContexts is empty whenever pendingUserInputs is non-empty. Now I'm checking the value passed for customAnswer when active progress exists, and starting to look at how plainAnswerMode is determined. There's a subtle edge case where isComposerApprovalState being true alongside a non-null activePendingProgress could make value empty while plainText stays true, though these states are likely mutually exclusive in practice. I want to trace the remount focus restoration more carefully: when flipping from rich to plain mode, the key change unmounts the old editor and mounts a new one, triggering the cleanup that sets restoreFocusOnRemountRef if it was focused, then the new mount initializes its snapshot and calls focusAt, which in turn calls onChangeRef with the answer value — so I need to check what onPromptChange does in plain mode.
8. browse_code — apps/web/src/components/chat/ChatComposer.tsx
💭 I'm checking that the trigger-detection branch correctly resets the composer trigger within the pending-question path. Now I'm tracing a possible focus-restore issue: when a question resolves and the editor flips from plain back to rich mode, the pending-input-ended effect updates the prompt ref and cursor position, causing the editor to remount with the draft value, and I need to verify focus is properly restored afterward. I'm working through whether the remount render uses a stale cursor value from before the mode flip, since the state update happens in an effect that runs after the render. It seems like this resolves correctly on the next render once the controlled sync effect fires, but I want to confirm focusAt doesn't steal focus when the user wasn't already focused, and that the onChange call it triggers during the plain-to-rich transition doesn't cause unwanted side effects with the snapshot value. Now I'm checking whether the restore-focus ref correctly handles edge cases where the root element or document.activeElement might be null during unmount, and whether there's a separate mobile plain-input path I should account for. I'm also considering whether the plain mode editor's node registration for token plugins could cause any inconsistency even though it mounts without the inline token plugins themselves. I'm spotting a potential mismatch:
plainAnswerModeis derived fromactivePendingProgress !== null, but the actual pending-answer branch inonPromptChangerequires both an active question and non-empty pending inputs. So ifactivePendingProgressis set but those sub-conditions aren't met, the editor renders in plain mode while the change handler still falls through to the normal draft-update path, writing topromptRefandsetPromptinstead of the pending-answer state — a state mismatch worth flagging. It's an edge case that's hard to prove definitively. Checking the pending-input sync effect, the cursor offset logic only applies when customAnswer is a string, which is consistent with plainAnswerMode being true there, and the reset-on-thread-change logic also checks plainAnswerMode consistently. Now I want to look at the stash restore logic and the promptChanged handling further down in the diff.
9. browse_code — apps/web/src/components/chat/ChatComposer.tsx
10. browse_code — apps/web/src/components/chat/ChatComposer.tsx
💭 I'm checking how draftPromptRef gets initialized and kept in sync with the prompt value through that effect around line 1389.
11. browse_code — apps/web/src/components/chat/ChatComposer.tsx
💭 Now I'm tracing the reverse transition, plain to rich, when the question resolves. Since the effect at 1388 is declared before the one at 1466, it fires first, updating the draft ref and cursor based on the resolved prompt, and then the second effect overwrites the prompt ref and cursor again using the draft values, so I need to check which final state actually wins. Shifting focus, I'm now thinking about whether this PR falls within the scope of the UI consistency check at all, since it's mostly a state/behavior refactor in editor logic rather than styling or shared component changes, though interaction semantics like focus restoration and keyboard behavior are still fair game to flag — particularly whether the layout effect restores focus even when the editor isn't actually focused. I should check whether that plugin also governs general paste handling beyond mention tokens, since disabling it in plain mode could alter how multi-line text pastes. I'm also considering whether removing the chip selection and surround selection plugins in plain mode has any side effects, though those seem safe since plain mode has no chips, and checking that the placeholder logic correctly falls back when terminalContexts is empty.
12. browse_code — apps/web/src/components/ComposerPromptEditor.tsx
💭 The surround-selection plugin handles a general "type a quote with text selected → wrap it" convenience, unrelated to token logic. Disabling it in plain mode creates an inconsistency: selecting text and typing a quote wraps it in the draft composer but replaces it when a question is open, since the diff now conditionally disables a plugin that used to render unconditionally. This does look like a legitimate finding under preserving interaction semantics during migrations, since the same visible control now behaves differently across modes for no token-related reason, and the fix is small — thread the plainText flag through rather than hardcoding false. But I need to check whether simply re-enabling it in plain mode is actually safe, since the plugin calls a function that could tokenize mention-like text into chips even in plain mode, which is likely exactly why it was disabled — so the real fix requires passing plainText through rather than just flipping the condition. This is a real but minor behavior loss that the author may have accepted intentionally, so I should weigh how strongly to flag it. Let me check the other plugins disabled in plain mode to see if any lose non-token-specific behavior: the arrow-traversal, selection-normalize, and backspace plugins all look token-specific and fine, but I want to verify whether the paste plugin only handles tokens or also affects general plain-text pasting behavior like newlines.
13. git_tools — function Composer(InlineTokenPastePlugin|ChipSelectionPlugin|HomeEndKeyPlugin)
14. browse_code — apps/web/src/components/ComposerPromptEditor.tsx
💭 I should check whether registerComposerInlineTokenPaste handles general paste behavior, like converting file paths into mentions or dealing with plain text containing newlines.
15. browse_code — In apps/web/src, what does registerComposerInlineTokenPaste do (file, behavior)? Does it handle general plain-text paste behavior (e.g. inserting multi-line text, normalizing newlines) or is it strictly about mention/inline-token nodes? Show the implementation.
💭 The token paste plugin is clearly token-specific, so removing it in plain mode is correct. The remaining question is whether losing surround-selection typing (wrapping selection in quotes/brackets) counts as a real violation, since that plugin's only token dependency seems to be a specific composer prompt call rather than the core behavior itself. Actually, this does look like a genuine regression — before this PR, question answers used the same rich editor with surround typing enabled, and now the diff hides that plugin entirely behind the plain-mode check, silently dropping the behavior for answers. I'd flag this as an inline comment since it's a subtle but real keyboard-behavior inconsistency introduced by the changed lines. I also want to verify the claim about mobile using a plain input for the same reason, so I'm checking whether there's already a separate raw input or textarea used for pending answers on mobile. I'm also considering whether StrictMode's double-invoked effects could cause the focus-restore ref to misfire on first mount, but that seems like a minor dev-only edge case rather than a real bug. A more concrete scenario worth examining is what happens to cursor restoration when a question opens while the user is actively typing in the draft — the remount and focus restore would place the caret based on a snapshot that may no longer match the current state. Rich mode still relies on that CSS, so no dead styling there. I also notice refs being mutated during render in a couple places, which is a React anti-pattern but not a UI consistency issue per se. Weighing whether to flag the loss of "type a quote/bracket to wrap the selection" behavior as a regression, since the instructions say only clear consistency or regression risks should fail the check, not optional preferences. That comment about rich-mode-only rendering explains an implementation constraint rather than a browser or platform one, so I'm not sure it counts as the kind of intentional exception I should preserve. I want to check whether there's a mobile plain-text input that also lacks surround behavior, which would mean the plain-mode gap is actually consistent with how mobile already works.
16. browse_code — How does mobile answer a pending question in the chat composer? Is there a separate plain /<textarea> for pending-question custom answers on mobile (e.g. in ChatComposer or a PendingQuestion component)? Show the component and element used.
💭 Mobile's plain TextInput reinforces that plain mode is meant to be a consistent plain-string contract across platforms, so losing surround-selection typing in web plain mode seems like a defensible design choice rather than a bug. Still, since it's a regression on the same control, I'm leaning toward not failing on it outright, though the check defaults to failure unless explicitly justified. Let me dig deeper into the focus-restore layout effect, since that's where the diff actually touches interaction behavior. I'm tracing through what happens when a user is mid-typing in the draft composer and a new question arrives — React swaps the keyed LexicalComposer subtree, and I need to work out the commit-phase ordering between the old tree's unmount and the new tree's mount, since that determines whether the layout effect can reliably restore focus. I'm now checking whether the controlled-sync effect and the focus-restore effect fire in the right order within the same component, and whether the fresh mount's initial value actually reflects the latest
valuegiven howinitialValueRef.currentgets set during render versus when the memoizedinitialConfigcaptures it. I trace through the flip scenario where a question arrives mid-draft: focus gets forcibly restored into the new plain-text answer editor, with the cursor clamped to 0 since the composer's old cursor position doesn't apply to the now-empty value. Then I start considering the reverse flip, rich→plain when a question resolves while the user is mid-answer. Good, that converges. Now I'm checking whether composerMenuOpen's gating logic still holds correctly in plain answer mode, since it affects keyboard handling for Enter/Escape/arrows in the composer — that seems fine since composerTrigger is already nulled out in plain mode. I want to double check how Enter submits the answer in this mode too. Looking at the call site, terminalContexts gets passed as an empty array whenever pendingUserInputs.length is nonzero, meaning a pending question clears the terminal contexts from the editor entirely. I'm tracing whether this causes any issue in ComposerPromptEditorInner's sync effect, but since it's a fresh mount initializing from current props, that path looks safe — though dropping terminal contexts while a question is open seems like pre-existing behavior worth double-checking against how value/terminalContexts are normally switched. I'm also checking whether the draft's terminal-context chips disappearing is a new issue or pre-existing, and confirming that onPromptChange in plain mode already skips syncing terminal context IDs by design. The real concern remains the surround-selection loss, and I want to verify whether this PR — which introduces a new plainText prop, state transitions, and remount/focus handling — includes any test coverage, since the diff doesn't show test changes despite ComposerPromptEditor.test.ts existing. Given the check's guidance to require focused tests when primitive composition changes behavior or state transitions, I'm weighing whether the missing tests for the new plainText contract (cursor mapping, plugin gating, focus restore) count as a legitimate, concrete violation versus just a general gap, and I want to look at what test infrastructure already exists for ChatComposer to judge whether such tests are even feasible to write.
17. browse_code — apps/web/src/**/composertest*
18. browse_code — apps/web/src/components/**/.test.
💭 The PR lacks tests for the new plainText contract covering cursor mapping, plugin gating, and focus restore—a defensible but weak finding on its own. I want to dig deeper for a more concrete regression, so I'm going to examine the composer code directly. Checking the focus restore effect again, the dependencies look stable per mount, so it should only run once. But I notice the cleanup sets the restore ref on every unmount without ever resetting it otherwise—I'm trying to think through whether there's a scenario where this causes a stale focus steal, like if the component stays mounted across thread switches or if focus is elsewhere when the remount happens. I'm also considering whether passing an empty skills array in plain mode has any side effects beyond the remount, and whether the restoreFocusOnRemountRef being added via an intersection type rather than the main props interface could cause issues elsewhere. I want to check if ComposerPromptEditor is used in other call sites that might be affected by these changes.
19. git_tools — ComposerPromptEditor
💭 The font preview editor setup looks unaffected by this change. Now I'm checking ComposerPendingUserInputPanel to see if clicking an option triggers a focus interaction with the composer. Clicking an option only updates the selected option, not customAnswer, so plainAnswerMode stays true and no remount occurs. Now I'm examining whether there's a cursor mapping bug in plain mode with newlines—checking whether the selection offset readers treat line breaks consistently between collapsed and expanded space, since plain mode has no tokens so both spaces should be identical. I'm also weighing whether the focus-restore fix has a real flaw, like not handling focus restoration when the editor is disabled, or relying on an assumption about React's commit ordering—specifically whether layout-effect cleanup runs before the host node is actually removed from the DOM during a deletion. That ordering assumption feels like the most promising thread to dig into further, though the other candidates seem weak. Deletions process before new mounts in the mutation phase, so the focus fix holds up. Now I'm checking whether
setPromptFromTraitsin plain mode might be a regression — it writes to the draft store without callingsetPromptdirectly, and I'm wondering if it's also missing a call toscheduleComposerFocus(). Actually that's intentional in plain mode, since focus shouldn't be stolen from an open answer. Toggling a trait while a question is open just means focus stays wherever it was, which seems acceptable. I'm also tracingdraftPromptRef's initialization and confirming it stays synced withprompteven when a question is already open at mount, and verifying thatpromptitself reflects the draft store value wheneversetPromptisn't being bypassed by the pending path. Now I'm checking the effect that syncspromptRefand cursor fromdraftPromptwhen a question resolves — it doesn't callsetPromptsincepromptis already the draft, which is consistent. I'm also confirming that other handlers likesubmitComposerandapplyPromptReplacement, which readpromptRefwhile a question is open, behave the same as before since that value was already holding the answer text. Overall the diff seems carefully handled, so I'm moving on to examine theplainAnswerModecondition more closely. Looking at the mismatch between howplainAnswerModeis computed versus whatonPromptChangeactually requires, I realize there's a real edge case: ifactivePendingProgressexists butactiveQuestionis null, typing in the editor would fall into the draft-prompt branch, updatingdraftPromptRefwhile the editor's boundvalueprop stays frozen on the oldcustomAnswer. That mismatch would cause the controlled sync effect to overwrite whatever the user just typed, reverting it back to the stale answer — but since this binding existed before the PR too, it's a pre-existing issue rather than something newly introduced. I want to check howactivePendingProgressis actually derived to see whetheractiveQuestioncan ever be null while it's still set, which would determine if this edge case is reachable at all.
20. git_tools — activePendingProgress|activePendingUserInput =|const pendingUserInputs
💭 I notice activeQuestion can be null in the type, so I should check where activePendingProgress gets constructed to see if that null case is actually reachable while progress is non-null.
21. browse_code — Where is the activePendingProgress prop passed to ChatComposer constructed (apps/web/src)? Show the code that builds the object with fields questionIndex, isLastQuestion, canAdvance, customAnswer, activeQuestion. Can activeQuestion be null while the object itself is non-null? Under what conditions?
💭 The zero-question edge case isn't a strong finding since it's pre-existing behavior. Looking at the diff itself, it only touches two TSX files with no CSS changes, so I'm focusing on interaction semantics: disabling inline token plugins in plain mode seems appropriate, but disabling surround-selection typing removes a keyboard behavior for question answers that previously worked. Trigger menus being disabled makes sense since answers shouldn't carry mentions or skills, and focus is preserved across the remount. The undo history resetting per mode is actually arguably an improvement over the old single-instance behavior where undo semantics across a value switch were already murky. The surround-typing plugin has dedicated dead-key handling, suggesting it's a deliberate typing feature whose loss in question-answering mode is a real keyboard behavior regression worth flagging. The minimal fix would be threading plainText into the surround-selection plugin and keeping it mounted in both modes. I'm weighing whether this counts as a "clear" regression worth an inline comment versus just an aesthetic nitpick — since it's an intentional simplification that costs a genuine text-editing convenience only in the pending-answer editor, I lean toward flagging it as a low-severity but real inconsistency, while being cautious about false positives. Before deciding, I want to verify whether skipping the token path in plain mode also skips anything else needed, like terminal context node setup, by checking the full implementation of the prompt-setting function.
22. browse_code — apps/web/src/components/ComposerPromptEditor.tsx
💭 I'm leaning toward flagging the surround-selection loss as a single low-severity inline comment. Since the check explicitly defaults to failure unless there are no findings, having this one finding means the state should be "failure" rather than defaulting to success. But I want to double-check whether this finding is actually strong enough to justify that, since the check also warns against flagging intentional editor-surface behavior or guessing without proof — the surround plugin's purpose in plain mode needs closer scrutiny before I commit to failing the check. I'm checking whether any DOM listeners for drag-and-drop or mention handling are attached directly to the contenteditable editor element rather than the stable wrapper div, since a remount would silently break that behavior. I also want to verify whether any effect queries the composer editor by test id in a way that assumes a single persistent instance.
23. git_tools — composer-editor|contenteditable|contentEditable
💭 I should check whether the drag/drop handlers attach to the stable composerSurfaceRef wrapper rather than the editor element itself, so they survive remounts—let me grep for onDrop in ChatComposer to confirm.
24. git_tools — onDrop|onDragOver|addEventListener
💭 The grep hit a binary-looking byte in that file, which isn't useful here. I've spent enough time digging, so I need to settle on a final assessment — weighing whether to flag the keyboard-interaction change or conclude everything looks fine, thinking about what a careful reviewer focused on UI consistency and shared components would actually call out in this diff. Looking closer, five of the six disabled plugins are token-specific, but the surround-selection one is a general editing convenience — that asymmetry is exactly the kind of interaction-semantics regression this check is meant to catch, and the fix is small: thread
plainTextthrough the plugin. I'll flag it as a single inline comment and mark the check as failing. I'm also checking whether keeping the surround plugin active in plain mode would actually break anything, since it touches the prompt-rewrite and cursor-collapse calls — both would needplainTextthreaded through for correctness, which is a bit more than a one-line fix but still self-contained, so I'll describe it in prose across both call sites rather than as a diff. Weighing severity, I lean toward treating this as the one finding and marking the check as failing, though I recognize the author could reasonably argue it's intentional — plain-text answers getting plain-text editing semantics, similar to mobile — which makes it more of a debatable design choice than a clear-cut bug. This is a real regression worth flagging, since the check cares about preserving keyboard behavior across composition changes. I'll post an inline comment on the surround plugin line in the diff, explaining that gating it on plainText silently drops the wrap-on-selection behavior for question answers that were previously edited in the same rich editor. I'll also note there's no inline suggestion since this spans multiple hunks, and briefly mention in the summary that the PR lacks tests covering the plainText contract, without making it a separate inline comment. Then I'll post the review.
25. modify_pr — One finding: the new plainText plugin gate also disables the non-token surround-selection typing behavior for question answers. Details inline.
Agent Credits: 413 credits